mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
After #714 the 2.5D overlay scene still carried what decides nothing: - src/iso-overlays.ts: IsoOverlayPlacement loses tether and grounding (always invisible) and raisedScene (always equal to visualScene); IsoOverlayOwner loses area; IsoOverlayPlacementInput loses hovered, focused, selected and filtersSupported, which the resolver ignored. IsoWallSilhouette and tetherGeometry go with them. - src/iso-scene-render.ts: the structural scene no longer projects wall silhouettes (isoWallSilhouettesOf and IsoSceneCacheEntry.wallSilhouettes) that served only as a cache key. The placement and render-scene caches are keyed by the wall geometry the scene is drawn with (IsoOverlaySceneInput. structure = scene.geometry): the structural LRU hands out the same object across zoom, stage resize and HA state, and a new one after any wall, room or opening edit. The resolveCollisions flag and its fit/live cache slots are gone: since #713 both held equal placements, and 2.5D renders only in View, where the fit probe and the live frame ask with the same devices, so they now read one snapshot. - src/houseplan-card.ts: the fit call passes no flag; the overlay scene gets structural.geometry. data-hp-iso-nudged stays the constant "false" read by the golden requireOneRise preflight, the live-touch smoke and the benchmark. Tests: iso-overlays pins the placement fields; iso-scene-render builds the structure with buildIsoWallGeometry, the #714 zoom/resize and #711 state tests stay, fit and live are asserted to share one snapshot, and two #724 AC2 tests run the production path (createIsoStructuralSource -> resolveIsoScene -> buildIsoOverlayRenderScene): a thicker wall with the same room rebuilds the scene (red with a key without walls, e.g. keyed by the room rows), and a room edit that moves the owner gives the new owner (red with a constant key). The silhouette-construction test goes with the construction. Mutants: #473 W2 (iso-placement-cache-survives-silhouette-change, id kept for history) now keys the placement cache by a constant instead of input.structure and its guard also runs the #724 AC2 tests; W6 patches the new structure line; the W5 description no longer speaks of a nudge. The isometric-contract regex checks the new key instead of the silhouette construction. docs/ISOMETRIC.md names the key. Live 2.5D output is unchanged: the 21 isometric golden scenes pass on the accepted baselines. Issue: #724 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
135 lines
5.8 KiB
JavaScript
135 lines
5.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildIsoFootprintPolygon,
|
|
isoOverlayPlane,
|
|
resolveIsoOverlayOwner,
|
|
resolveIsoOverlayPlacement,
|
|
} from '../test-build/iso-overlays.js';
|
|
import {
|
|
ISO_RAISED_OVERLAY_HEIGHT,
|
|
projectPlanPoint,
|
|
} from '../test-build/iso-projection.js';
|
|
|
|
const close = (actual, expected, epsilon = 1e-7) =>
|
|
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
|
|
|
const square = (id, x0, y0, x1, y1) => ({
|
|
id,
|
|
outer: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
|
|
});
|
|
|
|
const placement = (overrides = {}) => resolveIsoOverlayPlacement({
|
|
kind: 'device',
|
|
floorAnchor: [50, 50],
|
|
rooms: [square('room', 0, 0, 100, 100)],
|
|
preferredRoomId: 'room',
|
|
showBorders: true,
|
|
footprintHalfSize: [4, 4],
|
|
...overrides,
|
|
});
|
|
|
|
test('the exact Stage 4 overlay matrix keeps only the three interactive roots on the low plane', () => {
|
|
for (const kind of ['device', 'room-label', 'opening-lock'])
|
|
assert.equal(isoOverlayPlane(kind, true), 'raised', kind);
|
|
for (const kind of [
|
|
'vacuum', 'vacuum-trail', 'glow', 'room-fill', 'room-hover',
|
|
'sunlight', 'decor', 'furniture', 'backdrop',
|
|
]) assert.equal(isoOverlayPlane(kind, true), 'floor', kind);
|
|
for (const kind of ['device', 'room-label', 'opening-lock'])
|
|
assert.equal(isoOverlayPlane(kind, false), 'floor', `${kind} without borders`);
|
|
});
|
|
|
|
test('owner resolution honours bindings, then canonical minimum area and stable id', () => {
|
|
const rooms = [
|
|
square('wide', 0, 0, 100, 100),
|
|
square('z-small', 25, 25, 75, 75),
|
|
square('a-small', 25, 25, 75, 75),
|
|
];
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [50, 50], rooms, preferredRoomId: 'wide',
|
|
})?.id, 'wide', 'a valid explicit device binding wins');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [50, 50], rooms, preferredRoomId: 'missing',
|
|
})?.id, 'a-small', 'minimum area and then stable id resolve the fallback');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'room-label', floorAnchor: [150, 150], rooms, preferredRoomId: 'wide',
|
|
})?.id, 'wide', 'a saved room label may live outside its owning room');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'opening-lock', floorAnchor: [100, 50], rooms, preferredRoomId: 'wide',
|
|
})?.id, 'wide', 'the opening host supplies lock ownership');
|
|
});
|
|
|
|
test('strict room ownership excludes holes, shared boundaries and outside points', () => {
|
|
const ring = {
|
|
...square('ring', 0, 0, 100, 100),
|
|
holes: [[[40, 40], [60, 40], [60, 60], [40, 60]]],
|
|
};
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [20, 20], rooms: [ring],
|
|
})?.id, 'ring');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [50, 50], rooms: [ring],
|
|
}), null, 'a point in a room hole has no guessed owner');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [0, 50], rooms: [ring],
|
|
}), null, 'a point on a shared/boundary edge is not strictly contained');
|
|
assert.equal(resolveIsoOverlayOwner({
|
|
kind: 'device', floorAnchor: [150, 50], rooms: [ring],
|
|
}), null, 'an outside saved point stays ownerless');
|
|
});
|
|
|
|
test('a degenerate or missing owner room still raises the overlay straight up', () => {
|
|
const degenerate = { id: 'line', outer: [[0, 0], [50, 0], [100, 0]] };
|
|
for (const [name, overrides] of [
|
|
['degenerate room', { rooms: [degenerate], preferredRoomId: degenerate.id }],
|
|
['no room at all', { floorAnchor: [150, 50], rooms: [], preferredRoomId: null }],
|
|
]) {
|
|
const result = placement(overrides);
|
|
assert.equal(result.owner, null, name);
|
|
assert.equal(result.plane, 'raised', name);
|
|
assert.deepEqual(result.visualScene,
|
|
projectPlanPoint(result.floorAnchor, ISO_RAISED_OVERLAY_HEIGHT), `${name}: no move without an owner`);
|
|
}
|
|
});
|
|
|
|
test('footprint corners use the same affine camera on the raised plane', () => {
|
|
const footprint = buildIsoFootprintPolygon([50, 50], [10, 5], ISO_RAISED_OVERLAY_HEIGHT);
|
|
const logical = [[40, 45], [60, 45], [60, 55], [40, 55]];
|
|
logical.forEach((point, index) => {
|
|
const projected = projectPlanPoint(point, ISO_RAISED_OVERLAY_HEIGHT);
|
|
close(footprint[index][0], projected[0]);
|
|
close(footprint[index][1], projected[1]);
|
|
});
|
|
close(footprint[0][1], footprint[1][1]);
|
|
assert.ok(footprint[2][1] > footprint[1][1],
|
|
'zero yaw keeps the screen-facing footprint aligned with the plan axes');
|
|
});
|
|
|
|
// #724: what a placement carries is what someone reads — no invisible tether or
|
|
// grounding, no second raised point equal to the visual one, no owner area.
|
|
const PLACEMENT_FIELDS = ['floorAnchor', 'floorScene', 'footprint', 'owner', 'plane', 'visualScene'];
|
|
|
|
test('free low overlay separates the immutable floor anchor from its invisible footprint', () => {
|
|
const normal = placement();
|
|
assert.deepEqual(Object.keys(normal).sort(), PLACEMENT_FIELDS);
|
|
assert.equal(normal.plane, 'raised');
|
|
assert.deepEqual(normal.owner, { id: 'room' });
|
|
assert.deepEqual(normal.floorAnchor, [50, 50]);
|
|
assert.deepEqual(normal.floorScene, projectPlanPoint([50, 50], 0));
|
|
assert.deepEqual(normal.visualScene, projectPlanPoint([50, 50], ISO_RAISED_OVERLAY_HEIGHT));
|
|
assert.deepEqual(normal.footprint,
|
|
buildIsoFootprintPolygon([50, 50], [4, 4], ISO_RAISED_OVERLAY_HEIGHT));
|
|
assert.deepEqual(placement({ visualOffset: 0 }).visualScene, normal.floorScene,
|
|
'a zero offset keeps the root on its floor point (#713 room names)');
|
|
});
|
|
|
|
test('show_borders:false is exact no-volume: floor anchor, no footprint/cues', () => {
|
|
const result = placement({ showBorders: false });
|
|
assert.deepEqual(Object.keys(result).sort(), PLACEMENT_FIELDS);
|
|
assert.equal(result.plane, 'floor');
|
|
assert.equal(result.owner, null);
|
|
assert.deepEqual(result.visualScene, result.floorScene);
|
|
assert.deepEqual(result.footprint, []);
|
|
});
|