iobroker.roborock
Version:
1,006 lines (890 loc) • 30.9 kB
text/typescript
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);
}
}