feat: implement isometric stage 3

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 07:06:54 +03:00
parent 3bf9e2d6d3
commit ee7d486924
87 changed files with 7676 additions and 1375 deletions
+122 -5
View File
@@ -1,7 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
ISO_OPENING_FRAME_THICKNESS_RATIO, ISO_OPENING_LEAF_THICKNESS_RATIO,
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening,
projectIsoOpeningStructure, resolveIsoDecoration,
} from '../test-build/iso-openings.js';
import { openingAmount } from '../test-build/logic.js';
import { projectPlanPoint } from '../test-build/iso-projection.js';
@@ -9,7 +11,7 @@ import { 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, side: 1 }, ...patch,
face: { ox: 0, oy: 5, cm: 20, side: 1 }, ...patch,
});
test('door basis is immutable and live amount preserves the exact jamb anchor', () => {
@@ -25,6 +27,112 @@ test('door basis is immutable and live amount preserves the exact jamb anchor',
assert.equal(basis.leaves[0].top, 64 * 0.92);
});
test('Stage 3 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, 64 * ISO_OPENING_LEAF_THICKNESS_RATIO);
assert.equal(basis.frameThickness, 64 * 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 has only light inserts, frame and sill surfaces and no dark glass material', () => {
const basis = buildIsoOpeningBasis(opening({ type: 'window' }));
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.some((surface) => /glass|dark/i.test(surface.material)), false);
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 === 1
&& panel.surfaces[0].kind === 'window-insert'
&& panel.surfaces[0].material === 'light-window'), true);
});
test('live projection is O(leaves)-only and cannot mutate the structural Stage 3 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, fixed height bounds and gate 10 degree turn', () => {
const windowBasis = buildIsoOpeningBasis(opening({ type: 'window' }));
const gateBasis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true }));
@@ -77,8 +185,10 @@ test('isometric symbols keep one centre while flips change only direction', () =
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);
});
@@ -120,14 +230,21 @@ test('decoration degradation never removes structure or creates floating panels'
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: false, forcedColors: false,
}), {
structural: true, panels: true, shadows: false, materialNuance: true, floorSymbols: 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.equal(resolveIsoDecoration({
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: true, filtersSupported: true, forcedColors: false,
}).panels, false);
}), {
structural: true, panels: false, shadows: true, materialNuance: true, floorSymbols: false,
});
});