mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
164 lines
6.1 KiB
TypeScript
164 lines
6.1 KiB
TypeScript
/**
|
|
* Editor-only half of vacuum map routing (#162).
|
|
*
|
|
* Adding, re-targeting and deleting routes only ever happens in the device
|
|
* editor, so these helpers live in the lazy editor graph: the View card must
|
|
* not pay for code it can never run (docs/ARCHITECTURE.md, #367).
|
|
*/
|
|
import type { Marker } from './types';
|
|
import { FitParams, fitFromMatrix, initialFit, VacRoom } from './vacuum';
|
|
|
|
/** The marker's vacuum block exactly as the config stores it. */
|
|
export type MarkerVacuumCfg = NonNullable<Marker['vacuum']>;
|
|
import {
|
|
Affine, VacuumMapRoute, VacuumRouteMarkerCfg, effectiveRoutes, normalizeRouteMatrix,
|
|
} from './vacuum-routes';
|
|
|
|
/** A marker-local id that no existing route uses. */
|
|
export function newRouteId(existing: Iterable<string>, seed: () => number = Math.random): string {
|
|
const taken = new Set(existing);
|
|
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
const id = 'vr_' + Math.floor(seed() * 0xffffff).toString(36).padStart(5, '0');
|
|
if (!taken.has(id)) return id;
|
|
}
|
|
return 'vr_' + Date.now().toString(36);
|
|
}
|
|
|
|
export function addRoute(
|
|
routes: VacuumMapRoute[], route: Omit<VacuumMapRoute, 'id'>, id: string,
|
|
): VacuumMapRoute[] {
|
|
return [...routes, { ...route, id, calibration: route.calibration ?? null }];
|
|
}
|
|
|
|
export function removeRoute(routes: VacuumMapRoute[], routeId: string): VacuumMapRoute[] {
|
|
return routes.filter((route) => route.id !== routeId);
|
|
}
|
|
|
|
/**
|
|
* Re-target a route to another space.
|
|
*
|
|
* A new space is a NEW identity, not an edited one: the calibration was solved
|
|
* against the old space's geometry and the recorded runs were filed under the
|
|
* old id, so both must go rather than silently apply to a different floor.
|
|
*/
|
|
export function changeRouteSpace(
|
|
routes: VacuumMapRoute[], routeId: string, space: string, newId: string,
|
|
): VacuumMapRoute[] {
|
|
return routes.map((route) => (route.id === routeId
|
|
? { ...route, id: newId, space, calibration: null } : route));
|
|
}
|
|
|
|
export function saveRouteCalibration(
|
|
routes: VacuumMapRoute[], routeId: string, matrix: Affine,
|
|
): VacuumMapRoute[] {
|
|
return routes.map((route) => (route.id === routeId ? { ...route, calibration: matrix } : route));
|
|
}
|
|
|
|
/**
|
|
* Turn a legacy `calibration` dictionary into explicit routes — all of it.
|
|
*
|
|
* Converting only the map the user happens to be looking at would drop the
|
|
* other floors' calibrations on the first routing edit, so this is all or
|
|
* nothing: without an exact source there is no conversion at all and the
|
|
* caller must ask for one first (spec 7.3).
|
|
*/
|
|
export function convertLegacyRoutes(
|
|
marker: VacuumRouteMarkerCfg | null | undefined,
|
|
dockSpace: string,
|
|
source: string,
|
|
nextId: (taken: Set<string>) => string,
|
|
): VacuumMapRoute[] | null {
|
|
if (!source) return null;
|
|
const calibration = marker?.calibration;
|
|
if (!calibration || typeof calibration !== 'object') return null;
|
|
const taken = new Set<string>();
|
|
const routes: VacuumMapRoute[] = [];
|
|
for (const mapId of Object.keys(calibration)) {
|
|
const matrix = normalizeRouteMatrix(calibration[mapId]);
|
|
if (!matrix) continue;
|
|
const id = nextId(taken);
|
|
taken.add(id);
|
|
routes.push({ id, source, map_id: mapId, space: dockSpace, calibration: matrix });
|
|
}
|
|
return routes.length ? routes : null;
|
|
}
|
|
|
|
/**
|
|
* Where a solved matrix belongs.
|
|
*
|
|
* With explicit routes the matrix is the route's own property; an unmapped map
|
|
* still falls back to the Stage 1 dictionary, because refusing to save a
|
|
* calibration the user just solved would lose real work over bookkeeping.
|
|
*/
|
|
export function writeVacuumMatrix(
|
|
vacuum: MarkerVacuumCfg | null | undefined,
|
|
opts: { source: string; mapId: string; routeId?: string; matrix: Affine },
|
|
): MarkerVacuumCfg {
|
|
const rounded = opts.matrix.map((n) => Number(n.toFixed(6))) as Affine;
|
|
const routes = vacuum?.map_routes;
|
|
if (Array.isArray(routes) && routes.length) {
|
|
const target = routes.find((route) => route.id === opts.routeId)
|
|
|| routes.find((route) => route.source === opts.source && route.map_id === opts.mapId);
|
|
if (target) {
|
|
return { ...vacuum, map_routes: saveRouteCalibration(routes, target.id, rounded) };
|
|
}
|
|
}
|
|
return {
|
|
...vacuum,
|
|
source: opts.source,
|
|
calibration: { ...(vacuum?.calibration || {}), [opts.mapId]: rounded },
|
|
};
|
|
}
|
|
|
|
export interface VacuumFitPlan {
|
|
space: string;
|
|
routeId: string;
|
|
params: FitParams;
|
|
}
|
|
|
|
/**
|
|
* Which space the manual fit canvas opens on, and from which matrix.
|
|
*
|
|
* Calibration is solved against the geometry of the space the map is assigned
|
|
* to — not the dock's space, which is where a multi-floor robot's fit used to
|
|
* land regardless of the map on screen (#162).
|
|
*/
|
|
export function planVacuumFit(
|
|
markerId: string,
|
|
vacuum: MarkerVacuumCfg | null | undefined,
|
|
opts: {
|
|
routeId?: string; source: string; mapId: string; dockSpace: string; rooms: VacRoom[];
|
|
viewBoxOf: (spaceId: string) => [number, number, number, number] | null;
|
|
},
|
|
): VacuumFitPlan | null {
|
|
const routes = effectiveRoutes(markerId, vacuum, opts.dockSpace, opts.source);
|
|
const route = routes.find((item) => item.id === opts.routeId)
|
|
|| routes.find((item) => item.source === opts.source && item.map_id === opts.mapId)
|
|
|| null;
|
|
const space = route?.space || opts.dockSpace;
|
|
const viewBox = opts.viewBoxOf(space);
|
|
if (!viewBox) return null;
|
|
const stored = normalizeRouteMatrix(route ? route.calibration : vacuum?.calibration?.[opts.mapId]);
|
|
const params = (stored && fitFromMatrix(stored)) || initialFit(opts.rooms, viewBox);
|
|
return { space, routeId: route?.id || '', params };
|
|
}
|
|
|
|
/**
|
|
* Which space a calibration is solved against, and which route stores it.
|
|
*
|
|
* Auto-calibration used to match the robot's rooms against the DOCK's rooms,
|
|
* whatever map was on screen — for a multi-floor robot that is the wrong floor
|
|
* half the time (#162, AC8).
|
|
*/
|
|
export function calibrationTarget(
|
|
markerId: string,
|
|
vacuum: MarkerVacuumCfg | null | undefined,
|
|
dockSpace: string,
|
|
source: string,
|
|
mapId: string,
|
|
): { space: string; routeId: string } {
|
|
const route = effectiveRoutes(markerId, vacuum, dockSpace, source)
|
|
.find((item) => item.source === source && item.map_id === mapId);
|
|
return { space: route?.space || dockSpace, routeId: route?.id || '' };
|
|
}
|