fix: refine hidden 2.5D presentation (#583)

Issue: #583
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-16 02:57:47 +03:00
parent ec2b3f3274
commit 5cae93501c
56 changed files with 1115 additions and 596 deletions
+56 -18
View File
@@ -25,6 +25,8 @@ test('door basis is immutable and live amount preserves the exact jamb anchor',
assert.match(closed.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
assert.match(open.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
assert.notEqual(open.d, closed.d);
assert.deepEqual(basis.leaves[0].hinge, basis.face.selectedStart,
'the derived volume pivots on the selected physical wall face, not inside masonry');
assert.equal(basis.leaves[0].top, ISO_WALL_HEIGHT * 0.92);
assert.equal(basis.leaves[0].turnDeg, -50);
});
@@ -151,7 +153,7 @@ test('window and gate retain two leaves with reviewed height and turn policies',
const windowBasis = buildIsoOpeningBasis(opening({ type: 'window' }));
const gateBasis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true }));
assert.equal(windowBasis.leaves.length, 2);
assert.equal(ISO_OPENING_GEOMETRY_POLICY.revision, 2);
assert.equal(ISO_OPENING_GEOMETRY_POLICY.revision, 3);
assert.equal(windowBasis.leaves.every((leaf) => leaf.bottom === ISO_WALL_HEIGHT * 0.40
&& leaf.top === ISO_WALL_HEIGHT * 0.98 && Math.abs(leaf.turnDeg) === 65), true);
assert.deepEqual(gateBasis.leaves.map((leaf) => Math.abs(leaf.turnDeg)), [10, 10]);
@@ -180,26 +182,29 @@ test('paired window leaves open toward their resolved exterior face in every ori
}
});
test('isometric symbols keep one centre while flips change only direction', () => {
const centred = buildIsoOpeningBasis(opening());
test('isometric door and gate volumes follow the selected host face without changing saved axes', () => {
const selected = buildIsoOpeningBasis(opening());
const oppositeResolvedFace = buildIsoOpeningBasis(opening({
face: { ox: 0, oy: -5, side: -1 },
}));
assert.deepEqual(oppositeResolvedFace.leaves, centred.leaves,
'default door ignores which physical room face was resolved');
assert.deepEqual(selected.axis, oppositeResolvedFace.axis,
'the saved centreline axis remains canonical');
assert.deepEqual(selected.leaves[0].hinge, selected.face.selectedStart);
assert.deepEqual(oppositeResolvedFace.leaves[0].hinge,
oppositeResolvedFace.face.selectedStart);
assert.notDeepEqual(oppositeResolvedFace.leaves, selected.leaves,
'the derived volume moves to the resolved physical face');
const flippedPositive = buildIsoOpeningBasis(opening({ flipV: true }));
const flippedNegative = buildIsoOpeningBasis(opening({
flipV: true, face: { ox: 0, oy: -5, side: -1 },
}));
assert.deepEqual(flippedNegative.leaves, flippedPositive.leaves,
'resolved room face cannot translate or redirect the saved flip');
assert.deepEqual(flippedPositive.leaves[0].hinge, centred.leaves[0].hinge,
'flip keeps the exact centreline hinge');
assert.deepEqual(flippedPositive.leaves[0].closedVector, centred.leaves[0].closedVector);
assert.deepEqual(flippedPositive.leaves[0].hinge, flippedPositive.face.selectedStart);
assert.deepEqual(flippedNegative.leaves[0].hinge, flippedNegative.face.selectedStart);
assert.deepEqual(flippedPositive.leaves[0].closedVector, selected.leaves[0].closedVector);
assert.equal(
flippedPositive.leaves[0].quarterVector[1],
-centred.leaves[0].quarterVector[1],
-selected.leaves[0].quarterVector[1],
'flip mirrors the opening direction without moving its origin',
);
@@ -207,17 +212,50 @@ test('isometric symbols keep one centre while flips change only direction', () =
const gateFlipped = buildIsoOpeningBasis(opening({
type: 'gate', flipV: true, face: { ox: 0, oy: -5, side: -1 },
}));
assert.equal(gate.leaves[0].hinge[1], 80);
assert.equal(gateFlipped.leaves[0].hinge[1], 80);
assert.equal(gate.leaves[0].hinge[1], 85);
assert.equal(gateFlipped.leaves[0].hinge[1], 75);
assert.deepEqual(gate.leaves.map((leaf) => leaf.turnDeg), [10, -10]);
assert.deepEqual(gateFlipped.leaves.map((leaf) => leaf.turnDeg), [-10, 10]);
assert.notDeepEqual(
projectIsoOpening(gate, 1).map((panel) => panel.d),
projectIsoOpening(gateFlipped, 1).map((panel) => panel.d),
'flip changes the gate turn without translating its centred origin',
'flip changes both the selected face and outward turn',
);
});
test('door and gate face matrix keeps every live state on the selected physical hinge', () => {
for (const type of ['door', 'gate']) {
for (const angle of [0, 90, 37]) {
const radians = angle * Math.PI / 180;
const normal = [-Math.sin(radians), Math.cos(radians)];
for (const side of [-1, 1]) {
for (const flipH of [false, true]) {
const basis = buildIsoOpeningBasis(opening({
type, angle, flipH,
face: { ox: normal[0] * 5 * side, oy: normal[1] * 5 * side, side },
}));
for (const leaf of basis.leaves) {
const expected = flipH
? leaf.leaf === 0 ? basis.face.selectedEnd : basis.face.selectedStart
: leaf.leaf === 0 ? basis.face.selectedStart : basis.face.selectedEnd;
assert.deepEqual(leaf.hinge, expected,
`${type} angle=${angle} side=${side} flipH=${flipH} leaf=${leaf.leaf}`);
}
for (const amount of [0, 0.5, 1]) {
const panels = projectIsoOpening(basis, amount);
assert.equal(panels.length, basis.leaves.length);
assert.ok(panels.every((panel) => panel.surfaces.length === 5
&& panel.surfaces.every((surface) => Number.isFinite(surface.depth)
&& Number.isFinite(surface.cameraDepth)
&& !/NaN|Infinity/.test(surface.d))),
`${type} angle=${angle} side=${side} flipH=${flipH} amount=${amount}`);
}
}
}
}
}
});
test('passage keeps the wall cut but never creates an isometric panel', () => {
const passageBasis = buildIsoOpeningBasis(opening({ type: 'passage' }));
assert.deepEqual(passageBasis.leaves, []);
@@ -257,16 +295,16 @@ test('flipH and flipV independently mirror their exact structural axes', () => {
};
};
assert.deepEqual(signature(normal), {
hinge: [70, 80], closedVector: [60, 0], quarterVector: [0, 60],
hinge: [70, 85], closedVector: [60, 0], quarterVector: [0, 60],
});
assert.deepEqual(signature(horizontal), {
hinge: [130, 80], closedVector: [-60, 0], quarterVector: [0, 60],
hinge: [130, 85], closedVector: [-60, 0], quarterVector: [0, 60],
});
assert.deepEqual(signature(vertical), {
hinge: [70, 80], closedVector: [60, 0], quarterVector: [0, -60],
hinge: [70, 85], closedVector: [60, 0], quarterVector: [0, -60],
});
assert.deepEqual(signature(both), {
hinge: [130, 80], closedVector: [-60, 0], quarterVector: [0, -60],
hinge: [130, 85], closedVector: [-60, 0], quarterVector: [0, -60],
});
});
+68
View File
@@ -4,8 +4,10 @@ import {
ISO_OVERLAY_MAX_NUDGE_CSS_PX,
ISO_OVERLAY_SAFETY_GAP_CSS_PX,
buildIsoFootprintPolygon,
isoOverlayCollisionKey,
isoOverlayPlane,
isoRoomSafePoint,
resolveIsoOverlayCollisions,
resolveIsoOverlayOwner,
resolveIsoOverlayPlacement,
} from '../test-build/iso-overlays.js';
@@ -53,6 +55,72 @@ test('the exact Stage 4 overlay matrix keeps only the three interactive roots on
assert.equal(isoOverlayPlane(kind, false), 'floor', `${kind} without borders`);
});
test('group collision separates a solvable dense set independently of input order', () => {
const make = (id) => ({
id,
kind: id === 'lock' ? 'opening-lock' : 'device',
placement: placement({
floorAnchor: [100, 100],
rooms: [square('room', 0, 0, 200, 200, [100, 100])],
visualOffset: 0,
camera: identityCamera,
}),
screenHalfSize: [8, 8],
});
const solve = (items) => resolveIsoOverlayCollisions({
items,
rooms: [square('room', 0, 0, 200, 200, [100, 100])],
wallSilhouettes: [],
sceneUnitsPerCssPixel: 1,
visualOffset: 0,
camera: identityCamera,
});
const normal = solve([make('a'), make('b'), make('lock')]);
const reversed = solve([make('lock'), make('b'), make('a')]);
assert.deepEqual(normal.residualPairs, []);
assert.deepEqual(reversed.residualPairs, []);
const keys = [
isoOverlayCollisionKey('device', 'a'),
isoOverlayCollisionKey('device', 'b'),
isoOverlayCollisionKey('opening-lock', 'lock'),
];
const centers = keys.map((key) => normal.placements.get(key).visualScene);
for (let index = 0; index < centers.length; index++) {
assert.deepEqual(reversed.placements.get(keys[index]).visualScene, centers[index]);
assert.ok(normal.placements.get(keys[index]).nudgeDistanceCss <= 48);
for (let other = 0; other < index; other++) {
assert.ok(Math.abs(centers[index][0] - centers[other][0]) >= 20
|| Math.abs(centers[index][1] - centers[other][1]) >= 20,
`pair ${index}/${other} must clear its complete roots plus the 4px gap`);
}
}
assert.deepEqual(normal.placements.get(keys[0]).visualScene, [100, 100],
'the stable first item stays at its zero-deviation anchor');
});
test('group collision reports a deterministic residual without exceeding the absolute cap', () => {
const room = square('tight', 0, 0, 1, 1, [0.5, 0.5]);
const make = (id) => ({
id,
kind: 'device',
placement: placement({
floorAnchor: [0.5, 0.5], rooms: [room], preferredRoomId: 'tight',
visualOffset: 0, camera: identityCamera,
}),
screenHalfSize: [30, 30],
});
const result = resolveIsoOverlayCollisions({
items: [make('a'), make('b')], rooms: [room], wallSilhouettes: [],
sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera,
});
const second = result.placements.get(isoOverlayCollisionKey('device', 'b'));
assert.equal(result.residualPairs.length, 1);
assert.equal(second.status, 'degraded');
assert.equal(second.reason, 'overlay-collision');
assert.ok(second.nudgeDistanceCss <= 48,
'the search radius is total displacement, not a budget added per collision');
});
test('owner resolution honours bindings, then canonical minimum area and stable id', () => {
const rooms = [
square('wide', 0, 0, 100, 100, [50, 50]),
+93 -4
View File
@@ -239,6 +239,33 @@ test('shared painter queue puts elevated window glass over its rear sill only in
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);
@@ -439,6 +466,66 @@ test('Stage 4 reuses pure overlay placements and fit probes skip collision searc
'view scale invalidates the placement signature');
});
test('render scene separates device roots 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, [], 'the roomy fixture must fully separate all roots');
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];
const dx = Math.abs(a.placement.visualScene[0] - b.placement.visualScene[0]);
const dy = Math.abs(a.placement.visualScene[1] - b.placement.visualScene[1]);
assert.ok(dx >= a.screenHalfSize[0] + b.screenHalfSize[0] + 8 - 1e-7
|| dy >= a.screenHalfSize[1] + b.screenHalfSize[1] + 8 - 1e-7,
`device roots ${a.id}/${b.id} must not overlap after the 4 CSS px gap`);
}
}
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({
@@ -615,7 +702,7 @@ test('throwing decoration capability probes keep Iso structural geometry on the
}
});
test('shadow presentation failure retries the same Iso frame as solid geometry', () => {
test('removed contact shadows are never read while ambient shadow capability remains enabled', () => {
const previousCss = globalThis.CSS;
const previousMatchMedia = globalThis.matchMedia;
try {
@@ -639,11 +726,13 @@ test('shadow presentation failure retries the same Iso frame as solid geometry',
},
openings: [], amountOf: () => 0, overlays: () => null, cellCm: 5,
});
assert.equal(contactReads, 1);
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, false);
assert.equal(frame.layers.materialNuance, false);
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;
+17 -4
View File
@@ -166,7 +166,10 @@ test('Stage 4 structural cache fingerprints geometry/camera/heights and excludes
'export function isoStructuralOpeningHost', 'export interface IsoStructuralSourceInput');
for (const field of ['hostId: resolved.host.id', 't: resolved.t', 'depth: resolved.depth',
'face: partitionOpeningFace']) assert.ok(hostProjection.includes(field), `missing ${field}`);
assert.match(openings, /ISO_OPENING_GEOMETRY_POLICY[\s\S]*?revision: 2/);
assert.match(openings, /ISO_OPENING_GEOMETRY_POLICY[\s\S]*?revision: 3/);
assert.match(openings,
/input\.type === 'door' \|\| input\.type === 'gate'[\s\S]*?\? input\.face[\s\S]*?: openingSymbolOffset/,
'door/gate volume uses the selected physical face while Flat/window geometry stays canonical');
assert.match(source,
/buildIsoOpeningBasis\(\{ \.\.\.opening, face \}, wallHeight, openingGeometryPolicy\)/);
assert.match(openings, /policy\.gateTurnDeg[\s\S]*?policy\.gateTopRatio/);
@@ -279,7 +282,7 @@ test('low-plane DOM roots expose one floor/visual identity and preserve existing
assert.match(styles, /\.iso-overlays-svg,[\s\S]*?pointer-events:\s*none/);
});
test('Stage 4 materials and shadows are bounded, theme-aware and capability-safe', () => {
test('Stage 4 materials and ambient shadow are bounded, theme-aware and capability-safe', () => {
const materialIds = [...sceneRender.matchAll(/id="(hp-iso-[^"]+)" data-hp-iso-material-def/g)]
.map((match) => match[1]);
assert.ok(materialIds.length >= 5 && materialIds.length <= 12,
@@ -295,8 +298,18 @@ test('Stage 4 materials and shadows are bounded, theme-aware and capability-safe
assert.match(styles, /iso-material-glass-top[\s\S]*?fill:\s*#e3f2fa/);
const rendering = section(sceneRender, 'function renderIsoDefs(', 'export function resolveIsoDecorationLayers');
assert.match(sceneRender, /return `translate\(\$\{gridVisualUnits\(4, cellCm\)\} \$\{gridVisualUnits\(8, cellCm\)\}\)`/);
assert.equal([...sceneRender.matchAll(/transform=\$\{isoFixedLightTransform\(cellCm\)\}/g)].length, 3,
'ambient, contact and opening shadows share one fixed-light vector');
assert.equal([...sceneRender.matchAll(/transform=\$\{isoFixedLightTransform\(cellCm\)\}/g)].length, 1,
'only the approved building ambient shadow uses the fixed-light vector');
assert.doesNotMatch(sceneRender, /hp-iso-contact-shadow|hp-iso-leaf-shadow|iso-contact-shadow|iso-leaf-shadow/);
assert.doesNotMatch(styles, /\.iso-contact-shadow|\.iso-leaf-shadow/);
assert.match(styles, /\.iso-opening-panel\.iso-material-matte-leaf\s*\{[\s\S]*?stroke:\s*none/);
assert.match(styles, /\.stage\.theme-dark \.iso-opening-panel\.iso-material-matte-leaf\s*\{[^}]*stroke:\s*none/);
assert.match(styles, /\.stage:not\(\.theme-light\) \.iso-opening-panel\.iso-material-matte-leaf\s*\{[^}]*stroke:\s*none/);
assert.match(styles, /iso-material-glass-side[\s\S]*?stroke:\s*#8fb4c7/,
'window glass keeps its thin border');
assert.match(styles,
/\.stage\.projection-iso\.mode-view \.dev,[\s\S]*?\.stage\.projection-iso\.mode-view \.oplock\s*\{\s*z-index:\s*2/,
'interactive overlays remain above room labels');
assert.doesNotMatch(rendering, /sunState|_renderSun|Date\.now|Math\.random/);
});