Files
App/www/map-occupancy-edit.js
HiepLM 365a15c32a
Some checks are pending
Test / test (push) Waiting to run
update full objects type
2026-06-20 11:43:48 +02:00

235 lines
7.0 KiB
JavaScript

(() => {
/**
* map_server grayscale editing — bake walls/floors and brush-erase on floor-plan raster.
* Values match ROS map_server convention (see map-occupancy-canvas.js).
*/
const GRAY = {
free: 254,
occupied: 0,
unknown: 205,
};
const DEFAULT_LINE_WIDTH = 3;
const DEFAULT_BRUSH_RADIUS = 10;
function ensureCanvas(canvas, width, height) {
if (!canvas) return null;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
return canvas.getContext("2d");
}
function copyCanvas(src, dst) {
if (!src?.width || !dst) return false;
const ctx = ensureCanvas(dst, src.width, src.height);
if (!ctx) return false;
ctx.drawImage(src, 0, 0);
return true;
}
/**
* Composite = base scan + floor polygons (free) + wall polylines (occupied).
* @param {HTMLCanvasElement} baseCanvas
* @param {HTMLCanvasElement} outCanvas
* @param {object[]} zones
*/
function rebakeComposite(baseCanvas, outCanvas, zones) {
if (!baseCanvas?.width || !outCanvas) return false;
copyCanvas(baseCanvas, outCanvas);
const list = Array.isArray(zones) ? zones : [];
list.filter((z) => z?.type === "floor").forEach((z) => bakeFloor(outCanvas, z.points));
list.filter((z) => z?.type === "wall").forEach((z) => bakeWall(outCanvas, z.points));
return true;
}
/** Load PNG pixels into editable grayscale source canvas. */
function initSourceFromImage(sourceCanvas, imageEl) {
if (!sourceCanvas || !imageEl?.naturalWidth) return false;
const w = imageEl.naturalWidth;
const h = imageEl.naturalHeight;
const ctx = ensureCanvas(sourceCanvas, w, h);
if (!ctx) return false;
ctx.drawImage(imageEl, 0, 0, w, h);
return true;
}
function cloneRaster(sourceCanvas) {
if (!sourceCanvas?.width) return null;
const ctx = sourceCanvas.getContext("2d");
if (!ctx) return null;
return ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
}
function restoreRaster(sourceCanvas, imageData) {
if (!sourceCanvas || !imageData) return false;
const ctx = ensureCanvas(sourceCanvas, imageData.width, imageData.height);
if (!ctx) return false;
ctx.putImageData(imageData, 0, 0);
return true;
}
function grayRgb(gray) {
const g = Math.max(0, Math.min(255, gray | 0));
return `rgb(${g},${g},${g})`;
}
/** Paint display canvas from grayscale source using map_server thresholds. */
function renderDisplayFromSource(displayCanvas, sourceCanvas, meta, occModule) {
const occ = occModule || window.MapOccupancyCanvas;
if (!occ?.renderFromImage || !displayCanvas || !sourceCanvas?.width) return false;
return occ.renderFromImage(displayCanvas, sourceCanvas, meta || {});
}
function strokePolyline(ctx, points, gray, lineWidth = DEFAULT_LINE_WIDTH) {
if (!ctx || !points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = grayRgb(gray);
ctx.lineWidth = lineWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.restore();
}
function fillPolygon(ctx, points, gray) {
if (!ctx || !points || points.length < 3) return;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.fill();
ctx.restore();
}
/** Bake wall polyline as occupied pixels. */
function bakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
strokePolyline(ctx, points, GRAY.occupied, lineWidth);
return true;
}
/** Bake floor polygon as free walkable pixels. */
function bakeFloor(sourceCanvas, points) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
fillPolygon(ctx, points, GRAY.free);
return true;
}
/** Remove baked wall by painting free along its path. */
function unbakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH + 2) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
strokePolyline(ctx, points, GRAY.free, lineWidth);
return true;
}
function bakeZone(sourceCanvas, zone) {
if (!zone?.points?.length) return false;
if (zone.type === "wall") return bakeWall(sourceCanvas, zone.points);
if (zone.type === "floor") return bakeFloor(sourceCanvas, zone.points);
return false;
}
function unbakeZone(sourceCanvas, zone) {
if (!zone?.points?.length) return false;
if (zone.type === "wall") return unbakeWall(sourceCanvas, zone.points);
if (zone.type === "floor") {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
fillPolygon(ctx, zone.points, GRAY.unknown);
return true;
}
return false;
}
function paintBrush(sourceCanvas, x, y, layer, radius = DEFAULT_BRUSH_RADIUS) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
}
/** Erase pixels inside axis-aligned rectangle on base layer. */
function eraseRect(sourceCanvas, x0, y0, x1, y1, layer) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
const x = Math.min(x0, x1);
const y = Math.min(y0, y1);
const w = Math.abs(x1 - x0);
const h = Math.abs(y1 - y0);
if (w < 1 || h < 1) return false;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.fillRect(x, y, w, h);
ctx.restore();
return true;
}
/** Interpolated brush stroke between two image-space points. */
function paintBrushStroke(sourceCanvas, x0, y0, x1, y1, layer, radius = DEFAULT_BRUSH_RADIUS) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const step = Math.max(1, radius * 0.5);
const n = Math.max(1, Math.ceil(dist / step));
for (let i = 0; i <= n; i++) {
const t = i / n;
paintBrush(sourceCanvas, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, layer, radius);
}
}
function exportPngBlob(sourceCanvas) {
return new Promise((resolve, reject) => {
if (!sourceCanvas?.width) {
reject(new Error("no source canvas"));
return;
}
sourceCanvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error("export failed"));
},
"image/png",
);
});
}
window.MapOccupancyEdit = {
GRAY,
DEFAULT_LINE_WIDTH,
DEFAULT_BRUSH_RADIUS,
initSourceFromImage,
cloneRaster,
restoreRaster,
copyCanvas,
rebakeComposite,
renderDisplayFromSource,
bakeWall,
bakeFloor,
bakeZone,
unbakeZone,
paintBrush,
paintBrushStroke,
eraseRect,
exportPngBlob,
};
})();