UNPKG

iobroker.roborock

Version:
1,468 lines (1,296 loc) 44.9 kB
import * as fs from "node:fs"; import { createCanvas, Image, loadImage } from "@napi-rs/canvas"; import type { B01DeviceStatus, B01MapData } from "../b01/types"; import { Q10AssetCatalog, resolveQ10PluginAssetPath } from "./Q10AssetCatalog"; import { Q10_CANVAS_SCALE, getQ10ExportCanvasScale, Q10MapGeometry } from "./Q10MapGeometry"; import type { Q10CreatorArea, Q10CreatorData, Q10CreatorLine, Q10CreatorObstacle, Q10CreatorPathPoint, Q10CreatorSuspectedPoint, Q10MapPixelPoint, Q10PixelPose } from "./types"; const Q10_LAYOUT = { areaStrokeWidth: 2, areaMopDash: 3, forbidLineIconSize: 12, thresholdRowShiftRatio: 0.63 } as const; const DARK_MAP_COLORS = { wall: 1836349183, inWall: 1940580863, rooms: [1940580863, 3854457599, 3648937983, 634505215] as const, roomTagBase: [4279123053, 4283645184, 4286455337, 4278537798] as const, roomTagStroke: [4278528336, 4281147648, 4284156949, 4278202925] as const, forbidLine: 4294919482, forbidFill: 872367418, eraseFill: 872387840, eraseBase: 4294939904, thresholdBase: 4292136800, text: 3426499651 } as const; interface LoadedQ10Assets { device?: Image; power?: Image; forbidlineIcon?: Image; obstacle?: Image; tiaoGuoIcon?: Image; mapCarpetMaterial?: Image; mapThresholdMaterial?: Image; roomTags: Map<number, Image>; suspectedThreshold?: Image; suspectedEasycard?: Image; suspectedCliff?: Image; } interface Q10RenderMetrics { baseIconSize: number; roomFontSize: number; roomBubbleDiameter: number; roomGap: number; roomIconSize: number; roomBadgeRadius: number; } interface Q10PathLayerStyle { strokeStyle: string; lineWidth: number; dash?: number[]; dashOffset?: number; } type Q10OriginalMaterialKind = "ceramicTile" | "horizontalFloorBoard" | "verticalFloorBoard"; interface Q10RenderedMaps { full: Buffer; clean: Buffer; } function packedColorToRgbaBytes(color: number): [number, number, number, number] { return [ (color >>> 24) & 0xff, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff ]; } function packedArgbToCss(color: number, alphaOverride?: number): string { const a = ((color >>> 24) & 0xff) / 255; const r = (color >>> 16) & 0xff; const g = (color >>> 8) & 0xff; const b = color & 0xff; const alpha = alphaOverride ?? a; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function getRenderMetrics(): Q10RenderMetrics { const kImgRate = Q10_CANVAS_SCALE; return { baseIconSize: 8 * kImgRate, roomFontSize: 10, roomBubbleDiameter: 12, roomGap: 4, roomIconSize: 6, roomBadgeRadius: 6 }; } function imageWidth(image?: Image): number { return Number(image?.width ?? image?.naturalWidth ?? 0); } function imageHeight(image?: Image): number { return Number(image?.height ?? image?.naturalHeight ?? 0); } function drawCenteredAsset( ctx: any, image: Image | undefined, x: number, y: number, drawWidth: number, rotationDeg = 0 ): void { if (!image) return; const width = imageWidth(image); const height = imageHeight(image); if (width <= 0 || height <= 0) return; const scale = Math.min(drawWidth / width, drawWidth / height); const fittedWidth = width * scale; const fittedHeight = height * scale; ctx.save(); ctx.translate(x, y); if (rotationDeg) ctx.rotate((rotationDeg * Math.PI) / 180); ctx.drawImage(image as any, -fittedWidth / 2, -fittedHeight / 2, fittedWidth, fittedHeight); ctx.restore(); } function drawCenteredSpriteWidthScaled( ctx: any, image: Image | undefined, x: number, y: number, targetWidth: number, rotationDeg = 0 ): void { if (!image) return; const width = imageWidth(image); const height = imageHeight(image); if (width <= 0 || height <= 0) return; const scale = targetWidth / width; const drawWidth = width * scale; const drawHeight = height * scale; ctx.save(); ctx.translate(x, y); if (rotationDeg) ctx.rotate((rotationDeg * Math.PI) / 180); ctx.drawImage(image as any, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight); ctx.restore(); } function drawCenteredCoverSquareAsset( ctx: any, image: Image | undefined, x: number, y: number, size: number, rotationDeg = 0 ): void { if (!image) return; const width = imageWidth(image); const height = imageHeight(image); if (width <= 0 || height <= 0) return; const scale = Math.max(size / width, size / height); const drawWidth = width * scale; const drawHeight = height * scale; ctx.save(); ctx.translate(x, y); if (rotationDeg) ctx.rotate((rotationDeg * Math.PI) / 180); ctx.beginPath(); ctx.rect(-size / 2, -size / 2, size, size); ctx.clip(); ctx.drawImage(image as any, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight); ctx.restore(); } interface ImageOpaqueBounds { sx: number; sy: number; sw: number; sh: number; } function fillPolygon(ctx: any, points: Q10MapPixelPoint[]): void { if (!points.length) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let index = 1; index < points.length; index++) { ctx.lineTo(points[index].x, points[index].y); } ctx.closePath(); } function isMaterialMaskCellWalkable(mask: Uint8Array, width: number, height: number, x: number, y: number): boolean { return x >= 0 && x < width && y >= 0 && y < height && mask[y * width + x] === 1; } function buildRoomMaterialMaskGrid( baseGrid: Buffer, width: number, height: number, roomIds: number[] ): Uint8Array { const roomIdSet = new Set<number>(roomIds); const mask = new Uint8Array(width * height); for (let index = 0; index < width * height; index++) { if (roomIdSet.has(baseGrid[index] ?? 0)) { mask[index] = 1; } } return mask; } function pairMaterialSegments(points: Q10MapPixelPoint[]): Q10MapPixelPoint[][] { const pairs: Q10MapPixelPoint[][] = []; const pairCount = Math.floor(points.length / 2); for (let index = 0; index < pairCount; index++) { pairs.push([ points[index * 2]!, points[index * 2 + 1]! ]); } return pairs; } function clipHorizontalMaterialPath( startX: number, endX: number, y: number, mask: Uint8Array, width: number, height: number ): Q10MapPixelPoint[][] { const subPoints: Q10MapPixelPoint[] = []; let last = 0; for (let x = startX; x <= endX; x++) { const point = { x, y }; const isValid = isMaterialMaskCellWalkable(mask, width, height, x, y); if (!isValid) { if (last === 1 || last === 2) { subPoints.push(point); last = 0; } continue; } if (last === 0) { subPoints.push(point); last = 1; } else { last = 2; } if (x === endX) { subPoints.push(point); } } return pairMaterialSegments(subPoints); } function clipVerticalMaterialPath( x: number, startY: number, endY: number, mask: Uint8Array, width: number, height: number ): Q10MapPixelPoint[][] { const subPoints: Q10MapPixelPoint[] = []; let last = 0; for (let y = startY; y <= endY; y++) { const point = { x, y }; const isValid = isMaterialMaskCellWalkable(mask, width, height, x, y); if (!isValid) { if (last === 1 || last === 2) { subPoints.push(point); last = 0; } continue; } if (last === 0) { subPoints.push(point); last = 1; } else { last = 2; } if (y === endY) { subPoints.push(point); } } return pairMaterialSegments(subPoints); } function buildCeramicTileMaterialPaths( mask: Uint8Array, width: number, height: number, resolution: number ): Q10MapPixelPoint[][] { const wStep = Math.max(1, Math.floor(0.8 / resolution)); const hStep = Math.max(1, Math.floor(0.8 / resolution)); const paths: Q10MapPixelPoint[][] = []; for (let x = 0; x <= width; x++) { if (x % wStep === 0) { paths.push(...clipVerticalMaterialPath(x, 0, height, mask, width, height)); } } for (let y = 0; y <= height; y++) { if (y % hStep === 0) { paths.push(...clipHorizontalMaterialPath(0, width, y, mask, width, height)); } } return paths; } function buildHorizontalFloorBoardMaterialPaths( mask: Uint8Array, width: number, height: number, resolution: number ): Q10MapPixelPoint[][] { const materialW = 1.2; const materialH = 0.3; let wStep = Math.max(1, Math.floor(materialW / resolution)); const hStep = Math.max(1, Math.floor(materialH / resolution)); const paths: Q10MapPixelPoint[][] = []; for (let y = 0; y <= height; y++) { if (y % hStep === 0) { paths.push(...clipHorizontalMaterialPath(0, width, y, mask, width, height)); } } wStep = wStep / 2; let columnIndex = 0; for (let x = 0; x <= width; x++) { if (x % wStep !== 0) continue; columnIndex += 1; let points: Q10MapPixelPoint[] = []; for (let y = 0; y <= height; y++) { if (y % hStep !== 0) continue; points.push({ x, y }); } if (columnIndex % 2 === 1) { if (Math.floor(points.length % 2) === 1) { points = points.slice(0, points.length - 1); } } else { if (points.length > 0) { points = points.slice(1); } if (Math.floor(points.length % 2) === 1) { points = points.slice(0, points.length - 1); } } for (let index = 0; index < points.length / 2; index++) { const start = points[index * 2]!; const end = points[index * 2 + 1]!; paths.push(...clipVerticalMaterialPath(start.x, start.y, end.y, mask, width, height)); } } return paths; } function buildVerticalFloorBoardMaterialPaths( mask: Uint8Array, width: number, height: number, resolution: number ): Q10MapPixelPoint[][] { const materialW = 0.3; const materialH = 1.2; const wStep = Math.max(1, Math.floor(materialW / resolution)); let hStep = Math.max(1, Math.floor(materialH / resolution)); const paths: Q10MapPixelPoint[][] = []; for (let x = 0; x <= width; x++) { if (x % wStep === 0) { paths.push(...clipVerticalMaterialPath(x, 0, height, mask, width, height)); } } hStep = hStep / 2; let rowIndex = 0; for (let y = 0; y <= height; y++) { if (y % hStep !== 0) continue; rowIndex += 1; let points: Q10MapPixelPoint[] = []; for (let x = 0; x <= width; x++) { if (x % wStep !== 0) continue; points.push({ x, y }); } if (rowIndex % 2 === 1) { if (Math.floor(points.length % 2) === 1) { points = points.slice(0, points.length - 1); } } else { if (points.length > 0) { points = points.slice(1); } if (Math.floor(points.length % 2) === 1) { points = points.slice(0, points.length - 1); } } for (let index = 0; index < points.length / 2; index++) { const start = points[index * 2]!; const end = points[index * 2 + 1]!; paths.push(...clipHorizontalMaterialPath(start.x, end.x, start.y, mask, width, height)); } } return paths; } function measureRoomText( ctx: any, text: string, fontSize: number ): { width: number; ascent: number; descent: number; height: number } { const metrics = ctx.measureText(text); const fontMetrics = metrics as TextMetrics & { fontBoundingBoxAscent?: number; fontBoundingBoxDescent?: number; }; const ascent = fontMetrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent || fontSize * 0.78; const descent = fontMetrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent || fontSize * 0.22; return { width: metrics.width, ascent, descent, height: ascent + descent }; } export class Q10MapBuilder { private assetsLoadedForModel: string | null = null; private assets: LoadedQ10Assets = this.createEmptyAssets(); private opaqueBoundsCache = new WeakMap<Image, ImageOpaqueBounds>(); constructor(private adapter?: any) {} private createEmptyAssets(): LoadedQ10Assets { return { roomTags: new Map<number, Image>() }; } private async loadImageIfExists(filePath: string): Promise<Image | undefined> { if (!fs.existsSync(filePath)) return undefined; try { return await loadImage(fs.readFileSync(filePath)); } catch { return undefined; } } private toImageBuffer(fileData: unknown): Buffer | undefined { if (!fileData) return undefined; if (Buffer.isBuffer(fileData)) return fileData; if (typeof fileData === "object" && fileData !== null && "file" in fileData) { const file = (fileData as { file: unknown }).file; if (Buffer.isBuffer(file)) return file; if (file instanceof Uint8Array) return Buffer.from(file); if (file instanceof ArrayBuffer) return Buffer.from(file); if (typeof file === "string") return Buffer.from(file); return undefined; } if (fileData instanceof Uint8Array) return Buffer.from(fileData); if (fileData instanceof ArrayBuffer) return Buffer.from(fileData); if (typeof fileData === "string") return Buffer.from(fileData); return undefined; } private async loadImageFromAdapterAssets(relativePath: string, robotModel?: string): Promise<Image | undefined> { if ( !robotModel || !this.adapter?.name || typeof this.adapter.fileExistsAsync !== "function" || typeof this.adapter.readFileAsync !== "function" ) { return undefined; } const assetPath = `assets/${robotModel}/${relativePath}`; const namespaces = this.adapter.name.includes(".") ? [this.adapter.name, this.adapter.name.split(".")[0]] : [this.adapter.name]; for (const namespace of namespaces) { try { if (!(await this.adapter.fileExistsAsync(namespace, assetPath))) continue; const fileData = await this.adapter.readFileAsync(namespace, assetPath); const buffer = this.toImageBuffer(fileData); if (!buffer) continue; return await loadImage(buffer); } catch { // try next namespace } } return undefined; } private async loadImageAsset(relativePath: string, robotModel?: string): Promise<Image | undefined> { return ( (await this.loadImageFromAdapterAssets(relativePath, robotModel)) || (await this.loadImageIfExists(resolveQ10PluginAssetPath(relativePath))) ); } private async ensureAssets(robotModel?: string): Promise<void> { const modelCacheKey = robotModel ?? ""; if (this.assetsLoadedForModel === modelCacheKey) return; this.assets = this.createEmptyAssets(); this.assets.device = await this.loadImageAsset(Q10AssetCatalog.device, robotModel); this.assets.power = await this.loadImageAsset(Q10AssetCatalog.power, robotModel); this.assets.forbidlineIcon = await this.loadImageAsset(Q10AssetCatalog.forbidlineIcon, robotModel); this.assets.obstacle = await this.loadImageAsset(Q10AssetCatalog.obstacle, robotModel); this.assets.tiaoGuoIcon = await this.loadImageAsset(Q10AssetCatalog.tiaoGuoIcon, robotModel); this.assets.mapCarpetMaterial = await this.loadImageAsset(Q10AssetCatalog.mapCarpetMaterial, robotModel); this.assets.mapThresholdMaterial = await this.loadImageAsset(Q10AssetCatalog.mapThresholdMaterial, robotModel); this.assets.suspectedThreshold = await this.loadImageAsset(Q10AssetCatalog.yisiMenkan, robotModel); this.assets.suspectedEasycard = await this.loadImageAsset(Q10AssetCatalog.yisiYika, robotModel); this.assets.suspectedCliff = await this.loadImageAsset(Q10AssetCatalog.yisiXuanya, robotModel); for (let roomType = 0; roomType < Q10AssetCatalog.roomTags.length; roomType++) { const image = await this.loadImageAsset(Q10AssetCatalog.roomTags[roomType], robotModel); if (image) this.assets.roomTags.set(roomType, image); } this.assetsLoadedForModel = modelCacheKey; } private getOpaqueBounds(image: Image | undefined): ImageOpaqueBounds | undefined { if (!image) return undefined; const cached = this.opaqueBoundsCache.get(image); if (cached) return cached; const width = imageWidth(image); const height = imageHeight(image); if (width <= 0 || height <= 0) return undefined; const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); ctx.drawImage(image as any, 0, 0, width, height); const pixels = ctx.getImageData(0, 0, width, height).data; let minX = width; let minY = height; let maxX = -1; let maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const alpha = pixels[(y * width + x) * 4 + 3] ?? 0; if (alpha <= 8) continue; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; } } const bounds = maxX >= minX && maxY >= minY ? { sx: minX, sy: minY, sw: maxX - minX + 1, sh: maxY - minY + 1 } : { sx: 0, sy: 0, sw: width, sh: height }; this.opaqueBoundsCache.set(image, bounds); return bounds; } private drawCenteredOpaqueAsset( ctx: any, image: Image | undefined, x: number, y: number, drawWidth: number, rotationDeg = 0 ): void { if (!image) return; const bounds = this.getOpaqueBounds(image); if (!bounds || bounds.sw <= 0 || bounds.sh <= 0) { drawCenteredAsset(ctx, image, x, y, drawWidth, rotationDeg); return; } const scale = Math.min(drawWidth / bounds.sw, drawWidth / bounds.sh); const fittedWidth = bounds.sw * scale; const fittedHeight = bounds.sh * scale; ctx.save(); ctx.translate(x, y); if (rotationDeg) ctx.rotate((rotationDeg * Math.PI) / 180); ctx.drawImage( image as any, bounds.sx, bounds.sy, bounds.sw, bounds.sh, -fittedWidth / 2, -fittedHeight / 2, fittedWidth, fittedHeight ); ctx.restore(); } private drawBaseMap(ctx: any, data: B01MapData, creator: Q10CreatorData): void { const width = data.header.sizeX; const height = data.header.sizeY; const tempCanvas = createCanvas(width, height); const tempCtx = tempCanvas.getContext("2d"); const imageData = tempCtx.createImageData(width, height); const buffer = imageData.data; const roomColorMap = new Map<number, number>(); for (const room of creator.roomModels) roomColorMap.set(room.gridValue, room.colorID); for (let index = 0; index < data.mapGrid.length; index++) { const value = data.mapGrid[index]; const offset = index * 4; if (value === 127) { buffer[offset] = 0; buffer[offset + 1] = 0; buffer[offset + 2] = 0; buffer[offset + 3] = 0; continue; } let color: number = DARK_MAP_COLORS.inWall; if (value >= 128) color = DARK_MAP_COLORS.wall; else if (value > 1) { const roomColor = roomColorMap.get(value) ?? (value - 1) % DARK_MAP_COLORS.rooms.length; color = DARK_MAP_COLORS.rooms[roomColor] ?? DARK_MAP_COLORS.rooms[0]; } const [r, g, b, a] = packedColorToRgbaBytes(color); buffer[offset] = r; buffer[offset + 1] = g; buffer[offset + 2] = b; buffer[offset + 3] = a || 255; } tempCtx.putImageData(imageData, 0, 0); ctx.drawImage(tempCanvas as any, 0, 0); } private withOutputSpace(ctx: any, geometry: Q10MapGeometry, draw: (outputCtx: any) => void): void { ctx.save(); const scale = Math.max(geometry.canvasScaleValue(), 0.001); ctx.scale(1 / scale, 1 / scale); ctx.imageSmoothingEnabled = true; draw(ctx); ctx.restore(); } private withMapSpace(ctx: any, geometry: Q10MapGeometry, draw: (mapCtx: any) => void): void { ctx.save(); const scale = Math.max(geometry.canvasScaleValue(), 0.001); ctx.scale(scale, scale); draw(ctx); ctx.restore(); } public buildSelfIdentifiedCarpetSourceCanvas(carpet: Q10CreatorData["selfIdentifiedCarpets"][number]): any | null { if (carpet.width <= 0 || carpet.height <= 0 || !carpet.mask.length) return null; const sourceWidth = carpet.width * 3; const sourceHeight = carpet.height * 3; const canvas = createCanvas(sourceWidth, sourceHeight); const carpetCtx = canvas.getContext("2d"); const imageData = carpetCtx.createImageData(sourceWidth, sourceHeight); const pixels = imageData.data; const setMaskPixel = (x: number, y: number): void => { const offset = (y * sourceWidth + x) * 4; pixels[offset] = 0; pixels[offset + 1] = 0; pixels[offset + 2] = 0; pixels[offset + 3] = 120; }; for (let index = 0; index < carpet.mask.length; index++) { if (carpet.mask[index] !== 1) continue; const localX = index % carpet.width; const localY = Math.floor(index / carpet.width); const pixelX = localX * 3; const pixelY = localY * 3; setMaskPixel(pixelX + 2, pixelY); setMaskPixel(pixelX + 1, pixelY + 1); setMaskPixel(pixelX, pixelY + 2); } carpetCtx.putImageData(imageData, 0, 0); return canvas; } private drawSelfIdentifiedCarpets(ctx: any, data: B01MapData, creator: Q10CreatorData): void { if (!creator.selfIdentifiedCarpets.length) return; const renderScale = getQ10ExportCanvasScale(data.header.sizeX, data.header.sizeY); for (const carpet of creator.selfIdentifiedCarpets) { this.drawSelfIdentifiedCarpetToExport(ctx, carpet, renderScale); } } private drawSelfIdentifiedCarpetToExport( ctx: any, carpet: Q10CreatorData["selfIdentifiedCarpets"][number], renderScale: number ): void { if (carpet.width <= 0 || carpet.height <= 0 || !carpet.mask.length) return; const sourceCanvas = this.buildSelfIdentifiedCarpetSourceCanvas(carpet); if (!sourceCanvas) return; const destX = carpet.lt.x * renderScale; const destY = carpet.lt.y * renderScale; const destWidth = (carpet.rb.x - carpet.lt.x) * renderScale; const destHeight = (carpet.rb.y - carpet.lt.y) * renderScale; if (destWidth <= 0 || destHeight <= 0) return; ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(sourceCanvas as any, destX, destY, destWidth, destHeight); ctx.restore(); } private buildOriginalMaterialPaths( data: B01MapData, creator: Q10CreatorData, roomIds: number[], kind: Q10OriginalMaterialKind ): Q10MapPixelPoint[][] { if (!roomIds.length) return []; const width = data.header.sizeX; const height = data.header.sizeY; const baseGrid = creator.clipEraseMapGrid && creator.clipEraseMapGrid.length === width * height ? creator.clipEraseMapGrid : data.mapGrid; const mask = buildRoomMaterialMaskGrid(baseGrid, width, height, roomIds); if (kind === "ceramicTile") { return buildCeramicTileMaterialPaths(mask, width, height, data.header.resolution); } if (kind === "horizontalFloorBoard") { return buildHorizontalFloorBoardMaterialPaths(mask, width, height, data.header.resolution); } return buildVerticalFloorBoardMaterialPaths(mask, width, height, data.header.resolution); } private drawRoomMaterials( ctx: any, geometry: Q10MapGeometry, data: B01MapData, creator: Q10CreatorData ): void { const ceramicTilePaths = creator.materialPaths.ceramicTile.length ? creator.materialPaths.ceramicTile : this.buildOriginalMaterialPaths(data, creator, creator.roomMaterialRoomIds.ceramicTile, "ceramicTile"); const horizontalFloorBoardPaths = creator.materialPaths.horizontalFloorBoard.length ? creator.materialPaths.horizontalFloorBoard : this.buildOriginalMaterialPaths(data, creator, creator.roomMaterialRoomIds.horizontalFloorBoard, "horizontalFloorBoard"); const verticalFloorBoardPaths = creator.materialPaths.verticalFloorBoard.length ? creator.materialPaths.verticalFloorBoard : this.buildOriginalMaterialPaths(data, creator, creator.roomMaterialRoomIds.verticalFloorBoard, "verticalFloorBoard"); const materialMapRate = geometry.canvasScaleValue(); this.drawMaterialPathGroup(ctx, ceramicTilePaths, materialMapRate); this.drawMaterialPathGroup(ctx, horizontalFloorBoardPaths, materialMapRate); this.drawMaterialPathGroup(ctx, verticalFloorBoardPaths, materialMapRate); } private drawMaterialPathGroup(ctx: any, polygons: Q10MapPixelPoint[][], mapRate: number): void { if (!polygons.length) return; ctx.save(); ctx.strokeStyle = packedArgbToCss(419430400); ctx.lineWidth = 2 / Math.max(mapRate, 1); ctx.lineJoin = "round"; ctx.lineCap = "round"; for (const polygon of polygons) { if (polygon.length < 2) continue; ctx.beginPath(); ctx.moveTo(polygon[0].x, polygon[0].y); for (let index = 1; index < polygon.length; index++) { ctx.lineTo(polygon[index].x, polygon[index].y); } ctx.stroke(); } ctx.restore(); } private drawThresholdArea( ctx: any, geometry: Q10MapGeometry, area: Q10CreatorArea ): void { if (area.points.length < 4) return; const placement = geometry.areaPlacement(area, "map"); const { centerX, centerY, width, height, angleRad } = placement; if (width <= 0 || height <= 0) return; let tileWidth = geometry.quarterMeterTileLengthInMap(); let tileHeight = tileWidth; const image = this.assets.mapThresholdMaterial; const sourceWidth = imageWidth(image) || tileWidth; const sourceHeight = imageHeight(image) || tileHeight; let imageScale = tileWidth / sourceWidth; if (imageScale <= 0.02) { imageScale = 0.02; tileWidth = sourceWidth * imageScale; tileHeight = sourceHeight * imageScale; } else { tileHeight = sourceHeight * imageScale; } const columns = Math.floor(width / tileWidth) + 2; const rows = Math.floor(height / tileHeight) + 2; if (columns <= 0 || rows <= 0) return; ctx.save(); fillPolygon(ctx, area.points); ctx.clip(); ctx.translate(centerX, centerY); ctx.rotate(angleRad); if (!image) { ctx.fillStyle = packedArgbToCss(DARK_MAP_COLORS.thresholdBase, 0.82); ctx.fillRect(-width / 2, -height / 2, width, height); ctx.restore(); return; } for (let row = 0; row < rows; row++) { const drawY = -height / 2 + row * tileHeight; const rowShift = (tileWidth * Q10_LAYOUT.thresholdRowShiftRatio * row) % tileWidth; for (let column = 0; column < columns; column++) { const drawX = -width / 2 + column * tileWidth - rowShift; ctx.drawImage(image as any, drawX, drawY, tileWidth, tileHeight); } } ctx.restore(); } private drawAreas( ctx: any, geometry: Q10MapGeometry, areas: Q10CreatorArea[], mode: "erase" | "forbid" | "mop" | "threshold" ): void { if (!areas.length) return; const strokeWidth = geometry.layoutLengthInMap(Q10_LAYOUT.areaStrokeWidth); const dashLength = geometry.layoutLengthInMap(Q10_LAYOUT.areaMopDash); const fillColor = packedArgbToCss( mode === "erase" ? DARK_MAP_COLORS.eraseFill : DARK_MAP_COLORS.forbidFill ); const strokeColor = packedArgbToCss( mode === "erase" ? DARK_MAP_COLORS.eraseBase : DARK_MAP_COLORS.forbidLine ); const dashPattern = mode === "mop" ? [dashLength, dashLength] : []; for (const area of areas) { if (area.points.length < 3) continue; if (mode === "threshold") { this.drawThresholdArea(ctx, geometry, area); continue; } ctx.save(); fillPolygon(ctx, area.points); ctx.fillStyle = fillColor; ctx.fill(); ctx.restore(); ctx.save(); ctx.strokeStyle = strokeColor; ctx.lineWidth = strokeWidth; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.setLineDash(dashPattern); fillPolygon(ctx, area.points); ctx.stroke(); ctx.restore(); } } private drawAreaMaterialAtlas( ctx: any, geometry: Q10MapGeometry, area: Q10CreatorArea, image: Image | undefined, rowShiftRatio = 0 ): void { if (area.points.length < 4) return; const placement = geometry.areaPlacement(area, "canvas"); const { centerX, centerY, width, height, angleRad } = placement; if (width <= 0 || height <= 0) return; let tileWidth = Math.max(1, geometry.quarterMeterTileLength()); let tileHeight = tileWidth; const sourceWidth = imageWidth(image) || tileWidth; const sourceHeight = imageHeight(image) || tileHeight; let imageScale = tileWidth / sourceWidth; if (imageScale <= 0.02) { imageScale = 0.02; tileWidth = sourceWidth * imageScale; tileHeight = sourceHeight * imageScale; } else { tileHeight = sourceHeight * imageScale; } const columns = Math.floor(width / tileWidth) + 2; const rows = Math.floor(height / tileHeight) + 2; if (columns <= 0 || rows <= 0) return; ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(angleRad); ctx.beginPath(); ctx.rect(-width / 2, -height / 2, width, height); ctx.clip(); if (!image) { ctx.fillStyle = "rgba(255, 255, 255, 0.22)"; ctx.fillRect(-width / 2, -height / 2, width, height); ctx.restore(); return; } for (let row = 0; row < rows; row++) { const drawY = -height / 2 + row * tileHeight; const rowShift = rowShiftRatio > 0 ? (tileWidth * rowShiftRatio * row) % tileWidth : 0; for (let column = 0; column < columns; column++) { const drawX = -width / 2 + column * tileWidth - rowShift; ctx.drawImage(image as any, drawX, drawY, tileWidth, tileHeight); } } ctx.restore(); } private drawManualCarpetAreas(ctx: any, geometry: Q10MapGeometry, areas: Q10CreatorArea[]): void { if (!areas.length) return; this.withOutputSpace(ctx, geometry, (outputCtx) => { for (const area of areas) { this.drawAreaMaterialAtlas(outputCtx, geometry, area, this.assets.mapCarpetMaterial); } }); } private drawForbidEndpoint( ctx: any, geometry: Q10MapGeometry, point: Q10MapPixelPoint, rotationDeg: number ): void { const canvasPoint = geometry.mapPoint(point); const endpointSize = geometry.layoutLength(Q10_LAYOUT.forbidLineIconSize); if (this.assets.forbidlineIcon) { drawCenteredAsset( ctx, this.assets.forbidlineIcon, canvasPoint.x, canvasPoint.y, endpointSize, rotationDeg ); return; } ctx.save(); ctx.translate(canvasPoint.x, canvasPoint.y); ctx.rotate((rotationDeg * Math.PI) / 180); ctx.fillStyle = packedArgbToCss(DARK_MAP_COLORS.forbidLine); ctx.beginPath(); ctx.arc(0, 0, endpointSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,1)"; ctx.fillRect( -endpointSize * 0.28, -endpointSize * 0.08, endpointSize * 0.56, endpointSize * 0.16 ); ctx.restore(); } private drawVirtualWalls(ctx: any, geometry: Q10MapGeometry, walls: Q10CreatorLine[]): void { if (!walls.length) return; const lineWidth = geometry.layoutLength(Q10_LAYOUT.areaStrokeWidth); ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = packedArgbToCss(DARK_MAP_COLORS.forbidLine); ctx.lineWidth = lineWidth; for (const wall of walls) { const start = geometry.mapPoint(wall.points[0]); const end = geometry.mapPoint(wall.points[1]); ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); const rotationDeg = (Math.atan2(end.y - start.y, end.x - start.x) * 180) / Math.PI; this.drawForbidEndpoint(ctx, geometry, wall.points[0], rotationDeg); this.drawForbidEndpoint(ctx, geometry, wall.points[1], rotationDeg); } ctx.restore(); } private historyUpdateToPathKind(update: number | undefined): number { if (update === 6) return 0; if (update === 4) return 1; if (update === 5) return 2; return 0; } private normalizeNativePathType(type: number | undefined): number { if (type === 0 || type === 1 || type === 2 || type === 3 || type === 4) return type; return 0; } private packagePathPointsLikeNative(points: Array<Q10MapPixelPoint & { type: number }>): Q10MapPixelPoint[][][] { const paths: Q10MapPixelPoint[][][] = [[], [], [], [], []]; let previous: (Q10MapPixelPoint & { type: number }) | null = null; for (const point of points) { const bucket = paths[point.type] ?? paths[0]!; const changedType = previous?.type !== point.type; if (changedType) { const subPath: Q10MapPixelPoint[] = []; if (previous && previous.type !== -1) { subPath.push({ x: previous.x, y: previous.y }); } else { subPath.push({ x: point.x, y: point.y }); } subPath.push({ x: point.x, y: point.y }); bucket.push(subPath); } else if (bucket.length > 0) { bucket[bucket.length - 1]!.push({ x: point.x, y: point.y }); } previous = point; } return paths; } private hasDrawablePathSegments(segments: Q10MapPixelPoint[][]): boolean { return segments.some((segment) => segment.length > 1); } private createPathCanvas( geometry: Q10MapGeometry, data: B01MapData, points: Array<Q10MapPixelPoint & { type: number }> ): any | null { if (points.length < 2) return null; // Draw the path directly at final PNG resolution. The previous // implementation rendered on the coarse map grid first and then scaled // the bitmap up by the canvas scale, which made the path look visibly soft. const { width, height } = geometry.mapCanvasSize(); const canvas = createCanvas(width, height); const pathCtx = canvas.getContext("2d"); const paths = this.packagePathPointsLikeNative(points); const primaryWidth = width / 375; const glowWidth = geometry.mapLength(0.3 / Math.max(data.header.resolution, 0.001)); const drawPath = ( segments: Q10MapPixelPoint[][], strokeStyle: string, lineWidth: number, dash?: number[], dashOffset = 0 ): void => { const drawableSegments = segments.filter((segment) => segment.length >= 2); if (!drawableSegments.length) return; pathCtx.beginPath(); pathCtx.strokeStyle = strokeStyle; pathCtx.lineWidth = lineWidth; pathCtx.lineJoin = "round"; pathCtx.lineCap = "round"; pathCtx.setLineDash(dash ?? []); pathCtx.lineDashOffset = dashOffset; for (const segment of drawableSegments) { const start = geometry.mapPoint(segment[0]!); pathCtx.moveTo(start.x, start.y); for (let index = 1; index < segment.length; index++) { const point = geometry.mapPoint(segment[index]!); pathCtx.lineTo(point.x, point.y); } } pathCtx.stroke(); pathCtx.setLineDash([]); pathCtx.lineDashOffset = 0; }; pathCtx.clearRect(0, 0, width, height); pathCtx.imageSmoothingEnabled = true; // Path paints use Skia.Color() in the original bundle, which interprets // packed integers as AARRGGBB. Base-map raster colors in this file use a // different packing, so path colors must be decoded separately. const wideGlowColor = packedArgbToCss(1728053247); const solidWhite = packedArgbToCss(4294967295); const thinGlowColor = packedArgbToCss(1728053247); const dashedColor = packedArgbToCss(2583691263); const pathStyles: Array<{ segments: Q10MapPixelPoint[][]; layers: Q10PathLayerStyle[] }> = [ { segments: paths[0]!, layers: [ { strokeStyle: wideGlowColor, lineWidth: glowWidth }, { strokeStyle: solidWhite, lineWidth: primaryWidth } ] }, { segments: paths[1]!, layers: [ { strokeStyle: wideGlowColor, lineWidth: glowWidth }, { strokeStyle: thinGlowColor, lineWidth: primaryWidth } ] }, { segments: paths[2]!, layers: [ { strokeStyle: solidWhite, lineWidth: primaryWidth } ] }, { segments: paths[3]!, layers: [ { strokeStyle: dashedColor, lineWidth: primaryWidth, dash: [primaryWidth, primaryWidth * 3], dashOffset: primaryWidth * 3 } ] } ] as const; for (const pathStyle of pathStyles) { if (!this.hasDrawablePathSegments(pathStyle.segments)) continue; for (const layer of pathStyle.layers) { drawPath( pathStyle.segments, layer.strokeStyle, layer.lineWidth, layer.dash, layer.dashOffset ); } } return canvas; } private drawPath(ctx: any, geometry: Q10MapGeometry, data: B01MapData, creator: Q10CreatorData): void { const sourcePath = data.q10SourceData?.pathPoints ?? []; const nativePath = creator.pathPixels ?? []; if (!sourcePath.length && !nativePath.length && !data.history?.length) return; const pixelPoints = sourcePath.length ? sourcePath.map((point) => ({ x: data.q10SourceData!.xMin + point.x, y: data.q10SourceData!.yMin - point.y, type: this.normalizeNativePathType(point.type) })) : nativePath.length ? nativePath.map((point: Q10CreatorPathPoint) => ({ x: point.x, y: point.y, // Native Q10 path rendering in the original app is driven by the // decoded raw `type` from parserPathData/yx_getPathPointWith. // Do not synthesize alternate types from `update` here, otherwise // we may draw segments that the original leaves hidden or styles // differently. type: this.normalizeNativePathType(point.type) })) : (data.history ?? []).map((point) => ({ x: point.x, y: point.y, type: this.historyUpdateToPathKind(point.update) })); const pathCanvas = this.createPathCanvas(geometry, data, pixelPoints); if (!pathCanvas) return; ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(pathCanvas as any, 0, 0); ctx.restore(); } private drawPose( ctx: any, geometry: Q10MapGeometry, pose: Q10PixelPose | undefined, image: Image | undefined, drawWidth: number, rotationOffset = 0, fallback: "charger" | "robot" = "robot" ): void { if (!pose) return; const canvasPose = geometry.mapPose(pose); if (!canvasPose) return; const targetWidth = geometry.mapLength(drawWidth); if (image) { drawCenteredAsset(ctx, image, canvasPose.x, canvasPose.y, targetWidth, (canvasPose.phi ?? 0) + rotationOffset); return; } const radius = targetWidth * 0.28; ctx.save(); ctx.translate(canvasPose.x, canvasPose.y); ctx.rotate((((canvasPose.phi ?? 0) + rotationOffset) * Math.PI) / 180); if (fallback === "charger") { ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(20,31,48,0.9)"; ctx.lineWidth = Math.max(1, radius * 0.22); ctx.beginPath(); ctx.moveTo(-radius * 0.22, -radius * 0.55); ctx.lineTo(radius * 0.05, -radius * 0.1); ctx.lineTo(-radius * 0.02, -radius * 0.1); ctx.lineTo(radius * 0.22, radius * 0.55); ctx.lineTo(-radius * 0.05, radius * 0.08); ctx.lineTo(radius * 0.02, radius * 0.08); ctx.stroke(); } else { ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(90,120,150,0.95)"; ctx.beginPath(); ctx.moveTo(0, -radius * 0.7); ctx.lineTo(radius * 0.28, -radius * 0.1); ctx.lineTo(0, radius * 0.1); ctx.lineTo(-radius * 0.28, -radius * 0.1); ctx.closePath(); ctx.fill(); } ctx.restore(); } private drawObstacleIcons( ctx: any, geometry: Q10MapGeometry, entries: Q10CreatorObstacle[], image: Image | undefined, fallbackColor: string ): void { const targetWidth = geometry.imgRateLength(6); for (const entry of entries) { const point = geometry.mapPoint(entry.point); if (image) { drawCenteredSpriteWidthScaled(ctx, image, point.x, point.y, targetWidth); continue; } ctx.fillStyle = fallbackColor; ctx.beginPath(); ctx.arc(point.x, point.y, targetWidth * 0.25, 0, Math.PI * 2); ctx.fill(); } } private drawSuspectedPoints(ctx: any, geometry: Q10MapGeometry, entries: Q10CreatorSuspectedPoint[]): void { const targetSize = geometry.layoutLength(16); for (const entry of entries) { const point = geometry.mapPoint(entry.point); const image = entry.type === "threshold" ? this.assets.suspectedThreshold : entry.type === "easycard" ? this.assets.suspectedEasycard : this.assets.suspectedCliff; if (image) { drawCenteredCoverSquareAsset(ctx, image, point.x, point.y, targetSize); continue; } ctx.fillStyle = "rgba(255, 196, 0, 0.9)"; ctx.beginPath(); ctx.arc(point.x, point.y, targetSize * 0.25, 0, Math.PI * 2); ctx.fill(); } } private drawRoomTags(ctx: any, geometry: Q10MapGeometry, creator: Q10CreatorData): void { if (!creator.roomModels.length) return; const metrics = getRenderMetrics(); const referenceKImgRate = geometry.roomTagReferenceKImgRate(); const exportScale = geometry.roomTagExportScale(); const logicalFontSize = referenceKImgRate <= 3 ? 10 : 10 + 0.8 * (referenceKImgRate - 3); const fontSize = logicalFontSize * exportScale; const bubbleSize = metrics.roomBubbleDiameter * exportScale; const iconSize = metrics.roomIconSize * exportScale; const gap = metrics.roomGap * exportScale; ctx.font = `700 ${fontSize}px "Segoe UI", sans-serif`; for (const room of creator.roomModels) { const label = room.roomName?.trim(); if (!label) continue; const bubbleColor = packedArgbToCss(DARK_MAP_COLORS.roomTagBase[room.colorID] ?? DARK_MAP_COLORS.roomTagBase[0]); const borderColor = packedArgbToCss(DARK_MAP_COLORS.roomTagStroke[room.colorID] ?? DARK_MAP_COLORS.roomTagStroke[0]); const textColor = bubbleColor; const icon = this.assets.roomTags.get(room.roomType) ?? this.assets.roomTags.get(0); const textMetrics = measureRoomText(ctx, label, fontSize); const paragraphWidth = textMetrics.width + exportScale; const paragraphHeight = textMetrics.height + exportScale; const totalWidth = bubbleSize + gap + paragraphWidth; const center = geometry.mapPoint(room.transCenterPoint); const centerX = center.x; const centerY = center.y; const startX = centerX - totalWidth / 2; const bubbleCenterX = startX + bubbleSize / 2; ctx.beginPath(); ctx.arc(bubbleCenterX, centerY, bubbleSize / 2, 0, Math.PI * 2); ctx.fillStyle = bubbleColor; ctx.fill(); ctx.strokeStyle = borderColor; ctx.lineWidth = Math.max(1, 0.5 * exportScale); ctx.stroke(); if (icon) this.drawCenteredOpaqueAsset(ctx, icon, bubbleCenterX, centerY, iconSize); const textX = startX + bubbleSize + gap; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillStyle = textColor; ctx.fillText(label, textX, centerY - paragraphHeight / 2); if (room.cleanOrder > 0) { const badgeRadius = metrics.roomBadgeRadius * exportScale; const badgeCenterX = startX + badgeRadius + exportScale; const badgeCenterY = centerY + paragraphHeight / 2 + 2 * exportScale + badgeRadius; ctx.beginPath(); ctx.arc(badgeCenterX, badgeCenterY, badgeRadius, 0, Math.PI * 2); ctx.fillStyle = "rgba(111,111,116,0.95)"; ctx.fill(); ctx.fillStyle = "rgba(255,255,255,1)"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `700 ${(room.cleanOrder < 10 ? 10 : 8) * exportScale}px "Segoe UI", sans-serif`; ctx.fillText(String(room.cleanOrder), badgeCenterX, badgeCenterY); ctx.font = `700 ${fontSize}px "Segoe UI", sans-serif`; } } } private initializeCanvas(ctx: any, width: number, height: number): void { ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, width, height); } private drawBaseLayers(ctx: any, geometry: Q10MapGeometry, data: B01MapData, creator: Q10CreatorData): void { this.withMapSpace(ctx, geometry, (mapCtx) => { this.drawBaseMap(mapCtx, data, creator); this.drawRoomMaterials(mapCtx, geometry, data, creator); }); } private drawCleanOverlayLayers(ctx: any, geometry: Q10MapGeometry, data: B01MapData, creator: Q10CreatorData): void { this.drawSelfIdentifiedCarpets(ctx, data, creator); this.withMapSpace(ctx, geometry, (mapCtx) => { this.drawManualCarpetAreas(mapCtx, geometry, creator.carpetAreas); this.drawAreas(mapCtx, geometry, creator.forbidAreas, "forbid"); this.drawAreas(mapCtx, geometry, creator.mopAreas, "mop"); }); ctx.imageSmoothingEnabled = true; this.drawVirtualWalls(ctx, geometry, creator.virtualWalls); this.withMapSpace(ctx, geometry, (mapCtx) => { this.drawAreas(mapCtx, geometry, creator.thresholdAreas, "threshold"); }); this.withMapSpace(ctx, geometry, (mapCtx) => { this.drawAreas(mapCtx, geometry, creator.eraseAreas, "erase"); }); } private drawInteractiveOverlayLayers(ctx: any, geometry: Q10MapGeometry, creator: Q10CreatorData): void { this.drawPose(ctx, geometry, creator.chargerPixel, this.assets.power, 8, -90, "charger"); this.drawPose(ctx, geometry, creator.robotPixel, this.assets.device, 8, 90, "robot"); this.drawObstacleIcons(ctx, geometry, creator.obstaclePixels, this.assets.obstacle, "rgba(255,100,80,0.9)"); this.drawObstacleIcons(ctx, geometry, creator.skipPixels, this.assets.tiaoGuoIcon, "rgba(255,220,60,0.92)"); this.drawSuspectedPoints(ctx, geometry, creator.suspectedPoints); this.drawRoomTags(ctx, geometry, creator); } public async buildMaps(data: B01MapData, deviceStatus?: B01DeviceStatus, robotModel?: string): Promise<Q10RenderedMaps> { void deviceStatus; await this.ensureAssets(robotModel); const creator = data.q10CreatorData; if (!creator?.q10Detected) { throw new Error("Q10 creator data missing for Q10 builder"); } const renderScale = getQ10ExportCanvasScale(data.header.sizeX, data.header.sizeY); const geometry = new Q10MapGeometry(data, 1, renderScale); const { width, height } = geometry.mapCanvasSize(); const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); this.initializeCanvas(ctx, width, height); this.drawBaseLayers(ctx, geometry, data, creator); this.drawCleanOverlayLayers(ctx, geometry, data, creator); const clean = canvas.toBuffer("image/png"); this.drawPath(ctx, geometry, data, creator); this.drawInteractiveOverlayLayers(ctx, geometry, creator); return { full: canvas.toBuffer("image/png"), clean }; } public async buildMap(data: B01MapData, deviceStatus?: B01DeviceStatus, robotModel?: string): Promise<Buffer> { const rendered = await this.buildMaps(data, deviceStatus, robotModel); return rendered.full; } }