fix: make plan visuals grid-scale invariant

Issue: #239
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 13:45:26 +03:00
parent a4e8dd7ba7
commit 0d91c1e18e
31 changed files with 2017 additions and 963 deletions
+18
View File
@@ -79,6 +79,24 @@ test('icon size: a percentage of the PLAN, exactly as before the canvas', () =>
assert.equal(iconCqw(2.5, small, 1000, 2), 2 * iconCqw(2.5, small, 1000));
});
test('icon size is invariant for physically equivalent plans at different grid scales', () => {
const coarse = model({
id: 'coarse', cell_cm: 5,
rooms: [{ id: 'r', poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.88], [0.1, 0.88]] }],
});
const fine = model({
id: 'fine', cell_cm: 1, view_box: [0, 0, 5, 5],
rooms: [{ id: 'r', poly: [[0.5, 0.5], [4.5, 0.5], [4.5, 4.4], [0.5, 4.4]] }],
});
assert.equal(iconUnit(coarse), NORM_W);
assert.equal(iconUnit(fine), 5 * NORM_W);
assert.equal(iconCqw(2.5, coarse, spaceFrame(coarse).w), iconCqw(2.5, fine, spaceFrame(fine).w));
});
test('a legacy space without cell_cm keeps the historical five-centimetre read fallback', () => {
assert.equal(model({ id: 'legacy' }).cellCm, 5);
});
test('icon size: a plan drawn past the old square keeps its markers', () => {
// rooms at 1.0..3.0 — 2 canvases wide, framed by ~2.2 canvases once the
// frame is the content. With the old fixed NORM_W numerator the marker
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GRID_IMPERIAL_CELL_CM,
GRID_VISUAL_REFERENCE_CELL_CM,
gridCellFieldToCm,
gridCellFieldValue,
gridVisualScale,
gridVisualUnits,
newSpaceCellCm,
} from '../test-build/grid-scale.js';
test('visual scale preserves the 5 cm reference and follows physical equivalence', () => {
assert.equal(GRID_VISUAL_REFERENCE_CELL_CM, 5);
assert.equal(gridVisualScale(5), 1);
for (const cellCm of [1, 2.54, 10, 25]) {
assert.equal(gridVisualScale(cellCm), 5 / cellCm);
assert.equal(gridVisualUnits(2.5, cellCm), 2.5 * (5 / cellCm));
}
});
test('invalid visual-scale inputs retain the legacy 5 cm appearance', () => {
for (const value of [NaN, Infinity, -Infinity, 0, -1, undefined, null, '', '1', 'not-a-number']) {
assert.equal(gridVisualScale(value), 1);
}
});
test('new-space defaults use one centimetre or exactly one inch', () => {
assert.equal(newSpaceCellCm(false), 1);
assert.equal(newSpaceCellCm(true), GRID_IMPERIAL_CELL_CM);
assert.equal(GRID_IMPERIAL_CELL_CM, 2.54);
});
test('imperial field projection is readable while canonical values stay available', () => {
assert.equal(gridCellFieldValue(2.54, true), '1');
assert.equal(gridCellFieldValue(1, false), '1');
assert.equal(gridCellFieldValue(5, true), '1.968504');
assert.equal(gridCellFieldToCm(1, true), 2.54);
assert.equal(gridCellFieldToCm(1.25, true), 3.175);
assert.equal(gridCellFieldToCm(1.25, false), 1.25);
});
+3 -2
View File
@@ -41,13 +41,14 @@ test('all current floor/live layers remain in the one main scene', () => {
test('structural cache includes opening flips and excludes live HA amount', () => {
assert.match(card, /flipH: !!opening\.flip_h/);
assert.match(card, /flipV: !!opening\.flip_v/);
assert.match(card, /floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3/);
assert.match(card, /const floorEdgeHeight = gridVisualUnits\(ISO_FLOOR_EDGE_HEIGHT, this\._cellCm\)/);
assert.match(card, /floorEdgeHeight, algorithm: 3/);
const source = card.slice(card.indexOf('private _isoSource()'), card.indexOf('private _isoSceneKey()'));
assert.doesNotMatch(source, /_openingAmt|openingAmount|\.hass|matchMedia|CSS\.supports|theme|hover/);
assert.match(card, /projectIsoOpening\(basis, this\._openingAmt\(opening\)\)/);
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
assert.match(card, /if \(!this\._spaceDisplayForRender\(\)\.showBorders\)/);
assert.match(card, /projectedFrame\(\{ rect: flat, wallHeight: ISO_WALL_HEIGHT \}\)/);
assert.match(card, /wallHeight: gridVisualUnits\(ISO_WALL_HEIGHT, this\._cellCm\)/);
assert.match(card, /viewBox=\$\{isoLayers\?\.structural[\s\S]*?: `\$\{floorView\.x\}/);
assert.match(card, /preserveAspectRatio=\$\{isoLayers\?\.structural \|\| !iso \? 'xMidYMid meet' : 'none'\}/);
assert.match(card, /transform=\$\{isoLayers\?\.structural \? isoFloorMatrixCss\(\) : nothing\}/);
+1 -1
View File
@@ -56,7 +56,7 @@ test('placement preview adds passage-only cut geometry without changing saved sy
const start = card.indexOf('private _renderOpeningPlacementPreview()');
const preview = card.slice(start, card.indexOf('private _renderOpenings(', start));
assert.match(preview, /candidate\.type === 'passage'/);
assert.match(preview, /passagePlacementPreviewGeometry\(candidate, this\._gridPitch\)/);
assert.match(preview, /passagePlacementPreviewGeometry\([\s\S]*gridVisualUnits\(this\._gridPitch, this\._cellCm\)/);
assert.equal((preview.match(/class="passage-preview-cut"/g) || []).length, 1);
assert.equal((preview.match(/class="passage-preview-boundary"/g) || []).length, 1,
'one mapped template emits exactly two resolved boundary records');
+14 -2
View File
@@ -25,13 +25,25 @@ const templateText = (value) => {
test('opening metrics expand hit and outline zones with a thick wall face', () => {
assert.deepEqual(openingVisibleMetrics(spec()), {
half: 50, jambHalf: 4, gateDepth: 0, outlineHalf: 16, hitHalf: 20,
half: 50, jambHalf: 2, gateDepth: 0, outlineHalf: 8, hitHalf: 10,
});
assert.deepEqual(openingVisibleMetrics(spec({ face: { ox: 0, oy: 20, cm: 20, side: 1 } })), {
half: 50, jambHalf: 20, gateDepth: 0, outlineHalf: 28, hitHalf: 30,
half: 50, jambHalf: 20, gateDepth: 0, outlineHalf: 24, hitHalf: 25,
});
});
test('equivalent grid scales preserve visual padding without double-scaling physical jambs', () => {
const reference = openingVisibleMetrics(spec({
length: 100, cellCm: 5, face: { ox: 0, oy: 0, cm: 20, side: 1 },
}));
const detailed = openingVisibleMetrics(spec({
length: 500, cellCm: 1, face: { ox: 0, oy: 0, cm: 20, side: 1 },
}));
assert.equal(detailed.jambHalf, reference.jambHalf * 5);
assert.equal(detailed.outlineHalf, reference.outlineHalf * 5);
assert.equal(detailed.hitHalf, reference.hitHalf * 5);
});
test('shared renderer emits the expected visible symbol for every opening type', () => {
const windowText = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', face: { ox: 0, oy: 20, cm: 20, side: 1 },