Files
houseplan-card/test/iso-openings.test.mjs
Claude 0d641ffbfd feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)
- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 15:18:00 +03:00

365 lines
17 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ISO_OPENING_FRAME_THICKNESS_RATIO, ISO_OPENING_GEOMETRY_POLICY,
ISO_OPENING_LEAF_THICKNESS_RATIO,
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening,
projectIsoOpeningStructure, resolveIsoDecoration,
} from '../test-build/iso-openings.js';
import { openingAmount } from '../test-build/logic.js';
import { ISO_WALL_HEIGHT, projectPlanPoint } from '../test-build/iso-projection.js';
const opening = (patch = {}) => ({
id: 'op-1', sourceIndex: 0, type: 'door', x: 100, y: 80,
angle: 0, length: 60, flipH: false, flipV: false,
face: { ox: 0, oy: 5, cm: 20, side: 1 }, ...patch,
});
test('door basis is immutable and live amount preserves the exact jamb anchor', () => {
const basis = buildIsoOpeningBasis(opening());
assert.equal(Object.isFrozen(basis.leaves), true);
const closed = projectIsoOpening(basis, 0)[0];
const open = projectIsoOpening(basis, 1)[0];
const hinge = projectPlanPoint(basis.leaves[0].hinge, basis.leaves[0].bottom)
.map((value) => Number(value.toFixed(4))).join(' ');
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);
});
test('Stage 4 basis freezes the real axis, physical face depth and jamb endpoints', () => {
const basis = buildIsoOpeningBasis(opening());
assert.equal(Object.isFrozen(basis), true);
assert.equal(Object.isFrozen(basis.axis), true);
assert.equal(Object.isFrozen(basis.face), true);
assert.equal(Object.isFrozen(basis.reveals), true);
assert.equal(Object.isFrozen(basis.axis.start), true);
assert.deepEqual(basis.axis, {
center: [100, 80],
tangent: [1, 0],
normal: [0, 1],
start: [70, 80],
end: [130, 80],
});
assert.deepEqual(basis.face, {
side: 1,
cm: 20,
depth: 10,
offset: [0, 5],
selectedStart: [70, 85],
selectedEnd: [130, 85],
oppositeStart: [70, 75],
oppositeEnd: [130, 75],
});
assert.deepEqual(basis.reveals, [
{ jamb: 0, center: [70, 80], selected: [70, 85], opposite: [70, 75] },
{ jamb: 1, center: [130, 80], selected: [130, 85], opposite: [130, 75] },
]);
assert.equal(basis.leafThickness, ISO_WALL_HEIGHT * ISO_OPENING_LEAF_THICKNESS_RATIO);
assert.equal(basis.frameThickness, ISO_WALL_HEIGHT * ISO_OPENING_FRAME_THICKNESS_RATIO);
const rotated = buildIsoOpeningBasis(opening({
angle: 90,
face: { ox: -5, oy: 0, cm: 20, side: 1 },
}));
assert.ok(Math.abs(rotated.axis.start[0] - 100) < 1e-9);
assert.ok(Math.abs(rotated.axis.start[1] - 50) < 1e-9);
assert.ok(Math.abs(rotated.axis.end[0] - 100) < 1e-9);
assert.ok(Math.abs(rotated.axis.end[1] - 110) < 1e-9);
assert.equal(rotated.face.depth, 10);
});
test('door and gate expose matte fixed-thickness leaf prisms plus real jamb reveals', () => {
for (const type of ['door', 'gate']) {
const basis = buildIsoOpeningBasis(opening({ type }));
const structure = projectIsoOpeningStructure(basis);
assert.equal(Object.isFrozen(structure), true);
assert.equal(structure.filter((surface) => surface.kind === 'jamb-reveal').length, 2);
assert.equal(structure.every((surface) => surface.material === 'reveal'), true);
assert.deepEqual(
structure.filter((surface) => surface.kind === 'jamb-reveal')
.map((surface) => surface.jamb).sort(),
[0, 1],
);
const panels = projectIsoOpening(basis, 0.5);
assert.equal(panels.length, type === 'gate' ? 2 : 1);
for (const panel of panels) {
assert.equal(panel.material, 'matte-leaf');
assert.equal(panel.thickness, basis.leafThickness);
assert.equal(Object.isFrozen(panel), true);
assert.equal(Object.isFrozen(panel.surfaces), true);
assert.equal(panel.surfaces.filter((surface) => surface.kind === 'leaf-front').length, 1);
assert.equal(panel.surfaces.filter((surface) => surface.kind === 'leaf-back').length, 1);
assert.deepEqual(
panel.surfaces.filter((surface) => surface.kind === 'leaf-edge')
.map((surface) => surface.edge).sort(),
['hinge', 'tip'],
);
assert.equal(panel.surfaces.filter((surface) => surface.kind === 'leaf-top').length, 1);
assert.equal(panel.surfaces.every((surface) => surface.material === 'matte-leaf'), true);
}
}
});
test('window separates neutral fixed/sash frames from blue glass above the sill', () => {
const basis = buildIsoOpeningBasis(opening({ type: 'window' }));
assert.deepEqual(basis.windowProfile, {
frameBottom: ISO_WALL_HEIGHT * 0.38,
frameTop: ISO_WALL_HEIGHT,
sashBottom: ISO_WALL_HEIGHT * 0.40,
sashTop: ISO_WALL_HEIGHT * 0.98,
glassBottom: ISO_WALL_HEIGHT * 0.45,
glassTop: ISO_WALL_HEIGHT * 0.93,
member: ISO_WALL_HEIGHT * 0.05,
});
const structure = projectIsoOpeningStructure(basis);
assert.equal(structure.filter((surface) => surface.kind === 'jamb-reveal').length, 2);
assert.equal(structure.filter((surface) => surface.kind === 'window-frame-side').length, 4);
assert.equal(structure.filter((surface) => surface.kind === 'window-frame-top').length, 2);
assert.equal(structure.filter((surface) => surface.kind === 'window-sill').length, 1);
assert.equal(structure.filter((surface) => surface.kind.startsWith('window-'))
.every((surface) => ['light-frame', 'light-sill'].includes(surface.material)), true);
const panels = projectIsoOpening(basis, 0.5);
assert.equal(panels.length, 2);
assert.equal(panels.every((panel) => panel.material === 'light-window'
&& panel.thickness === 0
&& panel.surfaces.length === 7
&& panel.surfaces.filter((surface) => surface.material === 'light-frame').length === 4
&& panel.surfaces.filter((surface) => surface.material === 'glass-side').length === 2
&& panel.surfaces.filter((surface) => surface.material === 'glass-top').length === 1), true);
assert.equal(panels.flatMap((panel) => panel.surfaces)
.filter((surface) => surface.material.startsWith('glass'))
.every((surface) => ['window-glass', 'window-glass-top'].includes(surface.kind)), true);
});
test('live projection is O(leaves)-only and cannot mutate the structural Stage 4 basis', () => {
const basis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true }));
const snapshot = structuredClone(basis);
const structure = projectIsoOpeningStructure(basis);
const closed = projectIsoOpening(basis, 0);
const open = projectIsoOpening(basis, 1);
assert.notDeepEqual(open.map((panel) => panel.surfaces), closed.map((panel) => panel.surfaces));
assert.deepEqual(projectIsoOpeningStructure(basis), structure);
assert.deepEqual(basis, snapshot);
});
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, 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]);
assert.equal(gateBasis.leaves.every((leaf) => leaf.top === ISO_WALL_HEIGHT * 0.88), true);
assert.equal(projectIsoOpening(gateBasis, 1).length, 2);
});
test('paired window leaves open toward their resolved exterior face in every orientation', () => {
for (const angle of [0, 90, 180, 270, 37]) {
const radians = angle * Math.PI / 180;
const normal = [-Math.sin(radians), Math.cos(radians)];
for (const flipV of [false, true]) for (const flipH of [false, true]) {
const side = flipV ? 1 : -1;
const basis = buildIsoOpeningBasis(opening({
type: 'window', angle, flipV, flipH,
face: { ox: normal[0] * side * 5, oy: normal[1] * side * 5, cm: 20, side },
}));
for (const leaf of basis.leaves) {
const turn = leaf.turnDeg * Math.PI / 180;
const swingNormal = (leaf.quarterVector[0] * Math.sin(turn)) * normal[0]
+ (leaf.quarterVector[1] * Math.sin(turn)) * normal[1];
assert.ok(swingNormal * side > 0,
`angle=${angle} flipH=${flipH} flipV=${flipV} leaf=${leaf.leaf}`);
}
}
}
});
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(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(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],
-selected.leaves[0].quarterVector[1],
'flip mirrors the opening direction without moving its origin',
);
const gate = buildIsoOpeningBasis(opening({ type: 'gate' }));
const gateFlipped = buildIsoOpeningBasis(opening({
type: 'gate', flipV: true, face: { ox: 0, oy: -5, side: -1 },
}));
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 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, []);
assert.deepEqual(passageBasis.reveals, []);
assert.deepEqual(projectIsoOpening(passageBasis, 0), []);
assert.deepEqual(projectIsoOpening(passageBasis, 1), []);
assert.deepEqual(projectIsoOpeningStructure(passageBasis), []);
assert.equal(isoOpeningBounds([passageBasis]), null);
});
test('state-independent opening bounds contain closed and open leaf tips', () => {
const basis = buildIsoOpeningBasis(opening());
const bounds = isoOpeningBounds([basis]);
assert.ok(bounds);
for (const amount of [0, 0.5, 1]) {
const panel = projectIsoOpening(basis, amount)[0];
assert.match(panel.d, /^M /);
}
const leaf = basis.leaves[0];
assert.ok(bounds.x <= leaf.hinge[0] - 60 && bounds.x + bounds.w >= leaf.hinge[0] + 60);
assert.ok(bounds.y <= leaf.hinge[1] - 60 && bounds.y + bounds.h >= leaf.hinge[1] + 60);
assert.equal(isoOpeningBounds([]), null);
});
test('flipH and flipV independently mirror their exact structural axes', () => {
const normal = buildIsoOpeningBasis(opening());
const horizontal = buildIsoOpeningBasis(opening({ flipH: true }));
const vertical = buildIsoOpeningBasis(opening({ flipV: true }));
const both = buildIsoOpeningBasis(opening({ flipH: true, flipV: true }));
const signature = (basis) => {
const leaf = basis.leaves[0];
const clean = (point) => point.map((value) => Math.abs(value) < 1e-9 ? 0 : value);
return {
hinge: clean(leaf.hinge),
closedVector: clean(leaf.closedVector),
quarterVector: clean(leaf.quarterVector),
};
};
assert.deepEqual(signature(normal), {
hinge: [70, 85], closedVector: [60, 0], quarterVector: [0, 60],
});
assert.deepEqual(signature(horizontal), {
hinge: [130, 85], closedVector: [-60, 0], quarterVector: [0, 60],
});
assert.deepEqual(signature(vertical), {
hinge: [70, 85], closedVector: [60, 0], quarterVector: [0, -60],
});
assert.deepEqual(signature(both), {
hinge: [130, 85], closedVector: [-60, 0], quarterVector: [0, -60],
});
});
test('HA state changes only projected leaves', () => {
const normal = buildIsoOpeningBasis(opening());
const basisSnapshot = structuredClone(normal);
const noContact = projectIsoOpening(normal, openingAmount('door', null));
const unavailable = projectIsoOpening(normal, openingAmount('door', 'unavailable'));
const closed = projectIsoOpening(normal, openingAmount('door', 'off'));
const inverted = projectIsoOpening(normal, openingAmount('door', 'off', true));
assert.deepEqual(unavailable, noContact);
assert.notDeepEqual(closed, inverted);
assert.deepEqual(normal, basisSnapshot);
});
test('decoration degradation never removes structure or creates floating panels', () => {
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: true, forcedColors: false,
}), {
structural: true, panels: true, shadows: true, materialNuance: true, floorSymbols: false,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: false, forcedColors: false,
}), {
structural: true, panels: true, shadows: false, materialNuance: false, floorSymbols: false,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: true, forcedColors: true,
}), {
structural: true, panels: true, shadows: false, materialNuance: false, floorSymbols: false,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: false, hideOpenings: false, filtersSupported: true, forcedColors: false,
}), {
structural: false, panels: false, shadows: false, materialNuance: false, floorSymbols: true,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: true, filtersSupported: true, forcedColors: false,
}), {
structural: true, panels: false, shadows: true, materialNuance: true, floorSymbols: false,
});
});
test('#713 AC7: opening depth runs along the oblique projector (s·y + z)', () => {
const s = Math.sin(20 * Math.PI / 180);
const basis = buildIsoOpeningBasis(opening());
const leaf = basis.leaves[0];
const top = projectIsoOpening(basis, 0)[0].surfaces.find((surface) => surface.kind === 'leaf-top');
assert.ok(top, 'the closed door leaf has a top face');
// The four top corners straddle the leaf axis symmetrically, so their mean
// plan point is the middle of the closed leaf.
const meanY = leaf.hinge[1] + leaf.closedVector[1] / 2;
assert.ok(Math.abs(top.cameraDepth - (s * meanY + leaf.top)) < 1e-9,
`${top.cameraDepth} != s·y + z = ${s * meanY + leaf.top}`);
assert.ok(Math.abs(top.cameraDepth - (s * meanY + leaf.top * Math.cos(20 * Math.PI / 180))) > 1e-3,
'the former orthographic key y·sin 20° + z·cos 20° is gone');
});