feat: smooth vacuum trails (#209)

Issue: #209
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 23:16:58 +03:00
parent 2890b9a1ab
commit 9d2ef12c96
43 changed files with 578 additions and 268 deletions
+25 -14
View File
@@ -104,7 +104,8 @@ import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, readVacTelemetry,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback,
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, normalizeVacPath,
smoothVacPath, VAC_TRAIL_SMOOTH_RADIUS_CM, areaCentroid,
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
@@ -11815,6 +11816,21 @@ export class HouseplanCard extends LitElement {
});
}
/** One bounded current/previous trail serializer for flat and Iso surfaces. */
private _vacTrailPathD(path: VacPath, matrix: Affine): string {
const calibrated = path.map((segment) => segment.map(([x, y]) => applyAffine(matrix, x, y)));
const commands = smoothVacPath(calibrated, this._cmToUnits(VAC_TRAIL_SMOOTH_RADIUS_CM));
const pointText = (point: readonly [number, number]): string => {
const scene = this._scenePoint(point);
return `${scene[0].toFixed(1)} ${scene[1].toFixed(1)}`;
};
return commands.map((segment) => segment.map((command) => {
if (command.kind === 'move') return `M ${pointText(command.point)}`;
if (command.kind === 'line') return `L ${pointText(command.point)}`;
return `Q ${pointText(command.control)} ${pointText(command.point)}`;
}).join(' ')).join(' ');
}
/** Puck + trail for every live vacuum of the space. */
private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
if (this._markup || this._mode === 'decor') return nothing;
@@ -11846,13 +11862,12 @@ export class HouseplanCard extends LitElement {
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
// the PREVIOUS run stays visible even at rest: users compare where the
// robot has been against where it has not (owner call 2026-07-31)
if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) {
const pts = srvPrev.points.map(([x, y]: number[]) => {
const [cx2, cy2] = applyAffine(matrix, x, y);
const point = this._scenePoint([cx2, cy2]);
return point[0].toFixed(1) + ',' + point[1].toFixed(1);
}).join(' ');
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
if (tmode === 'always' && srvPrev) {
const previous = normalizeVacPath(srvPrev.points);
const pathD = this._vacTrailPathD(previous, matrix);
if (pathD) {
trails.push(svg`<g class="prev"><path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path></g>`);
}
}
// One arbitration authority: integration multi-subpath → server run →
// local runtime. Only drawable segments participate.
@@ -11863,13 +11878,9 @@ export class HouseplanCard extends LitElement {
const raw: VacPath = moving && (current.source === 'integration' || current.source === 'server')
? trimVacPathTarget(current.path) : current.path;
if (raw.length) {
const pathD = raw.map((segment) => segment.map(([x, y], index) => {
const [cx, cy] = applyAffine(matrix, x, y);
const point = this._scenePoint([cx, cy]);
return `${index ? 'L' : 'M'} ${point[0].toFixed(1)} ${point[1].toFixed(1)}`;
}).join(' ')).join(' ');
const pathD = this._vacTrailPathD(raw, matrix);
// A single SVG path with several M commands keeps gaps literal.
trails.push(svg`<path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path>`);
if (pathD) trails.push(svg`<path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path>`);
const last = raw[raw.length - 1];
if (moving && last?.length >= 2) {
const anchor = last[last.length - 1];
+61
View File
@@ -11,6 +11,13 @@ export type Affine = [number, number, number, number, number, number];
export type Pt = [number, number];
export type VacPath = Pt[][];
export type VacPathCommand =
| { kind: 'move'; point: Pt }
| { kind: 'line'; point: Pt }
| { kind: 'quadratic'; control: Pt; point: Pt };
export const VAC_TRAIL_SMOOTH_RADIUS_CM = 17.5;
export const VAC_PATH_MAX_SEGMENTS = 64;
export const VAC_PATH_MAX_POINTS = 4000;
@@ -228,6 +235,60 @@ export function trimVacPathTarget(path: VacPath): VacPath {
return out;
}
const finitePoint = (point: Pt): boolean => Number.isFinite(point[0]) && Number.isFinite(point[1]);
const samePoint = (a: Pt, b: Pt): boolean => a[0] === b[0] && a[1] === b[1];
/**
* Turn calibrated plan-space trail points into bounded rounded-corner commands.
*
* Each corner is trimmed by at most half of either adjacent segment and by the
* caller's physical radius. The quadratic lies in the P-B-Q convex hull, so it
* cannot be farther from the source polyline than that radius. This function
* deliberately knows nothing about SVG, Lit, scale conversion or flat/iso
* projection; the renderer serialises and projects the typed commands.
*/
export function smoothVacPath(path: VacPath, maxRadius: number): VacPathCommand[][] {
if (!Number.isFinite(maxRadius) || maxRadius <= 0) return [];
const out: VacPathCommand[][] = [];
for (const rawSegment of path) {
if (!rawSegment.every(finitePoint)) continue;
const segment = rawSegment.filter((point, index) => index === 0 || !samePoint(point, rawSegment[index - 1]));
if (segment.length < 2) continue;
const commands: VacPathCommand[] = [{ kind: 'move', point: segment[0] }];
if (segment.length === 2) {
commands.push({ kind: 'line', point: segment[1] });
out.push(commands);
continue;
}
for (let index = 1; index < segment.length - 1; index++) {
const a = segment[index - 1], b = segment[index], c = segment[index + 1];
const inX = b[0] - a[0], inY = b[1] - a[1];
const outX = c[0] - b[0], outY = c[1] - b[1];
const inLength = Math.hypot(inX, inY), outLength = Math.hypot(outX, outY);
const dot = (inX * outX + inY * outY) / (inLength * outLength);
// An almost exact reversal would fold a quadratic back onto itself. Keep
// that telemetry cusp literal instead of inventing a loop around it.
if (!Number.isFinite(dot) || dot <= -0.999) {
commands.push({ kind: 'line', point: b });
continue;
}
const radius = Math.min(maxRadius, inLength / 2, outLength / 2);
if (!Number.isFinite(radius) || radius <= 0) {
commands.push({ kind: 'line', point: b });
continue;
}
const before: Pt = [b[0] - inX * radius / inLength, b[1] - inY * radius / inLength];
const after: Pt = [b[0] + outX * radius / outLength, b[1] + outY * radius / outLength];
commands.push({ kind: 'line', point: before });
commands.push({ kind: 'quadratic', control: b, point: after });
}
commands.push({ kind: 'line', point: segment[segment.length - 1] });
out.push(commands);
}
return out;
}
/**
* Map-id normalisation contract, shared with the backend recorder
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value