mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
fix(radar): show the room contour in the on-plan setup (#774)
The on-plan radar setup multiplied SpaceModel room contours by 1000 a second time. They are render units already, so the outline was drawn at 40000… far outside the fixed "0 0 1000 1000" viewBox, and the administrator placed mount, heading and references on an empty field. Dropping the multiplier alone is not enough: the fixed board also cut every room drawn outside the historical square, and the pointer mapping ignored the letterbox and clamped presses to [0,1]. The setup now frames the plan with the main card's content frame (docs/CANVAS.md §4, the same items through the editor host port). It opens on core and switches to all when core would cut the configured room; the stored view_box remains the empty-content fallback. The frame is computed once per session. One pure projection serves every layer: the contour is drawn as is, mount, heading, pending/reference marks and the live trail go through plan units x NORM_W, and a press maps back through xMidYMid meet to unclamped plan units, a press in the letterbox placing nothing. Stored radar data, the calibration solve, Apply/Save and View are unchanged. Tests: a unit table for the projection and the frame (offset frame, letterbox, negative and >1 plan units, outlier core -> all, empty-content fallback) and an extended smoke_radar_setup (a room outside [0,1] with view_box [0,0,1,1], vertices and bbox, known presses -> draft and DOM, frame stability across presses, live trail and resize, Apply, Cancel, Escape and reopen, a room without contour, an unknown room). Five unit-guarded mutants are registered. Issue: #774 User-Visible: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
+216
-5
@@ -2,7 +2,13 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { radarConfigFromDraft } from '../test-build/radar-editor.js';
|
||||
import { RadarSetupController } from '../test-build/radar-setup.js';
|
||||
import {
|
||||
RadarSetupController, radarSetupFrame, radarSetupProjection,
|
||||
} from '../test-build/radar-setup.js';
|
||||
import { contentFrame, itemOf } from '../test-build/space-geometry.js';
|
||||
|
||||
/** The historical 1000-unit board: plan units ×1000, no offset. */
|
||||
const BOARD = { x: 0, y: 0, w: 1000, h: 1000 };
|
||||
|
||||
const draft = () => ({
|
||||
original: null, enabled: true, showLive: true, profile: 'presence_v1', roomId: 'living',
|
||||
@@ -27,7 +33,7 @@ test('on-plan installation changes only the editor draft until ordinary Save', (
|
||||
});
|
||||
assert.equal(controller.begin('radar', draft(), {
|
||||
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
}, 5, 7), true);
|
||||
}, 5, 7, BOARD), true);
|
||||
assert.equal(controller.isDirty(), false, 'an untouched setup may close synchronously');
|
||||
controller.choosePoint(pointer(250, 400));
|
||||
assert.equal(controller.isDirty(), true, 'placing the mount makes the setup discard-sensitive');
|
||||
@@ -50,7 +56,7 @@ test('cancelling calibration releases its draft subscription exactly once', asyn
|
||||
});
|
||||
assert.equal(controller.begin('radar', draft(), {
|
||||
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
|
||||
}, 5, 7), true);
|
||||
}, 5, 7, BOARD), true);
|
||||
let calls = 0;
|
||||
controller.unsubscribe = () => { calls += 1; };
|
||||
assert.equal(await controller.cancel(), true);
|
||||
@@ -68,7 +74,7 @@ test('dirty calibration stays open when discard confirmation is rejected', async
|
||||
});
|
||||
assert.equal(controller.begin('radar', draft(), {
|
||||
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
|
||||
}, 5, 7), true);
|
||||
}, 5, 7, BOARD), true);
|
||||
controller.choosePoint(pointer(250, 400));
|
||||
let cleanups = 0;
|
||||
controller.unsubscribe = () => { cleanups += 1; };
|
||||
@@ -86,7 +92,7 @@ test('calibration reports bad reference placement separately from a measurement
|
||||
});
|
||||
assert.equal(controller.begin('radar', draft(), {
|
||||
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
|
||||
}, 5, 7), true);
|
||||
}, 5, 7, BOARD), true);
|
||||
const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200];
|
||||
controller.active.mount = [.5, .5];
|
||||
controller.active.refs = [
|
||||
@@ -103,3 +109,208 @@ test('calibration reports bad reference placement separately from a measurement
|
||||
controller.solve();
|
||||
assert.equal(controller.active.error, 'radar.bad_fit');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #774: one projection for the on-plan setup. The oracles below are computed
|
||||
// by hand from the frame, the SVG rectangle and xMidYMid meet — never by the
|
||||
// production function — so an offset, a scale, a clamp or a frame-relative
|
||||
// normalisation slipping into the code changes a number here.
|
||||
|
||||
const near = (actual, expected, message) => {
|
||||
assert.ok(actual, `${message}: expected a point, got ${actual}`);
|
||||
assert.ok(Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9,
|
||||
`${message}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
|
||||
};
|
||||
|
||||
/** Values of a lit template (recursively) whose static part ends with `suffix`. */
|
||||
const templateValues = (result, suffix) => {
|
||||
const found = [];
|
||||
const walk = (node) => {
|
||||
if (Array.isArray(node)) { node.forEach(walk); return; }
|
||||
if (!node || typeof node !== 'object' || !Array.isArray(node.strings)) return;
|
||||
node.values.forEach((value, index) => {
|
||||
if (node.strings[index].replace(/\s+/g, ' ').endsWith(suffix)) found.push(value);
|
||||
walk(value);
|
||||
});
|
||||
};
|
||||
walk(result);
|
||||
return found;
|
||||
};
|
||||
|
||||
// Offset, non-square frame in a square 400 px SVG at (10, 20): the scale is
|
||||
// 400 / 2000 = 0.2, the frame is 200 px tall, so 100 px of letterbox lie above
|
||||
// (client y 20..120) and below (320..420) the projected viewBox.
|
||||
const WIDE = { x: -500, y: 200, w: 2000, h: 1000 };
|
||||
const SQUARE = { left: 10, top: 20, width: 400, height: 400 };
|
||||
|
||||
test('#774 projection: screen → absolute render units → plan units, letterbox honoured', () => {
|
||||
const view = radarSetupProjection(WIDE);
|
||||
assert.equal(view.viewBox, '-500 200 2000 1000');
|
||||
assert.equal(view.glyph, 2, 'marker glyphs keep their 1000-unit board size');
|
||||
const table = [
|
||||
// client point expected plan units (null = ignored)
|
||||
[[60, 220], [-0.25, 0.7], 'negative x inside an offset frame'],
|
||||
[[390, 300], [1.4, 1.1], 'both axes past 1, never clamped'],
|
||||
[[10, 120], [-0.5, 0.2], 'top-left corner of the projected viewBox is inside'],
|
||||
[[410, 320], [1.5, 1.2], 'bottom-right corner of the projected viewBox is inside'],
|
||||
[[200, 60], null, 'upper letterbox'],
|
||||
[[200, 380], null, 'lower letterbox'],
|
||||
[[200, 119], null, 'one pixel above the projected viewBox'],
|
||||
[[5, 200], null, 'left of the SVG rectangle'],
|
||||
];
|
||||
for (const [[x, y], expected, label] of table) {
|
||||
const actual = view.plan(x, y, SQUARE);
|
||||
if (expected) near(actual, expected, label);
|
||||
else assert.equal(actual, null, label);
|
||||
}
|
||||
// The historical board is the identity case, and a wide rectangle letterboxes sideways.
|
||||
const board = radarSetupProjection(BOARD);
|
||||
near(board.plan(250, 400, { left: 0, top: 0, width: 1000, height: 1000 }), [0.25, 0.4], 'board');
|
||||
const wideRect = { left: 0, top: 0, width: 600, height: 300 };
|
||||
near(board.plan(150, 0, wideRect), [0, 0], 'left edge of a sideways letterbox');
|
||||
near(board.plan(300, 150, wideRect), [0.5, 0.5], 'centre of a sideways letterbox');
|
||||
assert.equal(board.plan(100, 150, wideRect), null, 'sideways letterbox');
|
||||
for (const rect of [
|
||||
{ left: 0, top: 0, width: 0, height: 400 },
|
||||
{ left: 0, top: 0, width: 400, height: 0 },
|
||||
{ left: 0, top: 0, width: Number.NaN, height: 400 },
|
||||
]) assert.equal(view.plan(100, 100, rect), null, `degenerate rect ${JSON.stringify(rect)}`);
|
||||
assert.deepEqual(view.scene([-0.25, 1.4]), [-250, 1400], 'plan → render is ×NORM_W, unclamped');
|
||||
});
|
||||
|
||||
const L_ROOM = { id: 'r_l', name: 'L', poly: [
|
||||
[100, 200], [700, 200], [700, 450], [400, 450], [400, 600], [100, 600]] };
|
||||
const OUT_ROOM = { id: 'r_out', name: 'Out', poly: [
|
||||
[-550, -400], [-150, -400], [-150, -100], [-350, -100], [-550, -250]] };
|
||||
const spaceOf = (rooms, vb = [0, 0, 1000, 1000]) => ({
|
||||
id: 's', title: 'S', cellCm: 5, vb, bg: null, rooms, stairs: [],
|
||||
});
|
||||
const itemsOf = (rooms, devices = []) => [
|
||||
...rooms.map((room) => itemOf(room.poly)),
|
||||
...devices.map(([x, y]) => ({ minX: x, minY: y, maxX: x, maxY: y })),
|
||||
];
|
||||
|
||||
test('#774 frame: the content frame, not the stored view_box, frames a room outside [0,1]', () => {
|
||||
const rooms = [L_ROOM, OUT_ROOM];
|
||||
const items = itemsOf(rooms, [[300, 300]]);
|
||||
// bbox x -550..700 (1250), y -400..600 (1000); 5 % of the longer side = 62.5
|
||||
const expected = { x: -612.5, y: -462.5, w: 1375, h: 1125 };
|
||||
for (const room of rooms) {
|
||||
assert.deepEqual(radarSetupFrame(spaceOf(rooms), room, items), expected, room.id);
|
||||
}
|
||||
const frame = radarSetupFrame(spaceOf(rooms), OUT_ROOM, items);
|
||||
const own = itemOf(OUT_ROOM.poly);
|
||||
assert.ok(frame.x <= own.minX && frame.y <= own.minY
|
||||
&& frame.x + frame.w >= own.maxX && frame.y + frame.h >= own.maxY,
|
||||
'the whole contour of the room outside [0,1] fits the frame');
|
||||
assert.ok(own.maxX < 0 && own.maxY < 0, 'premise: the stored unit view_box would cut it entirely');
|
||||
for (const vb of [[5000, 5000, 2000, 2000], [-3, 7, 0.5, 9]]) {
|
||||
assert.deepEqual(radarSetupFrame(spaceOf(rooms, vb), L_ROOM, items), expected,
|
||||
`replacing the stored view_box ${vb} does not move a content frame`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#774 frame: the configured room is never left to the outlier vote (core → all)', () => {
|
||||
const square = (id, x, y, side = 100) => ({ id, name: id, poly: [
|
||||
[x, y], [x + side, y], [x + side, y + side], [x, y + side]] });
|
||||
const near4 = [square('a', 400, 400), square('b', 500, 400), square('c', 400, 500),
|
||||
square('d', 500, 500)];
|
||||
const far = square('far', 40000, 40000, 500);
|
||||
const rooms = [...near4, far];
|
||||
const items = itemsOf(rooms);
|
||||
const { core, all, outliers } = contentFrame(items);
|
||||
assert.equal(outliers, 1, 'premise: the vote rejects the far room');
|
||||
assert.deepEqual(core, { x: 390, y: 390, w: 220, h: 220 });
|
||||
assert.deepEqual(all, { x: -1605, y: -1605, w: 44110, h: 44110 });
|
||||
assert.ok(core.x + core.w < 40000, 'core excludes the far room');
|
||||
assert.deepEqual(radarSetupFrame(spaceOf(rooms), far, items), all, 'the far room opens on all');
|
||||
assert.deepEqual(radarSetupFrame(spaceOf(rooms), near4[0], items), core,
|
||||
'a room inside the main mass keeps the ordinary opening frame');
|
||||
});
|
||||
|
||||
test('#774 frame: the stored view_box is only the empty-content fallback', () => {
|
||||
const room = { id: 'gone', name: 'No contour' };
|
||||
assert.deepEqual(radarSetupFrame(spaceOf([], [300, -200, 1500, 900]), room, []),
|
||||
{ x: 300, y: -200, w: 1500, h: 900 }, 'a valid offset hint is used verbatim');
|
||||
for (const vb of [[0, 0, 0, 1000], [0, 0, -5, 10], undefined]) {
|
||||
assert.deepEqual(radarSetupFrame(spaceOf([], vb), room, []), BOARD,
|
||||
`invalid hint ${JSON.stringify(vb)} → the standard unit frame`);
|
||||
}
|
||||
});
|
||||
|
||||
const coordinateDraft = () => ({
|
||||
...draft(), profile: 'cartesian_v1', roomId: 'r_out',
|
||||
xEntities: ['sensor.x'], yEntities: ['sensor.y'], swapXY: [false], xSigns: [1], ySigns: [1],
|
||||
slotPresenceEntities: [''], distanceEntities: [''], angleEntities: [''],
|
||||
});
|
||||
const press = (x, y, rect = SQUARE) => ({
|
||||
clientX: x, clientY: y, currentTarget: { getBoundingClientRect: () => rect },
|
||||
});
|
||||
|
||||
test('#774 every layer goes through the one projection; the frame stays put', () => {
|
||||
let applied = null;
|
||||
const controller = new RadarSetupController({
|
||||
hass: () => ({}), requestUpdate() {}, t: (key) => key,
|
||||
configFromDraft: radarConfigFromDraft, apply: (next) => { applied = next; },
|
||||
confirmDiscard: async () => true,
|
||||
});
|
||||
assert.equal(controller.begin('radar', coordinateDraft(), OUT_ROOM, 5, 7, WIDE), true);
|
||||
const frame = controller.active.projection.frame;
|
||||
controller.choosePoint(press(200, 60));
|
||||
assert.equal(controller.active.mount, null, 'a press in the letterbox places nothing');
|
||||
assert.equal(controller.active.phase, 'mount');
|
||||
controller.choosePoint(press(60, 220));
|
||||
near(controller.active.mount, [-0.25, 0.7], 'mount');
|
||||
controller.choosePoint(press(390, 220));
|
||||
near(controller.active.headingPoint, [1.4, 0.7], 'heading point');
|
||||
assert.equal(controller.active.draft.mountX, '-250');
|
||||
assert.equal(controller.active.draft.mountY, '700');
|
||||
assert.equal(controller.active.draft.heading, '90');
|
||||
assert.equal(controller.active.phase, 'reference_1');
|
||||
controller.choosePoint(press(60, 300));
|
||||
near(controller.active.pendingPlan, [-0.25, 1.1], 'pending reference');
|
||||
// Live diagnostics: 120 cm straight ahead (east) of the mount, then 60 cm to its right.
|
||||
controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 0, y_cm: 120 }] });
|
||||
controller.receive({ local_targets: [{ slot: 'target_1', x_cm: 60, y_cm: 120 }] });
|
||||
assert.equal(controller.active.projection.frame, frame, 'clicks and live data keep the session frame');
|
||||
const view = controller.render();
|
||||
assert.deepEqual(templateValues(view, '<svg viewBox='), ['-500 200 2000 1000']);
|
||||
assert.deepEqual(templateValues(view, 'class="room" points='),
|
||||
['-550,-400 -150,-400 -150,-100 -350,-100 -550,-250'], 'the contour is drawn once, untouched');
|
||||
assert.deepEqual(templateValues(view, 'x1='), [-250]);
|
||||
assert.deepEqual(templateValues(view, 'y1='), [700]);
|
||||
assert.deepEqual(templateValues(view, 'x2='), [1400]);
|
||||
assert.deepEqual(templateValues(view, 'y2='), [700]);
|
||||
assert.deepEqual(templateValues(view, 'transform="translate('), ['-250 700', '-250 1100'],
|
||||
'mount and pending marker in the same render units');
|
||||
assert.deepEqual(templateValues(view, ') scale('), [2, 2]);
|
||||
const trail = templateValues(view, 'class="trail" points=');
|
||||
assert.equal(trail.length, 1);
|
||||
const points = trail[0].split(' ').map((pair) => pair.split(',').map(Number));
|
||||
near(points[0], [-150, 700], 'trail sample 1 (120 cm ahead = +0.1 plan)');
|
||||
near(points[1], [-150, 750], 'trail sample 2 (60 cm right = +0.05 plan)');
|
||||
|
||||
// Presence profile: Apply hands back plain plan units, unclamped, and nothing else.
|
||||
assert.equal(controller.begin('radar', draft(), OUT_ROOM, 5, 7, WIDE), true);
|
||||
controller.choosePoint(press(60, 220));
|
||||
controller.choosePoint(press(390, 220));
|
||||
controller.apply();
|
||||
assert.equal(applied.mountX, '-250');
|
||||
assert.equal(applied.mountY, '700');
|
||||
assert.equal(applied.heading, '90');
|
||||
assert.deepEqual(radarConfigFromDraft(applied, 5).mount.x, -0.25);
|
||||
assert.deepEqual(radarConfigFromDraft(applied, 5).mount.y, 0.7);
|
||||
});
|
||||
|
||||
test('#774 a room without a contour draws no invented outline', () => {
|
||||
const controller = new RadarSetupController({
|
||||
hass: () => ({}), requestUpdate() {}, t: (key) => key,
|
||||
configFromDraft: radarConfigFromDraft, apply() {}, confirmDiscard: async () => true,
|
||||
});
|
||||
const room = { id: 'living', name: 'Legacy', x: 100, y: 100, w: 300, h: 200 };
|
||||
const space = spaceOf([room]);
|
||||
const frame = radarSetupFrame(space, room, [itemOf([[100, 100], [400, 300]])]);
|
||||
assert.equal(controller.begin('radar', draft(), room, 5, 7, frame), true);
|
||||
assert.deepEqual(templateValues(controller.render(), 'class="room" points='), [''],
|
||||
'the camera frame is not substituted for the missing polygon');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user