(() => { /** * 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, mapMeta = null) { if (!baseCanvas?.width || !outCanvas) return false; copyCanvas(baseCanvas, outCanvas); const list = Array.isArray(zones) ? zones : []; const linePx = (z) => window.MapObjects?.zoneLineWidthPx(z, mapMeta) ?? DEFAULT_LINE_WIDTH; list.filter((z) => z?.type === "floor").forEach((z) => bakeFloor(outCanvas, z.points)); list.filter((z) => z?.type === "wall").forEach((z) => bakeWall(outCanvas, z.points, linePx(z))); 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, mapMeta = null) { if (!zone?.points?.length) return false; if (zone.type === "wall") { const px = window.MapObjects?.zoneLineWidthPx(zone, mapMeta) ?? DEFAULT_LINE_WIDTH; return bakeWall(sourceCanvas, zone.points, px); } if (zone.type === "floor") return bakeFloor(sourceCanvas, zone.points); return false; } function unbakeZone(sourceCanvas, zone, mapMeta = null) { if (!zone?.points?.length) return false; if (zone.type === "wall") { const px = window.MapObjects?.zoneLineWidthPx(zone, mapMeta) ?? DEFAULT_LINE_WIDTH; return unbakeWall(sourceCanvas, zone.points, px + 2); } 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, }; })();