Files
houseplan-card/test/iso-scene-render.test.mjs
2026-09-26 03:41:32 +03:00

955 lines
42 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ISO_RAISED_FOOTPRINT,
buildIsoWallDepthQueue,
buildIsoOverlayRenderScene,
createIsoStructuralSource,
isoOpeningLockPlacement,
isoFixedLightTransform,
isoOverlaySceneBounds,
isoOverlayRooms,
isoRaisedOverlayHalfSize,
isoSourceOpenings,
isoStructuralOpeningHost,
isoStructuralRoomGeometry,
resolveIsoDecorationLayers,
resolveIsoFramePresentation,
resolveIsoOverlayFitEnvelope,
resolveIsoScene,
} from '../test-build/iso-scene-render.js';
import {
ISO_OPENING_GEOMETRY_POLICY,
buildIsoOpeningBasis,
projectIsoOpening,
projectIsoOpeningStructure,
} from '../test-build/iso-openings.js';
import { buildIsoWallGeometry } from '../test-build/iso-walls.js';
import { wallKey } from '../test-build/wall-thickness.js';
import {
buildIsoFootprintPolygon,
resolveIsoOverlayOwner,
resolveIsoOverlayPlacement,
} from '../test-build/iso-overlays.js';
import {
ISO_CAMERA,
ISO_OVERLAY_VISUAL_OFFSET,
ISO_RAISED_OVERLAY_HEIGHT,
ISO_WALL_HEIGHT,
unprojectFloorPoint,
} from '../test-build/iso-projection.js';
const room = (id, x0, y0, x1, y1) => ({
id,
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
});
test('every Stage 4 shadow plane uses the same scale-aware fixed-light vector', () => {
assert.equal(isoFixedLightTransform(5), 'translate(4 8)');
assert.equal(isoFixedLightTransform(1), 'translate(20 40)');
});
test('device footprint contains value capsule, bottom badge and displaced LQI row', () => {
const core = 100;
const presentation = {
valueText: '12345678',
valueFullText: '12345678',
valueBadge: {
configured: true,
enabled: true,
source: null,
sourceLabel: 'Energy',
text: '123.4 kWh',
fullText: '123.4 kWh',
position: 'bottom',
availability: 'available',
isLqi: false,
tone: 'default',
failure: null,
},
tempText: null,
humText: null,
lqiText: '255',
pulse: {
kind: 'none', reason: 'none', generation: 1, expiresAt: null,
color: null, diameterScale: 1.5, animated: false, reducedMotionIndicator: 'none',
},
};
const halfSize = isoRaisedOverlayHalfSize({ kind: 'device', core, presentation });
const lqiBottom = (ISO_RAISED_FOOTPRINT.deviceLqiBelowBottomBadgeTop
+ ISO_RAISED_FOOTPRINT.deviceLqiFontSize
+ ISO_RAISED_FOOTPRINT.devicePadding) * core;
assert.equal(halfSize[1], lqiBottom,
'bottom LQI position, line box and conservative footprint padding are all included');
assert.ok(halfSize[0] > core * 1.6,
'the expanding value core is not reduced to the shell diameter');
});
test('room footprint contains a long name and the complete four-metric row', () => {
const font = 20;
const labelRoom = {
...room('metrics', 0, 0, 100, 100),
name: 'Long engineering and utility room',
area: 'utility',
settings: { name_scale: 1.25, label_scale: 1.5 },
};
const halfSize = isoRaisedOverlayHalfSize({
kind: 'room-label',
font,
room: labelRoom,
display: { labelTemp: true, labelHum: true, labelLqi: true, labelLight: true },
});
const oldSymmetricMetricHalfWidth = font * 11.8 / 2;
const metricsBottom = (ISO_RAISED_FOOTPRINT.roomNameLineHeight * 1.25 / 2
+ ISO_RAISED_FOOTPRINT.roomMetricsTopGap
+ ISO_RAISED_FOOTPRINT.roomMetricFontSize * 1.5
* ISO_RAISED_FOOTPRINT.roomMetricLineHeight
+ ISO_RAISED_FOOTPRINT.roomPadding) * font;
assert.ok(halfSize[0] > oldSymmetricMetricHalfWidth * 2,
'four metric values, their icons and inter-item gaps exceed the legacy 11em guess');
assert.equal(halfSize[1], metricsBottom,
'absolute metrics below the centred name are included instead of halving total height');
});
test('room footprint treats wide ASCII glyphs conservatively', () => {
const footprint = (name) => isoRaisedOverlayHalfSize({
kind: 'room-label', font: 20,
room: { ...room('wide', 0, 0, 100, 100), name, settings: {} },
display: { labelTemp: false, labelHum: false, labelLqi: false, labelLight: false },
});
assert.ok(footprint('WWWWWW')[0] > footprint('iiiiii')[0] * 1.25,
'bold W/M labels must not escape a Latin-average fit estimate');
});
test('overlay bounds use final screen footprint and canonical owner filtering', () => {
const placement = (owner, center) => ({
owner: { id: owner }, floorScene: [center[0] - 5, center[1]],
raisedScene: [center[0] - 4, center[1]], visualScene: center,
nudgeScene: [4, 0],
footprint: [[center[0] - 2, center[1] - 1], [center[0] + 2, center[1] - 1],
[center[0] + 2, center[1] + 1], [center[0] - 2, center[1] + 1]],
});
const scene = { entries: [
{ id: 'one', kind: 'device', placement: placement('r1', [20, 30]),
groundRadius: 1, screenHalfSize: [5, 3] },
{ id: 'two', kind: 'device', placement: placement('r2', [200, 300]),
groundRadius: 1, screenHalfSize: [10, 8] },
] };
assert.deepEqual(isoOverlaySceneBounds(scene, 'r1'), { x: 15, y: 27, w: 10, h: 6 });
assert.deepEqual(isoOverlaySceneBounds(scene), { x: 15, y: 27, w: 195, h: 281 });
});
test('stable overlay fit contains a maximum final nudge without zoom feedback', () => {
const entry = {
id: 'edge', kind: 'device', groundRadius: 1, screenHalfSize: [10, 8],
placement: {
owner: { id: 'room' }, floorScene: [95, 50], raisedScene: [95, 50],
visualScene: [95, 50], nudgeScene: [0, 0],
footprint: [[90, 46], [100, 46], [100, 54], [90, 54]],
},
};
const stageSize = { width: 320, height: 180 };
const targetView = (bounds) => {
const aspect = stageSize.width / stageSize.height;
if (bounds.w / bounds.h > aspect) {
const h = bounds.w / aspect;
return { x: bounds.x, y: bounds.y - (h - bounds.h) / 2, w: bounds.w, h };
}
const w = bounds.h * aspect;
return { x: bounds.x - (w - bounds.w) / 2, y: bounds.y, w, h: bounds.h };
};
const fitted = resolveIsoOverlayFitEnvelope({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], stageSize, targetView,
});
assert.ok(fitted);
const unitsPerPixel = Math.max(
fitted.view.w / stageSize.width, fitted.view.h / stageSize.height,
);
const dx = 48 * unitsPerPixel;
const actual = { entries: [{ ...entry, placement: {
...entry.placement, visualScene: [95 + dx, 50], nudgeScene: [dx, 0],
footprint: entry.placement.footprint.map((point) => [point[0] + dx, point[1]]),
} }] };
const final = isoOverlaySceneBounds(actual);
assert.ok(final.x >= fitted.bounds.x - 1e-7
&& final.x + final.w <= fitted.bounds.x + fitted.bounds.w + 1e-7);
const repeated = resolveIsoOverlayFitEnvelope({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], stageSize, targetView,
});
assert.deepEqual(repeated, fitted, 'the canonical envelope is deterministic');
const otherRoom = resolveIsoOverlayFitEnvelope({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], stageSize,
targetView, ownerId: 'other-room',
});
assert.deepEqual(otherRoom.bounds, { x: 0, y: 0, w: 100, h: 100 });
});
test('one painter queue paints a nearer wall after an unrelated rear opening', () => {
const geometry = buildIsoWallGeometry([[[
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
]]]);
const nearWall = geometry.topFaces[0];
const rearOpening = {
id: 'rear-door', sourceIndex: 4, type: 'door', leaf: 0,
kind: 'leaf-front', material: 'matte-leaf', d: 'M 0 0 L 1 0 L 1 1 Z',
depth: nearWall.depth - 10, cameraDepth: nearWall.depth - 10,
};
const queue = buildIsoWallDepthQueue(geometry, [rearOpening]);
const rearIndex = queue.findIndex((entry) => entry.layer === 'opening');
const nearIndex = queue.findIndex((entry) => entry.layer === 'wall-top'
&& entry.face === nearWall);
assert.ok(rearIndex >= 0 && nearIndex > rearIndex,
'later SVG paint order must let the near wall occlude the rear opening');
assert.deepEqual(buildIsoWallDepthQueue(geometry, [rearOpening]), queue,
'the combined wall/opening order is deterministic');
});
test('shared painter queue puts elevated window glass over its rear sill only in window slots', () => {
const geometry = buildIsoWallGeometry([[[
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
]]]);
const basis = buildIsoOpeningBasis({
id: 'window-overlap', sourceIndex: 2, type: 'window', x: 50, y: 100,
angle: 0, length: 60, flipH: false, flipV: false,
face: { ox: 0, oy: -5, cm: 20, side: -1 },
});
const surfaces = [
...projectIsoOpeningStructure(basis),
...projectIsoOpening(basis, 0).flatMap((panel) => panel.surfaces),
].map((surface, index) => ({
...surface, id: basis.id, sourceIndex: basis.sourceIndex,
type: 'window', leaf: index,
}));
const queue = buildIsoWallDepthQueue(geometry, surfaces);
const windowEntries = queue.filter((entry) => entry.layer === 'opening');
assert.deepEqual(windowEntries.map((entry) => entry.surface.cameraDepth),
windowEntries.map((entry) => entry.surface.cameraDepth).toSorted((a, b) => a - b),
'one window reuses its shared queue slots in physical camera-depth order');
const sillIndex = queue.findIndex((entry) => entry.layer === 'opening'
&& entry.surface.kind === 'window-sill');
const glassIndices = queue.flatMap((entry, index) => entry.layer === 'opening'
&& entry.surface.material.startsWith('glass') ? [index] : []);
assert.ok(sillIndex >= 0 && glassIndices.length === 6
&& glassIndices.some((index) => index > sillIndex),
'elevated glass must paint after the rear sill projection');
const wallSlots = queue.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]);
const reversed = buildIsoWallDepthQueue(geometry, [...surfaces].reverse());
assert.deepEqual(reversed.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]),
wallSlots, 'window-local occlusion cannot move unrelated wall slots');
});
test('shared painter queue keeps rotating door prism faces in physical camera order', () => {
const geometry = buildIsoWallGeometry([[[
[0, 100], [100, 100], [100, 200], [0, 200], [0, 100],
]]]);
const basis = buildIsoOpeningBasis({
id: 'door-depth', sourceIndex: 3, type: 'door', x: 50, y: 100,
angle: 0, length: 45, flipH: false, flipV: false,
face: { ox: 0, oy: -5, cm: 20, side: -1 },
});
const surfaces = [
...projectIsoOpeningStructure(basis),
...projectIsoOpening(basis, 0.5).flatMap((panel) => panel.surfaces),
].map((surface, index) => ({
...surface, id: basis.id, sourceIndex: basis.sourceIndex,
type: 'door', leaf: index,
}));
const queue = buildIsoWallDepthQueue(geometry, [...surfaces].reverse());
const doorEntries = queue.filter((entry) => entry.layer === 'opening');
assert.deepEqual(doorEntries.map((entry) => entry.surface.cameraDepth),
doorEntries.map((entry) => entry.surface.cameraDepth).toSorted((a, b) => a - b),
'all faces of one live door reuse its queue slots in physical camera-depth order');
const wallSlots = queue.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]);
const natural = buildIsoWallDepthQueue(geometry, surfaces);
assert.deepEqual(natural.flatMap((entry, index) => entry.layer === 'opening' ? [] : [index]),
wallSlots, 'door-local ordering cannot move unrelated wall slots');
});
test('production overlay rooms preserve direct island holes and cache safe points', () => {
const outer = room('outer', 0, 0, 100, 100);
const island = room('island', 40, 40, 60, 60);
const space = { id: 'floor', rooms: [outer, island] };
const rows = isoOverlayRooms(space);
assert.strictEqual(isoOverlayRooms(space), rows, 'one immutable room snapshot is prepared once');
assert.deepEqual(rows[0].overlayRoom.holes, [island.poly]);
const safe = rows[0].overlayRoom.safePoint;
assert.ok(safe, 'outer ring has a proven safe point');
assert.equal(safe[0] > 40 && safe[0] < 60 && safe[1] > 40 && safe[1] < 60, false,
'the parent safe point cannot land in the island hole');
const owner = resolveIsoOverlayOwner({
kind: 'device', floorAnchor: [50, 50], rooms: rows.map((row) => row.overlayRoom),
});
assert.equal(owner?.id, 'island', 'a marker in the island belongs to the island, not its parent');
});
test('opening-lock placement inherits the selected physical host side', () => {
const index = {
adjacencyEps: 0.1,
edges: [
{
roomId: 'north', a: [-50, 0], b: [50, 0], inward: [0, 1],
cm: 40, half: 20, area: 1000, key: 'north-wall',
},
{
roomId: 'south', a: [-50, 0], b: [50, 0], inward: [0, -1],
cm: 40, half: 20, area: 1000, key: 'south-wall',
},
],
};
const opening = {
id: 'door', type: 'door', rx: 0, ry: 0, rlen: 40, angle: 0,
flip_h: false, flip_v: false,
};
const positive = isoOpeningLockPlacement(opening, index, 5);
const negative = isoOpeningLockPlacement({ ...opening, flip_v: true }, index, 5);
assert.equal(positive.preferredRoomId, 'north');
assert.equal(negative.preferredRoomId, 'south');
assert.ok(positive.floorAnchor[1] > 0 && negative.floorAnchor[1] < 0);
});
test('opening-lock scene keeps physical host ownership when spatial fallback points elsewhere', () => {
const host = room('host', 0, 0, 100, 100);
const decoy = room('decoy', 45, 60, 55, 75);
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [host, decoy], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const openingWallIndex = {
adjacencyEps: 0.1,
edges: [{
roomId: 'host', a: [0, 50], b: [100, 50], inward: [0, 1],
cm: 40, half: 20, area: 10_000, key: 'host-wall',
}],
};
const opening = {
id: 'door', type: 'door', rx: 50, ry: 50, rlen: 40, angle: 0,
flip_h: false, flip_v: false, lock: 'lock.door',
};
const roomRows = isoOverlayRooms(space);
const spatialOwner = resolveIsoOverlayOwner({
kind: 'device',
floorAnchor: isoOpeningLockPlacement(opening, openingWallIndex, 5).floorAnchor,
rooms: roomRows.map((row) => row.overlayRoom),
});
assert.equal(spatialOwner?.id, 'decoy', 'fixture proves point containment would pick the wrong room');
const scene = buildIsoOverlayRenderScene({
space,
devices: [],
openings: [opening],
view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { shadows: true },
wallSilhouettes: [],
iconPct: 100,
deviceBasePct: 100,
showLqi: false,
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 0, y: 0 }),
presentationOf: () => ({ scale: 1 }),
labelPositionOf: () => ({ x: 0, y: 0 }),
labelScaleOf: () => 1,
openingEntityAvailable: () => true,
openingWallIndex: () => openingWallIndex,
});
assert.equal(scene.locks.get('door')?.owner?.id, 'host',
'render-scene lock ownership comes from the selected physical wall side');
});
test('opening lock without a canonical host owner never guesses from point containment', () => {
const containing = room('containing', 0, 0, 100, 100);
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [containing], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const opening = {
id: 'partition-door', type: 'door', rx: 50, ry: 50, rlen: 40, angle: 0,
flip_h: false, flip_v: false, lock: 'lock.door',
partitionHost: {
depth: 10,
axis: { ux: 1, uy: 0 },
partition: { cm: 10 },
},
};
const scene = buildIsoOverlayRenderScene({
space,
devices: [],
openings: [opening],
view: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { shadows: true },
wallSilhouettes: [{ outer: [[0, 45], [100, 45], [100, 55], [0, 55]] }],
iconPct: 100,
deviceBasePct: 100,
showLqi: false,
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 0, y: 0 }),
presentationOf: () => ({ scale: 1 }),
labelPositionOf: () => ({ x: 0, y: 0 }),
labelScaleOf: () => 1,
openingEntityAvailable: () => true,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
});
const placement = scene.locks.get('partition-door');
assert.equal(placement?.owner, null);
assert.equal(placement?.nudged, false);
assert.equal(placement?.reason, 'missing-owner');
assert.equal(placement?.tether.visible, false);
});
test('Stage 4 reuses pure overlay placements and fit probes skip collision search', () => {
const owner = room('owner', 0, 0, 100, 100);
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const wallSilhouettes = [{
outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_WALL_HEIGHT),
}];
const input = {
space,
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
openings: [],
view: { x: 0, y: 0, w: 100, h: 100 },
referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true },
wallSilhouettes,
iconPct: 3.4,
deviceBasePct: 3.4,
showLqi: false,
cellCm: 5,
kioskIconScale: 1,
kioskFontScale: 1,
stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 5, y: 50 }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
tempText: null, humText: null, lqiText: null,
pulse: { animated: false, diameterScale: 1 },
}),
labelPositionOf: () => ({ x: 0, y: 0 }),
labelScaleOf: () => 1,
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
const live = buildIsoOverlayRenderScene(input);
const repeated = buildIsoOverlayRenderScene(input);
assert.strictEqual(repeated, live,
'an unchanged frame reuses the exact render-scene snapshot for Lit guards');
assert.strictEqual(repeated.devices.get('device'), live.devices.get('device'),
'unchanged HA/render passes reuse the exact pure placement result');
assert.equal(live.devices.get('device')?.nearWallBefore, true);
assert.equal(live.devices.get('device')?.cleared, true);
const zoomedIn = buildIsoOverlayRenderScene({
...input, view: { x: 0, y: 0, w: 80, h: 80 },
});
assert.strictEqual(zoomedIn, live,
'zooming in reuses a previously proved-safe immutable scene placement');
const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false });
assert.equal(fit.devices.get('device')?.nearWallBefore, false,
'fit envelope uses unnudged bounds without running wall collision search');
assert.notStrictEqual(fit.devices.get('device'), live.devices.get('device'),
'fit and live placements use separate bounded cache entries');
const zoomed = buildIsoOverlayRenderScene({ ...input, view: { x: -10, y: -10, w: 120, h: 120 } });
assert.strictEqual(zoomed, live,
'zooming out and panning reuse the same immutable scene placement');
assert.strictEqual(zoomed.devices.get('device'), live.devices.get('device'),
'live view is absent from the placement signature');
});
test('render scene keeps a close device cluster rigid without moving labels and caches permutations', () => {
const owner = {
...room('owner', 0, 0, 400, 400),
name: 'Owner label', area: 'living', settings: {},
};
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const walls = [];
const devices = ['b', 'a', 'c'].map((id) => ({
id, space: 'floor', marker: { room_id: 'owner', x: 200, y: 200 },
}));
const input = {
space, devices, openings: [],
view: { x: 0, y: 0, w: 400, h: 400 },
display: { showNames: true, cardFontScale: 1 },
layers: { structural: true, shadows: true },
wallSilhouettes: walls,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 200, height: 200 },
positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
tempText: null, humText: null, lqiText: null,
pulse: { animated: false, diameterScale: 1 },
}),
labelPositionOf: () => ({ x: 200, y: 200 }),
labelScaleOf: () => 1,
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
const scene = buildIsoOverlayRenderScene(input);
assert.deepEqual(scene.residualPairs, [], 'intra-group overlap is not reported as inter-group debt');
const deviceEntries = scene.entries.filter((entry) => entry.kind === 'device');
for (let index = 0; index < deviceEntries.length; index++) {
assert.ok(deviceEntries[index].placement.nudgeDistanceCss <= 48);
for (let other = 0; other < index; other++) {
const a = deviceEntries[index], b = deviceEntries[other];
assert.deepEqual(a.placement.visualScene, b.placement.visualScene,
`device roots ${a.id}/${b.id} keep their canonical coincident relationship`);
assert.deepEqual(a.placement.nudgeCss, b.placement.nudgeCss,
`device roots ${a.id}/${b.id} receive one rigid displacement`);
}
}
const label = scene.entries.find((entry) => entry.kind === 'room-label');
assert.ok(label, 'fixture includes a room label at the same projected anchor');
assert.equal(label.placement.nudgeDistanceCss, 0,
'device collisions do not push the room label');
const permuted = buildIsoOverlayRenderScene({ ...input, devices: [...devices].reverse() });
assert.strictEqual(permuted, scene,
'HA registry permutations reuse the same immutable group layout snapshot');
const fit = buildIsoOverlayRenderScene({ ...input, resolveCollisions: false });
assert.deepEqual(fit.devices.get('a').visualScene, fit.devices.get('b').visualScene,
'fit probing deliberately skips live group displacement');
});
test('visible wall side quads participate in overlay collision', () => {
const walls = [[[[45, 20], [55, 20], [55, 80], [45, 80]]]];
const scene = resolveIsoScene({
source: {
key: 'visible-side-collision',
build: () => ({ walls, floor: walls, openings: [], openingSurfaces: [] }),
},
cache: new Map(),
cellCm: 5,
liveFrame: { x: 0, y: 0, w: 100, h: 100 },
});
assert.ok(scene.geometry.sides.length > 0, 'fixture exposes visible vertical wall faces');
assert.equal(scene.wallSilhouettes.length, 1 + scene.geometry.sides.length,
'collision set contains the top footprint and every visible side');
scene.geometry.sides.forEach((face, index) => {
assert.strictEqual(scene.wallSilhouettes[index + 1].outer, face.points,
`visible side ${index} reuses its exact render quad`);
});
const face = scene.geometry.sides.reduce((lowest, candidate) =>
candidate.depth > lowest.depth ? candidate : lowest);
const sideCenter = [
face.points.reduce((sum, point) => sum + point[0], 0) / face.points.length,
face.points.reduce((sum, point) => sum + point[1], 0) / face.points.length,
];
const raisedFloorScene = [
sideCenter[0],
sideCenter[1] + ISO_RAISED_OVERLAY_HEIGHT * ISO_CAMERA.zScale
* Math.sin(ISO_CAMERA.tiltDeg * Math.PI / 180),
];
const floorAnchor = unprojectFloorPoint(raisedFloorScene);
const owner = room('owner', -1_000, -1_000, 1_000, 1_000);
owner.safePoint = [0, 0];
const collisionInput = {
kind: 'device',
floorAnchor,
rooms: [{ id: owner.id, outer: owner.poly, safePoint: owner.safePoint }],
preferredRoomId: owner.id,
showBorders: true,
footprintHalfSize: [0.05, 0.05],
wallHeight: ISO_WALL_HEIGHT,
visualOffset: ISO_OVERLAY_VISUAL_OFFSET,
sceneUnitsPerCssPixel: 1,
safetyGapCssPx: 0,
maxNudgeCssPx: 0,
};
const topOnly = resolveIsoOverlayPlacement({
...collisionInput, wallSilhouettes: scene.wallSilhouettes.slice(0, 1),
});
const complete = resolveIsoOverlayPlacement({
...collisionInput, wallSilhouettes: scene.wallSilhouettes,
});
assert.equal(topOnly.nearWallBefore, false,
'raised footprint does not touch the wall-top footprint');
assert.equal(complete.nearWallBefore, true,
'the same raised footprint intersects a rendered vertical side');
});
test('orphan hosted openings never become phantom Stage 4 volumes', () => {
const base = {
type: 'door', rx: 20, ry: 30, rlen: 40, angle: 0,
flip_h: false, flip_v: false,
};
const result = isoSourceOpenings([
{ ...base, id: 'orphan', orphanReason: 'missing-partition' },
{ ...base, id: 'valid' },
], 1000);
assert.deepEqual(result.map((opening) => [opening.id, opening.sourceIndex]), [['valid', 1]]);
});
const cacheRoom = (overrides = {}) => ({
id: 'cache-room',
name: 'Structural cache room',
area: 'living_room',
poly: [[0, 0], [100, 0], [100, 100], [0, 100]],
wall_ids: ['north', 'east', 'south', 'west'],
settings: { fill_mode: 'temp', glow: true, name_scale: 1.25, label_scale: 1.1 },
...overrides,
});
const structuralInput = ({
room = cacheRoom(), walls = [], openings = [], onBuild = () => {},
coordinateScale = 1000, wallKeyPitch = 1,
} = {}) => ({
space: {
id: 'cache-space', title: 'Cache space', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [room], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
},
walls, openCuts: [], openings,
partitionCuts: () => [], roomOpenings: () => [],
cellCm: 5, gridPitch: 5, wallKeyPitch, coordinateScale, onBuild,
});
test('presentation-only room changes reuse the structural scene but geometry changes rebuild it', () => {
assert.deepEqual(isoStructuralRoomGeometry(cacheRoom()), {
id: 'cache-room', x: undefined, y: undefined, w: undefined, h: undefined,
poly: [[0, 0], [100, 0], [100, 100], [0, 100]],
wall_ids: ['north', 'east', 'south', 'west'],
});
let builds = 0;
const cache = new Map();
const resolve = (room) => {
const source = createIsoStructuralSource(structuralInput({
room, onBuild: () => { builds += 1; },
}));
return { source, scene: resolveIsoScene({
source, cache, cellCm: 5, liveFrame: { x: 0, y: 0, w: 100, h: 100 },
}) };
};
const original = resolve(cacheRoom());
const presentation = resolve(cacheRoom({
name: 'Renamed room', area: 'private_area',
settings: { fill_mode: 'custom', custom_fill: { c: '#123456', a: 0.4 }, glow: false,
temp_source: 'sensor.private', hum_source: 'sensor.private_humidity',
name_scale: 2, label_scale: 0.75 },
}));
assert.equal(presentation.source.key, original.source.key);
assert.strictEqual(presentation.scene.geometry, original.scene.geometry);
assert.equal(builds, 1, 'presentation changes must hit the existing structural LRU entry');
const geometry = resolve(cacheRoom({
poly: [[0, 0], [120, 0], [100, 100], [0, 100]],
}));
assert.notEqual(geometry.source.key, original.source.key);
assert.notStrictEqual(geometry.scene.geometry, original.scene.geometry);
assert.equal(builds, 2, 'room geometry must invalidate and rebuild the structural scene');
});
test('structural scene cache refreshes a hot hit before evicting the least-recently-used entry', () => {
const cache = new Map();
const builds = new Map();
const resolve = (index) => {
const id = `lru-room-${index}`;
const source = createIsoStructuralSource(structuralInput({
room: cacheRoom({ id, poly: [[0, 0], [100 + index, 0], [100, 100], [0, 100]] }),
onBuild: () => builds.set(id, (builds.get(id) || 0) + 1),
}));
const scene = resolveIsoScene({
source, cache, cellCm: 5, liveFrame: { x: 0, y: 0, w: 100, h: 100 },
});
return { id, key: source.key, scene };
};
const initial = Array.from({ length: 8 }, (_, index) => resolve(index));
const hot = resolve(0);
assert.strictEqual(hot.scene.geometry, initial[0].scene.geometry);
const added = resolve(8);
assert.equal(cache.size, 8);
assert.equal(cache.has(initial[0].key), true, 'the refreshed hot entry must survive');
assert.equal(cache.has(initial[1].key), false, 'the coldest entry must be evicted');
assert.equal(cache.has(added.key), true);
assert.equal(builds.get(initial[0].id), 1, 'a hot hit must not rebuild structural geometry');
resolve(1);
assert.equal(builds.get(initial[1].id), 2, 'the evicted cold entry must rebuild on its next use');
});
test('throwing decoration capability probes keep Iso structural geometry on the solid path', () => {
const previousCss = globalThis.CSS;
const previousMatchMedia = globalThis.matchMedia;
try {
globalThis.CSS = { supports: () => { throw new Error('capability probe failure'); } };
globalThis.matchMedia = () => { throw new Error('forced-colors probe failure'); };
const layers = resolveIsoDecorationLayers({ showBorders: true, hideOpenings: false });
assert.deepEqual(layers, {
structural: true, panels: true, shadows: false, materialNuance: false,
floorSymbols: false,
});
} finally {
if (previousCss === undefined) delete globalThis.CSS;
else globalThis.CSS = previousCss;
if (previousMatchMedia === undefined) delete globalThis.matchMedia;
else globalThis.matchMedia = previousMatchMedia;
}
});
test('removed contact shadows are never read while ambient shadow capability remains enabled', () => {
const previousCss = globalThis.CSS;
const previousMatchMedia = globalThis.matchMedia;
try {
globalThis.CSS = { supports: () => true };
globalThis.matchMedia = () => ({ matches: false });
let contactReads = 0;
const geometry = {
topPath: '', topFaces: [], sides: [], edgeCount: 0,
get contactPath() {
contactReads += 1;
throw new Error('decorative shadow failure');
},
};
const frame = resolveIsoFramePresentation({
projection: 'iso',
display: { showBorders: true, hideOpenings: false },
scene: {
key: 'solid-retry', geometry,
floor: { footprintPath: '', sides: [] }, wallSilhouettes: [],
openings: [], openingSurfaces: [], frame: { x: 0, y: 0, w: 100, h: 100 },
},
openings: [], amountOf: () => 0, overlays: () => null, cellCm: 5,
});
assert.equal(contactReads, 0,
'the deprecated contact path must not be touched by presentation rendering');
assert.equal(frame.layers.structural, true);
assert.equal(frame.layers.panels, true);
assert.equal(frame.layers.shadows, true,
'the remaining building ambient shadow still follows filter capability');
assert.equal(frame.layers.materialNuance, true);
assert.equal(frame.overlays, null);
} finally {
if (previousCss === undefined) delete globalThis.CSS;
else globalThis.CSS = previousCss;
if (previousMatchMedia === undefined) delete globalThis.matchMedia;
else globalThis.matchMedia = previousMatchMedia;
}
});
const partitionOpening = (overrides = {}) => {
const resolved = {
opening: {},
host: { kind: 'partition', id: 'partition-a', t: 0.5 },
partition: { id: 'partition-a', a: [0, 0], b: [100, 0], cm: 15 },
center: [50, 0], angle: 0, length: 20, depth: 15, t: 0.5,
axis: { a: [0, 0], b: [100, 0], ux: 1, uy: 0, length: 100 },
...overrides,
};
return {
id: 'hosted-door', type: 'door', x: 0.5, y: 0, length: 0.2,
rx: 50, ry: 0, rlen: 20, angle: 0, flip_h: false, flip_v: false,
partitionHost: resolved,
};
};
test('gate flips move structural host face with the reviewed inverse convention', () => {
const rendered = partitionOpening();
const source = (type, flipV) => ({
id: `${type}-${flipV}`, sourceIndex: 0, type,
x: 0.5, y: 0, length: 0.2, angle: 0, flipH: false, flipV,
});
const doorNormal = isoStructuralOpeningHost(rendered, source('door', false));
const doorFlipped = isoStructuralOpeningHost(rendered, source('door', true));
const gateNormal = isoStructuralOpeningHost(rendered, source('gate', false));
const gateFlipped = isoStructuralOpeningHost(rendered, source('gate', true));
assert.ok(doorNormal && doorFlipped && gateNormal && gateFlipped);
assert.deepEqual(gateNormal.face, doorFlipped.face,
'an unflipped gate selects the opposite structural face from an unflipped door');
assert.deepEqual(gateFlipped.face, doorNormal.face,
'flipping a gate restores the door-normal structural face');
assert.notDeepEqual(gateNormal.face, gateFlipped.face,
'the saved gate flip must change the structural host face');
});
test('gate flips move unhosted structural face with the reviewed inverse convention', () => {
const square = cacheRoom({ wall_ids: [] });
const walls = square.poly.map((a, index) => ({
key: wallKey(a, square.poly[(index + 1) % square.poly.length], 1),
cm: 20,
}));
const gate = (flip_v) => ({
id: `unhosted-gate-${flip_v}`, type: 'gate',
rx: 50, ry: 0, rlen: 20, angle: 0, flip_h: false, flip_v,
});
const basis = (flip_v) => createIsoStructuralSource(structuralInput({
room: square, walls, openings: [gate(flip_v)], coordinateScale: 1,
})).build().openings[0];
const normal = basis(false);
const flipped = basis(true);
assert.equal(normal.face.side, -1,
'an unflipped gate selects the inverse wall face used by its saved swing convention');
assert.equal(flipped.face.side, 1,
'flipping the gate selects the opposite structural wall face');
assert.deepEqual(normal.face.selectedStart, [40, -10]);
assert.deepEqual(flipped.face.selectedStart, [40, 10]);
});
test('partition host identity, placement, depth and selected face invalidate opening volumes', () => {
const keyFor = (opening) => createIsoStructuralSource(structuralInput({
openings: [opening],
})).key;
const original = partitionOpening();
const base = keyFor(original);
const mutations = [
partitionOpening({ host: { kind: 'partition', id: 'partition-b', t: 0.5 } }),
partitionOpening({ t: 0.75 }),
partitionOpening({ depth: 25 }),
partitionOpening({
axis: { ...original.partitionHost.axis, ux: 0, uy: 1 },
}),
partitionOpening({
partition: { ...original.partitionHost.partition, cm: 25 },
}),
];
for (const mutation of mutations) assert.notEqual(keyFor(mutation), base);
});
test('opening geometry policy is both fingerprinted and consumed by the structural build', () => {
const input = structuralInput({ openings: [partitionOpening()] });
const original = createIsoStructuralSource(input);
const policy = {
...ISO_OPENING_GEOMETRY_POLICY,
revision: ISO_OPENING_GEOMETRY_POLICY.revision + 1,
leafThicknessRatio: ISO_OPENING_GEOMETRY_POLICY.leafThicknessRatio * 2,
};
const changed = createIsoStructuralSource(input, policy);
assert.notEqual(changed.key, original.key);
const basis = changed.build().openings[0];
assert.equal(basis.leafThickness, basis.wallHeight * policy.leafThicknessRatio);
});
// #473. Перф-дельта #160 (f1b9bbf3..b3ca0ca2) ввела кэш размещений, кэш по
// идентичности силуэтов, повторное использование при зуме внутрь и
// AABB-отсечение — и ушла в бету по §11.4 без ревью и без единого свидетеля.
// Двенадцать мутантов stage3-w* защищают картинку, ни один — эти механизмы.
// Ошибка любого из них даёт УСТАРЕВШЕЕ размещение без единого падения.
const perfFixture = () => {
const owner = room('owner', 0, 0, 100, 100);
const space = {
id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null,
rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
};
const wallSilhouettes = [{ outer: buildIsoFootprintPolygon([0, 50], [4, 60], ISO_WALL_HEIGHT) }];
const input = {
space,
devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }],
openings: [],
view: { x: 0, y: 0, w: 100, h: 100 },
referenceView: { x: 0, y: 0, w: 100, h: 100 },
display: { showNames: false, cardFontScale: 1 },
layers: { structural: true, shadows: true },
wallSilhouettes,
iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5,
kioskIconScale: 1, kioskFontScale: 1,
stageSize: { width: 100, height: 100 },
positionOf: () => ({ x: 5, y: 50 }),
presentationOf: () => ({
scale: 1, valueText: null, valueFullText: '', valueBadge: null,
tempText: null, humText: null, lqiText: null,
pulse: { animated: false, diameterScale: 1 },
}),
labelPositionOf: () => ({ x: 0, y: 0 }),
labelScaleOf: () => 1,
openingEntityAvailable: () => false,
openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }),
};
return { input, wallSilhouettes };
};
test('#570 supersedes #473 W1: selection reuses the cue-free low placement', () => {
const { input } = perfFixture();
const plain = buildIsoOverlayRenderScene(input).devices.get('device');
const selected = buildIsoOverlayRenderScene({ ...input, selectedDeviceId: 'device' })
.devices.get('device');
// Stage 4 removed the selected/hover tether and ground cue. Selection no
// longer changes collision geometry and therefore must not churn the cache.
assert.strictEqual(selected, plain, 'selection is presentation-only after cue removal');
const again = buildIsoOverlayRenderScene(input).devices.get('device');
assert.strictEqual(again, selected, 'clearing selection keeps the same immutable placement');
});
test('#473 W2: кэш размещений привязан к идентичности массива силуэтов', () => {
const { input } = perfFixture();
const withWall = buildIsoOverlayRenderScene(input).devices.get('device');
assert.equal(withWall.nearWallBefore, true, 'фикстура: стена рядом с плитой');
// Новая геометрия — новый массив. Кэш, ключуемый константой, отдал бы
// размещение «у стены» для плана, в котором стены больше нет.
const noWalls = buildIsoOverlayRenderScene({ ...input, wallSilhouettes: [] }).devices.get('device');
assert.equal(noWalls.nearWallBefore, false, 'без стен плита не у стены');
assert.notStrictEqual(noWalls, withWall);
});
test('#651 supersedes #473 W3: live zoom never recomputes scene placement', () => {
const { input } = perfFixture();
// Далёкая плита: зум внутрь переиспользует доказанно безопасное размещение.
const far = { ...input, positionOf: () => ({ x: 60, y: 20 }) };
const farLive = buildIsoOverlayRenderScene(far).devices.get('device');
assert.equal(farLive.nearWallBefore, false);
const farZoomed = buildIsoOverlayRenderScene({ ...far, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.strictEqual(farZoomed, farLive, 'не у стены — переиспользуется');
// Плита у стены, которую не удалось очистить, также остаётся в тех же
// координатах сцены. Иначе экранный zoom снова становится layout-событием
// и возвращает пользовательский дрейф #651.
const pinned = {
...input,
// упор со всех сторон: узкая комната не даёт места для nudge
space: { ...input.space, rooms: [room('owner', 0, 44, 12, 56)] },
positionOf: () => ({ x: 5, y: 50 }),
};
const pinnedLive = buildIsoOverlayRenderScene(pinned).devices.get('device');
assert.equal(pinnedLive.nearWallBefore, true);
if (pinnedLive.cleared) {
// Фикстура не смогла создать неочищенную плиту — тест обязан сказать об
// этом честно, а не пройти молча (правило после #426).
assert.fail('фикстура «у стены, не очищена» не построилась: cleared=true');
}
const pinnedZoomed = buildIsoOverlayRenderScene({ ...pinned, view: { x: 0, y: 0, w: 80, h: 80 } })
.devices.get('device');
assert.strictEqual(pinnedZoomed, pinnedLive,
'у стены и не очищена — тот же структурный layout, без zoom-feedback');
});
test('#473 W4: AABB-отсечение учитывает зазор безопасности', () => {
// Плита, чьи границы не пересекают границы стены, но лежат внутри gap,
// обязана считаться «у стены». Отсечение без зазора отбросило бы силуэт
// до точного теста, и плита легла бы вплотную к стене.
// Стена — компактный силуэт: у длинной стены AABB в изометрии накрывает плиту
// при любом сдвиге вдоль оси, и отсечение не участвует в решении.
const wall = { outer: buildIsoFootprintPolygon([0, 50], [2, 2], 0) };
const owner = { id: 'owner', outer: [[0, 0], [100, 0], [100, 100], [0, 100]], holes: [], safePoint: [50, 50] };
const placeAt = (x, safetyGapCssPx) => resolveIsoOverlayPlacement({
kind: 'device', floorAnchor: [x, 50], footprintHalfSize: [2, 2],
rooms: [owner], roomsValidated: true, preferredRoomId: 'owner',
ownerAlreadyResolved: true, resolvedOwner: { room: owner, safePoint: [50, 50] },
showBorders: true, wallSilhouettes: [wall], wallGeometryValidated: true,
unitsPerPixel: 1, safetyGapCssPx, wallHeight: ISO_WALL_HEIGHT, visualOffset: 0,
});
// без зазора плита на расстоянии 2 единиц от стены — не у стены
const noGap = placeAt(2 + 2 + 2, 0);
assert.equal(noGap.nearWallBefore, false, 'контроль: без зазора не у стены');
// с зазором 4 — у стены, хотя AABB не пересекаются
const withGap = placeAt(2 + 2 + 2, 4);
assert.equal(withGap.nearWallBefore, true, 'в пределах зазора — у стены');
});