UNPKG

iobroker.roborock

Version:
1,006 lines (890 loc) 30.9 kB
import type { B01DeviceStatus, B01MapData } from "../b01/types"; import { isB01DockAnchoredState } from "../b01/B01StateSemantics"; import { normalizeRoborockRoomDisplayName } from "../../roomNameNormalizer"; import { buildQ10Verification } from "./Q10Verification"; import type { Q10CreatorArea, Q10CreatorData, Q10CreatorLine, Q10CreatorObstacle, Q10CreatorPathPoint, Q10CreatorRoomModel, Q10CreatorRoomTangentInfo, Q10CreatorSelfIdentifiedCarpet, Q10CreatorSuspectedPoint, Q10DevicePoint, Q10DevicePose, Q10MapArrPoint, Q10MapPixelPoint, Q10SourceArea, Q10SourceData, Q10SourcePathPoint, Q10SourceRoom } from "./types"; const ROOM_COLOR_COUNT = 4; const ROOM_OTHER_MATERIAL = 3; const Q10_DOCK_ANCHORED_OFFSET = 3.5; interface RoomStat { roomID: number; count: number; sumX: number; sumY: number; minX: number; minY: number; maxX: number; maxY: number; pixelIndices: number[]; } interface RoomBorderEdge { id: number; startX: number; startY: number; endX: number; endY: number; startKey: string; endKey: string; } function devicePointToPixel(source: Q10SourceData, point: Q10DevicePoint): Q10MapPixelPoint { return { // Q10 overlay coordinates are device-relative and need the app's // devicePointToOrigMap transform to land in map-array space. x: source.xMin + point.x, y: source.yMin - point.y }; } function rotateVector(x: number, y: number, degrees: number): { x: number; y: number } { const radians = (degrees * Math.PI) / 180; const cos = Math.cos(radians); const sin = Math.sin(radians); return { x: x * cos - y * sin, y: x * sin + y * cos }; } function shouldAnchorRobotToDock(deviceStatus?: B01DeviceStatus): boolean { const stateCode = deviceStatus?.deviceState; if (isB01DockAnchoredState(stateCode)) return true; // Q10 shadow snapshots have been observed to report state 10 while the // live map still omits the robot pose and only exposes the dock pose. // Keep the dock-adjacent fallback for that state so the robot remains // visible in the rendered map instead of disappearing entirely. return stateCode === 10; } function mapArrPointToPixel(point: Q10MapArrPoint): Q10MapPixelPoint { return { x: point.x, y: point.y }; } function sourceAreaToCreator(source: Q10SourceData, area: Q10SourceArea): Q10CreatorArea { return { id: area.id, type: area.type, areaType: area.areaType, name: area.name, points: area.points.map((point) => devicePointToPixel(source, point)) }; } function sourceLineToCreator(source: Q10SourceData, area: Q10SourceArea): Q10CreatorLine | null { if (area.points.length < 2) return null; return { id: area.id, type: "virtualWall", points: [ devicePointToPixel(source, area.points[0]), devicePointToPixel(source, area.points[1]) ] }; } function mapSourceAreas(source: Q10SourceData, areas: Q10SourceArea[]): Q10CreatorArea[] { return areas.map((area) => sourceAreaToCreator(source, area)); } function mapSourceLines(source: Q10SourceData, areas: Q10SourceArea[]): Q10CreatorLine[] { return areas .map((area) => sourceLineToCreator(source, area)) .filter((area): area is Q10CreatorLine => area !== null); } function isRoomValue(value: number): boolean { return value > 1 && value < 127; } function analyzeRoomStatsFromGrid(mapGrid: Buffer, width: number, height: number): Map<number, RoomStat> { const stats = new Map<number, RoomStat>(); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const value = mapGrid[y * width + x]; if (!isRoomValue(value)) continue; const existing = stats.get(value) ?? { roomID: value, count: 0, sumX: 0, sumY: 0, minX: x, minY: y, maxX: x, maxY: y, pixelIndices: [] }; existing.count += 1; existing.sumX += x + 0.5; existing.sumY += y + 0.5; existing.minX = Math.min(existing.minX, x); existing.minY = Math.min(existing.minY, y); existing.maxX = Math.max(existing.maxX, x); existing.maxY = Math.max(existing.maxY, y); existing.pixelIndices.push(y * width + x); stats.set(value, existing); } } return stats; } function analyzeRoomStats(mapData: B01MapData, mapGrid = mapData.mapGrid): Map<number, RoomStat> { return analyzeRoomStatsFromGrid(mapGrid, mapData.header.sizeX, mapData.header.sizeY); } function getLineInterpolation(start: Q10MapPixelPoint, end: Q10MapPixelPoint): Q10MapPixelPoint[] { const result: Q10MapPixelPoint[] = [{ x: start.x, y: start.y }]; const distance = Math.hypot(start.x - end.x, start.y - end.y); if (distance > 1) { const step = 1 / distance; let ratio = 0; let previous = start; for (let index = 0; index < distance; index++) { ratio += step; const point = { x: start.x + (end.x - start.x) * ratio, y: start.y + (end.y - start.y) * ratio }; if (point.x !== previous.x || point.y !== previous.y) { result.push(point); previous = point; } } } result.push({ x: end.x, y: end.y }); return result; } function getPointMapValue(point: Q10MapPixelPoint, mapGrid: Buffer, width: number, height: number): number { if (point.x < 0 || point.x >= width) return -9999; if (point.y < 0 || point.y >= height) return -9999; const x = Math.floor(point.x); const y = Math.floor(point.y); const index = y * width + x; if (index >= 0 && index < mapGrid.length) return mapGrid[index] ?? -9999; return -9999; } function getRoomCrossPoints( start: Q10MapPixelPoint, end: Q10MapPixelPoint, roomGridValue: number, mapGrid: Buffer, width: number, height: number ): Q10MapPixelPoint[] { const linePoints = getLineInterpolation(start, end); const result: Q10MapPixelPoint[] = []; for (let index = 0; index < linePoints.length; index++) { const point = linePoints[index]!; const roomValue = getPointMapValue(point, mapGrid, width, height); if (roomValue !== roomGridValue) continue; if (index > 0 && index < linePoints.length - 1) { const lastRoomValue = getPointMapValue(linePoints[index - 1]!, mapGrid, width, height); const nextRoomValue = getPointMapValue(linePoints[index + 1]!, mapGrid, width, height); if (lastRoomValue === roomGridValue && nextRoomValue === roomGridValue) continue; } if (!result.some((existing) => existing.x === point.x && existing.y === point.y)) { result.push(point); } } return result; } function fixRoomCrossPoints(points: Q10MapPixelPoint[]): Q10MapPixelPoint[] { if (points.length < 4) return points; const remaining = points.slice(); const results: Q10MapPixelPoint[] = []; while (remaining.length >= 4) { const point1 = remaining[1]!; const point2 = remaining[2]!; const distance = Math.hypot(point1.x - point2.x, point1.y - point2.y); if (distance < 10) { remaining.splice(1, 2); } else { results.push(remaining[0]!, remaining[1]!); remaining.splice(0, 2); } } return results.concat(remaining); } function computeRoomLabelCenter( mapGrid: Buffer, mapWidth: number, mapHeight: number, roomGridValue: number, stat: RoomStat ): Q10MapPixelPoint { if (!stat.pixelIndices.length) { return { x: (stat.minX + stat.maxX) / 2, y: (stat.minY + stat.maxY) / 2 }; } let centerX = (stat.maxX + stat.minX) / 2; let centerY = (stat.maxY + stat.minY) / 2; let widthCrossPoints = getRoomCrossPoints( { x: stat.minX - 1, y: centerY }, { x: stat.maxX + 1, y: centerY }, roomGridValue, mapGrid, mapWidth, mapHeight ); widthCrossPoints = fixRoomCrossPoints(widthCrossPoints); if (widthCrossPoints.length >= 2) { const point1 = widthCrossPoints[0]!; const point2 = widthCrossPoints[widthCrossPoints.length - 1]!; centerX = (point2.x + point1.x) / 2; } let heightCrossPoints = getRoomCrossPoints( { x: centerX, y: stat.minY - 1 }, { x: centerX, y: stat.maxY + 1 }, roomGridValue, mapGrid, mapWidth, mapHeight ); heightCrossPoints = fixRoomCrossPoints(heightCrossPoints); if (heightCrossPoints.length >= 2) { const point1 = heightCrossPoints[0]!; const point2 = heightCrossPoints[heightCrossPoints.length - 1]!; centerY = (point2.y + point1.y) / 2; const centerRoomValue = getPointMapValue({ x: centerX, y: centerY }, mapGrid, mapWidth, mapHeight); if (centerRoomValue !== roomGridValue) { let maxSegmentIndex = 0; let maxDistance = 0; for (let index = 0; index < heightCrossPoints.length / 2; index++) { if (index * 2 + 1 > heightCrossPoints.length - 1) break; const start = heightCrossPoints[index * 2]!; const end = heightCrossPoints[index * 2 + 1]!; const distance = Math.hypot(start.x - end.x, start.y - end.y); if (distance > maxDistance) { maxDistance = distance; maxSegmentIndex = index; } } const start = heightCrossPoints[maxSegmentIndex * 2]!; const end = heightCrossPoints[maxSegmentIndex * 2 + 1]!; centerY = (end.y + start.y) / 2; } } return { x: centerX, y: centerY }; } function polygonSignedArea(points: Q10MapPixelPoint[]): number { let area = 0; for (let index = 0; index < points.length; index++) { const current = points[index]; const next = points[(index + 1) % points.length]; area += current.x * next.y - next.x * current.y; } return area / 2; } function buildRoomBorderLoops(mapWidth: number, stat: RoomStat): Q10MapPixelPoint[][] { if (!stat.pixelIndices.length) return []; const occupied = new Set<number>(stat.pixelIndices); const edges: RoomBorderEdge[] = []; let nextEdgeId = 0; const pushEdge = (startX: number, startY: number, endX: number, endY: number): void => { edges.push({ id: nextEdgeId++, startX, startY, endX, endY, startKey: `${startX},${startY}`, endKey: `${endX},${endY}` }); }; for (const pixelIndex of stat.pixelIndices) { const x = pixelIndex % mapWidth; const y = Math.floor(pixelIndex / mapWidth); const topIndex = pixelIndex - mapWidth; const rightIndex = pixelIndex + 1; const bottomIndex = pixelIndex + mapWidth; const leftIndex = pixelIndex - 1; if (y === 0 || !occupied.has(topIndex)) pushEdge(x, y, x + 1, y); if (x === mapWidth - 1 || !occupied.has(rightIndex)) pushEdge(x + 1, y, x + 1, y + 1); if (!occupied.has(bottomIndex)) pushEdge(x + 1, y + 1, x, y + 1); if (x === 0 || !occupied.has(leftIndex)) pushEdge(x, y + 1, x, y); } const outgoing = new Map<string, number[]>(); for (const edge of edges) { const list = outgoing.get(edge.startKey) ?? []; list.push(edge.id); outgoing.set(edge.startKey, list); } const used = new Set<number>(); const loops: Q10MapPixelPoint[][] = []; for (const edge of edges) { if (used.has(edge.id)) continue; const loop: Q10MapPixelPoint[] = []; const startKey = edge.startKey; let currentEdge = edge; while (true) { used.add(currentEdge.id); if (loop.length === 0) { loop.push({ x: currentEdge.startX, y: currentEdge.startY }); } loop.push({ x: currentEdge.endX, y: currentEdge.endY }); if (currentEdge.endKey === startKey) break; const nextCandidates = outgoing.get(currentEdge.endKey) ?? []; const nextEdgeId = nextCandidates.find((candidateId) => !used.has(candidateId)); if (nextEdgeId === undefined) break; currentEdge = edges[nextEdgeId]!; } if (loop.length > 1) { const first = loop[0]!; const last = loop[loop.length - 1]!; if (first.x === last.x && first.y === last.y) loop.pop(); } if (loop.length >= 3) loops.push(loop); } loops.sort((leftLoop, rightLoop) => Math.abs(polygonSignedArea(rightLoop)) - Math.abs(polygonSignedArea(leftLoop))); return loops; } function pointInPolygon(pointX: number, pointY: number, points: Q10MapPixelPoint[]): boolean { let inside = false; for (let index = 0, previous = points.length - 1; index < points.length; previous = index++) { const current = points[index]!; const prev = points[previous]!; const intersects = (current.y > pointY) !== (prev.y > pointY) && pointX < ((prev.x - current.x) * (pointY - current.y)) / (prev.y - current.y) + current.x; if (intersects) inside = !inside; } return inside; } function buildClipEraseMapGrid(mapData: B01MapData, source: Q10SourceData): Buffer | undefined { if (!source.eraseAreas.length) return undefined; const width = mapData.header.sizeX; const height = mapData.header.sizeY; const clipEraseGrid = Buffer.from(mapData.mapGrid); const eraseAreas = mapSourceAreas(source, source.eraseAreas); for (const area of eraseAreas) { if (area.points.length < 3) continue; const xs = area.points.map((point) => point.x); const ys = area.points.map((point) => point.y); const startX = Math.max(0, Math.floor(Math.min(...xs))); const endX = Math.min(width - 1, Math.ceil(Math.max(...xs))); const startY = Math.max(0, Math.floor(Math.min(...ys))); const endY = Math.min(height - 1, Math.ceil(Math.max(...ys))); for (let y = startY; y <= endY; y++) { for (let x = startX; x <= endX; x++) { const index = y * width + x; if (!isRoomValue(clipEraseGrid[index] ?? 0)) continue; if (pointInPolygon(x + 0.5, y + 0.5, area.points)) { clipEraseGrid[index] = 1; } } } } return clipEraseGrid; } function buildRoomTangentInfo(mapGrid: Buffer, width: number, height: number): Q10CreatorRoomTangentInfo[] { const tangents = new Set<string>(); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const current = mapGrid[y * width + x] ?? 0; if (!isRoomValue(current)) continue; if (x + 1 < width) { const right = mapGrid[y * width + x + 1] ?? 0; if (isRoomValue(right) && right !== current) { const roomID1 = Math.min(current, right); const roomID2 = Math.max(current, right); tangents.add(`${roomID1}:${roomID2}`); } } if (y + 1 < height) { const bottom = mapGrid[(y + 1) * width + x] ?? 0; if (isRoomValue(bottom) && bottom !== current) { const roomID1 = Math.min(current, bottom); const roomID2 = Math.max(current, bottom); tangents.add(`${roomID1}:${roomID2}`); } } } } return Array.from(tangents) .map((entry) => { const [roomID1, roomID2] = entry.split(":").map((value) => Number(value)); return { roomID1, roomID2, tangent: 1 as const }; }) .sort((left, right) => left.roomID1 - right.roomID1 || left.roomID2 - right.roomID2); } function findAdjacentRegions(mapGrid: Buffer, width: number, height: number, roomGridValues: number[]): number[][] { const roomIndexByGridValue = new Map<number, number>(); roomGridValues.forEach((roomGridValue, index) => roomIndexByGridValue.set(roomGridValue, index)); const roomNeighborInfo = roomGridValues.map((roomGridValue) => [roomGridValue, new Set<number>()] as const); for (let index = 0; index < mapGrid.length; index++) { const regionId = mapGrid[index]; const roomIndex = roomIndexByGridValue.get(regionId); if (roomIndex == null) continue; const x = index % width; const y = Math.floor(index / width); const roomNeighbors = roomNeighborInfo[roomIndex]?.[1]; if (!roomNeighbors) continue; for (let neighborY = y - 2; neighborY < y + 2; neighborY++) { for (let neighborX = x - 2; neighborX < x + 2; neighborX++) { if (neighborY === y || neighborX === x) continue; if (neighborY < 0 || neighborY >= height || neighborX < 0 || neighborX >= width) continue; const nextRoomId = mapGrid[width * neighborY + neighborX] ?? 0; if (nextRoomId === regionId || !roomIndexByGridValue.has(nextRoomId)) continue; roomNeighbors.add(nextRoomId); } } } const matrix = Array.from({ length: roomGridValues.length }, () => new Array(roomGridValues.length).fill(0)); for (let row = 0; row < roomGridValues.length; row++) { const roomNeighbors = roomNeighborInfo[row]?.[1]; if (!roomNeighbors) continue; for (const neighbor of roomNeighbors) { const col = roomIndexByGridValue.get(neighbor); if (col != null) matrix[row][col] = 1; } } return matrix; } function reColoringMap(nextArea: number, color: number, colorPlan: number[], matrix: number[][]): number[] { colorPlan[nextArea] = color; if (nextArea === colorPlan.length - 1) return Array.from(colorPlan); const candidateArea = nextArea + 1; const availableColors = [0, 1, 2, 3]; for (let index = 0; index < matrix.length; index++) { if (matrix[candidateArea]?.[index] > 0 && colorPlan[index] >= 0) { const colorIndex = availableColors.indexOf(colorPlan[index]!); if (colorIndex !== -1) availableColors.splice(colorIndex, 1); } } for (const availableColor of availableColors) { const result = reColoringMap(candidateArea, availableColor, Array.from(colorPlan), matrix); if (result.every((entry) => entry !== -1)) return result; } return Array.from(colorPlan); } function buildRoomColorPlan( roomGridValues: number[], matrix: number[][], roomCounts: ReadonlyMap<number, number> ): Map<number, number> { const roomColorMap = new Map<number, number>(); if (!roomGridValues.length) return roomColorMap; let maxRoomIndex = -1; let maxRoomCount = -1; for (let index = 0; index < roomGridValues.length; index++) { const count = roomCounts.get(roomGridValues[index]!) ?? 0; if (count > maxRoomCount) { maxRoomCount = count; maxRoomIndex = index; } } if (roomGridValues.length <= ROOM_COLOR_COUNT) { let colorIndex = 1; for (let index = 0; index < roomGridValues.length; index++) { roomColorMap.set(roomGridValues[index]!, index === maxRoomIndex ? 0 : colorIndex++); } return roomColorMap; } const colorPlan = reColoringMap(0, 0, new Array(roomGridValues.length).fill(-1), matrix).map((value) => value === -1 ? 3 : value); const usedColors = Array.from(new Set(colorPlan)); const unusedColors = [0, 1, 2, 3].filter((color) => !usedColors.includes(color)); if (usedColors.length < ROOM_COLOR_COUNT) { for (const unusedColor of unusedColors) { for (const usedColor of usedColors) { let seen = 0; let filled = false; for (let index = 0; index < colorPlan.length; index++) { if (colorPlan[index] === usedColor) seen++; if (seen > 1) { colorPlan[index] = unusedColor; filled = true; break; } } if (filled) break; } } } for (let index = 0; index < roomGridValues.length; index++) { roomColorMap.set(roomGridValues[index]!, colorPlan[index] ?? 0); } const maxRoomColorId = roomColorMap.get(roomGridValues[maxRoomIndex]!) ?? 0; if (maxRoomColorId === 0) return roomColorMap; for (const roomGridValue of roomGridValues) { const currentColorId = roomColorMap.get(roomGridValue) ?? 0; if (currentColorId === 0) roomColorMap.set(roomGridValue, maxRoomColorId); else if (currentColorId === maxRoomColorId) roomColorMap.set(roomGridValue, 0); } return roomColorMap; } function parseSerializedRoomColorPlan( planStr: string | undefined, logicalRoomIdByGridValue: ReadonlyMap<number, number> ): Map<number, number> | null { if (!planStr?.trim()) return null; try { const plan = JSON.parse(planStr) as Array<{ roomID?: unknown; colorID?: unknown }>; if (!Array.isArray(plan) || !plan.length) return null; const gridValueByRoomId = new Map<number, number>(); for (const [gridValue, roomId] of logicalRoomIdByGridValue.entries()) { gridValueByRoomId.set(roomId, gridValue); } const colorMap = new Map<number, number>(); for (const entry of plan) { const roomID = Number(entry?.roomID); const colorID = Number(entry?.colorID); if (!Number.isInteger(roomID) || !Number.isInteger(colorID)) continue; if (colorID < 0 || colorID >= ROOM_COLOR_COUNT) continue; const gridValue = gridValueByRoomId.get(roomID); if (gridValue == null) continue; colorMap.set(gridValue, colorID); } return colorMap.size ? colorMap : null; } catch { return null; } } function serializeRoomColorPlan(roomModels: ReadonlyArray<Q10CreatorRoomModel>): string { return JSON.stringify( roomModels.map((room) => ({ roomID: room.roomID, colorID: room.colorID })) ); } function buildRoomModels( mapData: B01MapData, source: Q10SourceData, getDefaultRoomName?: () => string | undefined, translateRoomName?: (key: string, fallback?: string) => string, mapGrid = mapData.mapGrid ): Q10CreatorRoomModel[] { const roomMeta = new Map<number, Q10SourceRoom>(); for (const room of source.rooms) roomMeta.set(room.roomID, room); const logicalRoomIdByGridValue = new Map<number, number>(); for (const room of mapData.rooms ?? []) { if (room.gridValue != null) logicalRoomIdByGridValue.set(room.gridValue, room.roomId); } const mapWidth = mapData.header.sizeX; const stats = analyzeRoomStats(mapData, mapGrid); const roomGridValues = Array.from(stats.keys()); const roomCounts = new Map<number, number>(); for (const [roomGridValue, stat] of stats) roomCounts.set(roomGridValue, stat.count); const roomColorPlan = parseSerializedRoomColorPlan(source.tempRoomColorPlanStr, logicalRoomIdByGridValue) ?? buildRoomColorPlan( roomGridValues, findAdjacentRegions(mapGrid, mapData.header.sizeX, mapData.header.sizeY, roomGridValues), roomCounts ); return roomGridValues.map((gridValue, index) => { const stat = stats.get(gridValue); if (!stat) { throw new Error(`Missing room stats for Q10 grid value ${gridValue}`); } const logicalRoomID = logicalRoomIdByGridValue.get(gridValue) ?? (gridValue - 1); const meta = roomMeta.get(logicalRoomID); const centerPoint = computeRoomLabelCenter( mapGrid, mapWidth, mapData.header.sizeY, gridValue, stat ); const borderArr = buildRoomBorderLoops(mapWidth, stat); return { roomID: logicalRoomID, gridValue, roomName: normalizeRoborockRoomDisplayName( meta?.roomName, getDefaultRoomName, translateRoomName, meta?.roomType ), roomType: meta?.roomType ?? 0, roomMaterial: meta?.roomMaterial ?? ROOM_OTHER_MATERIAL, cleanOrder: meta?.cleanOrder ?? 0, cleanCount: meta?.cleanCount ?? 0, funLevel: meta?.funLevel ?? -1, waterLevel: meta?.waterLevel ?? -1, cleanType: meta?.cleanType ?? -1, cleanLine: meta?.cleanLine ?? 0, colorID: roomColorPlan.get(gridValue) ?? (index % ROOM_COLOR_COUNT), centerPoint, transCenterPoint: centerPoint, borderArr, borderEdge: { left: stat.minX, top: stat.minY, right: stat.maxX + 1, bottom: stat.maxY + 1 }, bounds: { left: stat.minX, top: stat.minY, right: stat.maxX + 1, bottom: stat.maxY + 1 } }; }); } function buildObstaclePixels( source: Q10SourceData, entries: { point: Q10DevicePoint; type?: "obstacle" | "skip" }[], type: "obstacle" | "skip" ): Q10CreatorObstacle[] { return entries.map((entry) => ({ type, point: devicePointToPixel(source, entry.point) })); } function buildSuspectedPixels(source: Q10SourceData): Q10CreatorSuspectedPoint[] { return source.suspectedPoints.map((entry) => ({ type: entry.type, point: devicePointToPixel(source, entry.point) })); } function q10PathTypeToHistoryUpdate(type: number | undefined): number { if (type === 0) return 6; if (type === 1) return 4; if (type === 2 || type === 4) return 5; return 0; } function buildPathPixels(source: Q10SourceData): Q10CreatorPathPoint[] { return source.pathPoints.map((point: Q10SourcePathPoint) => ({ ...devicePointToPixel(source, point), type: point.type, update: point.update ?? q10PathTypeToHistoryUpdate(point.type) })); } function buildMaterialPathGroup(polygons: Q10MapArrPoint[][] | undefined): Q10MapPixelPoint[][] { if (!polygons?.length) return []; return polygons.map((polygon) => polygon.map((point) => mapArrPointToPixel(point))); } function buildSelfIdentifiedCarpets(data: B01MapData): Q10CreatorSelfIdentifiedCarpet[] { if (!data.carpetGrid?.length) return []; const width = data.header.sizeX; const height = data.header.sizeY; const carpetStats = new Map< number, { left: number; top: number; right: number; bottom: number; indices: number[]; } >(); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const index = y * width + x; const carpetID = (data.carpetGrid[index] ?? 0) & 0x3f; if (carpetID === 0) continue; const stat = carpetStats.get(carpetID) ?? { left: x, top: y, right: x, bottom: y, indices: [] }; stat.left = Math.min(stat.left, x); stat.top = Math.min(stat.top, y); stat.right = Math.max(stat.right, x); stat.bottom = Math.max(stat.bottom, y); stat.indices.push(index); carpetStats.set(carpetID, stat); } } return Array.from(carpetStats.entries()) .sort((left, right) => left[0] - right[0]) .map(([carpetID, stat]) => { const localWidth = stat.right - stat.left + 1; const localHeight = stat.bottom - stat.top + 1; const mask = Buffer.alloc(localWidth * localHeight); for (const index of stat.indices) { const localX = (index % width) - stat.left; const localY = Math.floor(index / width) - stat.top; mask[localY * localWidth + localX] = 1; } return { id: carpetID, carpetID, left: stat.left, top: stat.top, right: stat.right + 1, bottom: stat.bottom + 1, width: localWidth, height: localHeight, lt: { x: stat.left, y: stat.top }, rb: { x: stat.right + 1, y: stat.bottom + 1 }, mask }; }); } function withVerification(mapData: B01MapData, q10CreatorData: Q10CreatorData): B01MapData { const nextMapData = { ...mapData, q10CreatorData }; return { ...nextMapData, q10Verification: buildQ10Verification(nextMapData) }; } export class Q10MapCreator { constructor( private readonly deps?: { translationManager?: { get: (key: string, defaultVal?: string) => string; }; } ) {} private getDefaultRoomName(): string | undefined { return this.deps?.translationManager?.get("default_room_name"); } private translateRoomName(key: string, fallback?: string): string { return this.deps?.translationManager?.get(key, fallback) ?? fallback ?? key; } private applyRuntimePose( mapData: B01MapData, source: Q10SourceData, creatorData: Q10CreatorData, deviceStatus?: B01DeviceStatus ): { nextMapData: B01MapData; nextSource: Q10SourceData; nextCreatorData: Q10CreatorData } { if ( source.robotPosition || creatorData.robotPixel || !creatorData.chargerPixel || !source.chargePosition || !shouldAnchorRobotToDock(deviceStatus) ) { return { nextMapData: mapData, nextSource: source, nextCreatorData: creatorData }; } const chargerPhi = source.chargePosition.phi ?? creatorData.chargerPixel.phi ?? 0; const offset = rotateVector(Q10_DOCK_ANCHORED_OFFSET, 0, chargerPhi); const robotDevicePose: Q10DevicePose = { x: source.chargePosition.x + offset.x, y: source.chargePosition.y + offset.y, phi: chargerPhi }; const robotPixel = devicePointToPixel(source, robotDevicePose); const robotWorld = { x: mapData.header.minX + robotDevicePose.x, y: mapData.header.maxY - robotDevicePose.y, phi: chargerPhi }; return { nextMapData: { ...mapData, robotPos: robotWorld }, nextSource: { ...source, robotPosition: robotDevicePose }, nextCreatorData: { ...creatorData, robotPixel: { ...robotPixel, phi: chargerPhi } } }; } public create(mapData: B01MapData, deviceStatus?: B01DeviceStatus): B01MapData { if (!mapData?.header || !mapData.mapGrid?.length) return mapData; const source = mapData.q10SourceData; if (!source) { throw new Error("Q10 source data missing. Refusing synthetic creator fallback."); } const roomModels = buildRoomModels( mapData, source, () => this.getDefaultRoomName(), (key, fallback) => this.translateRoomName(key, fallback) ); const clipEraseMapGrid = buildClipEraseMapGrid(mapData, source); const clipEraseRoomModels = clipEraseMapGrid ? buildRoomModels( mapData, source, () => this.getDefaultRoomName(), (key, fallback) => this.translateRoomName(key, fallback), clipEraseMapGrid ) : []; const roomTangentInfo = buildRoomTangentInfo(mapData.mapGrid, mapData.header.sizeX, mapData.header.sizeY); const clipEraseRoomTangentInfo = clipEraseMapGrid ? buildRoomTangentInfo(clipEraseMapGrid, mapData.header.sizeX, mapData.header.sizeY) : []; const roomMaterialRoomIds = { ceramicTile: roomModels.filter((room) => room.roomMaterial === 2).map((room) => room.gridValue), horizontalFloorBoard: roomModels.filter((room) => room.roomMaterial === 0).map((room) => room.gridValue), verticalFloorBoard: roomModels.filter((room) => room.roomMaterial === 1).map((room) => room.gridValue), other: roomModels .filter((room) => room.roomMaterial !== 0 && room.roomMaterial !== 1 && room.roomMaterial !== 2) .map((room) => room.gridValue) }; const nextSource: Q10SourceData = { ...source }; const q10CreatorData: Q10CreatorData = { q10Detected: true, mapRate: nextSource.mapRate, mapWidth: nextSource.mapWidth, mapHeight: nextSource.mapHeight, roomModels, clipEraseRoomModels, eraseAreas: mapSourceAreas(nextSource, nextSource.eraseAreas), virtualWalls: mapSourceLines(nextSource, nextSource.virtualWalls), forbidAreas: mapSourceAreas(nextSource, nextSource.forbidAreas), mopAreas: mapSourceAreas(nextSource, nextSource.mopAreas), thresholdAreas: mapSourceAreas(nextSource, nextSource.thresholdAreas), carpetAreas: mapSourceAreas(nextSource, nextSource.carpetAreas), pathPixels: buildPathPixels(nextSource), obstaclePixels: buildObstaclePixels(nextSource, nextSource.obstacles, "obstacle"), skipPixels: buildObstaclePixels(nextSource, nextSource.skipPoints, "skip"), suspectedPoints: buildSuspectedPixels(nextSource), selfIdentifiedCarpets: buildSelfIdentifiedCarpets(mapData), roomTangentInfo, clipEraseRoomTangentInfo, clipEraseMapGrid, materialPaths: { ceramicTile: buildMaterialPathGroup(nextSource.mapCeramicTilePath), horizontalFloorBoard: buildMaterialPathGroup(nextSource.mapHorizontalFloorBoardPath), verticalFloorBoard: buildMaterialPathGroup(nextSource.mapVerticalFloorBoardPath) }, roomMaterialRoomIds }; if (!nextSource.tempRoomColorPlanStr) { nextSource.tempRoomColorPlanStr = serializeRoomColorPlan(roomModels); } if (clipEraseRoomModels.length && !nextSource.tempClipEraseRoomColorPlanStr) { nextSource.tempClipEraseRoomColorPlanStr = serializeRoomColorPlan(clipEraseRoomModels); } if (nextSource.chargePosition) { q10CreatorData.chargerPixel = { ...devicePointToPixel(nextSource, nextSource.chargePosition), phi: nextSource.chargePosition.phi }; } if (nextSource.robotPosition) { q10CreatorData.robotPixel = { ...devicePointToPixel(nextSource, nextSource.robotPosition), phi: nextSource.robotPosition.phi }; } const { nextMapData, nextSource: runtimeSource, nextCreatorData } = this.applyRuntimePose(mapData, nextSource, q10CreatorData, deviceStatus); return withVerification({ ...nextMapData, q10SourceData: runtimeSource }, nextCreatorData); } }