fix(geometry): canonicalize decor image coordinates

Issue: #431
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 11:26:31 +03:00
parent d97bf63feb
commit 58efb6a1b2
20 changed files with 301 additions and 139 deletions
+52
View File
@@ -5,6 +5,7 @@ import { readHouseplanProductionSource } from './houseplan-source.mjs';
import {
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalizeConfigGeometry,
@@ -39,6 +40,57 @@ test('frontend and backend share the scalar+lattice fixture contract (#291)', ()
assert.equal(Object.is(layout['rl:poly'].x, -0), false, 'negative zero becomes positive');
});
test('decor box catalog canonicalizes every box kind and preserves image metadata (#431)', () => {
assert.deepEqual(DECOR_BOX_KINDS, fixture.boxKinds);
const inputBoxes = fixture.configInput.spaces[0].decor
.filter((item) => fixture.boxKinds.includes(item.kind));
assert.deepEqual(
inputBoxes.map((item) => item.kind).sort(),
[...fixture.boxKinds].sort(),
'the shared fixture covers the exact runtime box catalog',
);
const output = canonicalizeConfigGeometry(fixture.configInput);
const outputById = new Map(output.spaces[0].decor.map((item) => [item.id, item]));
const expectedById = new Map(fixture.configExpected.spaces[0].decor.map((item) => [item.id, item]));
for (const input of inputBoxes) {
const actual = outputById.get(input.id);
const expected = expectedById.get(input.id);
for (const field of ['x', 'y', 'w', 'h', 'angle']) {
assert.equal(actual[field], expected[field], `${input.kind}.${field} is canonical`);
assert.notEqual(actual[field], input[field], `${input.kind}.${field} exercises the barrier`);
}
}
const inputImage = inputBoxes.find((item) => item.kind === 'image');
const actualImage = outputById.get('image');
for (const field of ['asset_id', 'opacity', 'flip_h', 'flip_v', 'future']) {
assert.deepEqual(actualImage[field], inputImage[field], `image.${field} is preserved`);
}
const futureBox = {
id: 'future', kind: 'future-box', x: 0.1000000004, y: 0.2000000006,
w: 0.3000000004, h: 0.4000000006, angle: 12.1234567896,
};
const unknownResult = canonicalizeConfigGeometry({ spaces: [{ decor: [futureBox] }] });
assert.deepEqual(unknownResult.spaces[0].decor[0], futureBox);
});
test('lattice report includes image box coordinates (#431)', () => {
const node = 83 / 240;
const noise = Number(node.toFixed(9));
const report = latticeCanonicalizationReport({ spaces: [{
id: 'floor', title: 'Floor', cell_cm: 5,
decor: [{ id: 'image', kind: 'image', x: noise, y: node, w: noise, h: 0.06 }],
}] });
assert.equal(report.canonicalized, 2);
assert.equal(report.far, 1);
assert.equal(report.spaces.length, 1);
assert.equal(report.spaces[0].canonicalized, 2);
assert.equal(report.spaces[0].far, 1);
});
test('scalar canonicalization is symmetric and never snaps off-grid geometry (#224)', () => {
assert.equal(canonicalizeNumber(1.2345678905), 1.234567891);
assert.equal(canonicalizeNumber(-1.2345678905), -1.234567891);
+29
View File
@@ -1,5 +1,6 @@
{
"decimals": 9,
"boxKinds": ["rect", "ellipse", "furniture", "image"],
"configInput": {
"spaces": [
{
@@ -117,6 +118,20 @@
"w": 0.1000000004,
"h": 0.1000000006,
"angle": 90.1234567896
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5000000004,
"y": 0.5000000006,
"w": 0.2000000004,
"h": 0.3000000006,
"angle": 45.1234567896,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [
@@ -318,6 +333,20 @@
"w": 0.1,
"h": 0.1,
"angle": 90.12345679
},
{
"id": "image",
"kind": "image",
"asset_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"x": 0.5,
"y": 0.5,
"w": 0.2,
"h": 0.3,
"angle": 45.12345679,
"opacity": 0.5000000004,
"flip_h": true,
"flip_v": false,
"future": {"numeric": 0.1234567896}
}
],
"room_drafts": [