fix: close #298 review gaps

Issue: #298
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 23:04:32 +03:00
parent d5670ad141
commit 2d19ce04ad
11 changed files with 548 additions and 279 deletions
File diff suppressed because one or more lines are too long
+47
View File
@@ -96,6 +96,53 @@ const res = await page.evaluate(async () => {
rooms: sp().rooms, walls: sp().walls, openings: sp().openings,
}) === before;
}
// #298: an affected key-only record has no endpoints with which to prove a
// partial mapping. The handle remains eligible, but the runtime candidate
// must fail closed before preview, history and config/set.
sp().rooms = [
{
id: 'legacy-left', name: 'Legacy left',
poly: [[0.08, 0.12], [0.50, 0.12], [0.50, 0.55], [0.08, 0.55]],
},
{
id: 'legacy-right', name: 'Legacy right',
poly: [[0.50, 0.12], [0.92, 0.12], [0.92, 0.55], [0.50, 0.55]],
},
];
sp().walls = [{ key: '0.250000,0.120833@0.0000', cm: 22 }];
sp().openings = [];
delete sp().open_spans;
await upd();
const legacyBefore = JSON.stringify({ rooms: sp().rooms, walls: sp().walls });
const legacyHistoryBefore = c._geometryHistory?.size || 0;
const originalCallWS = c.hass.callWS.bind(c.hass);
let legacyWrites = 0;
c.hass.callWS = async (message) => {
if (message.type === 'houseplan/config/set') legacyWrites++;
return originalCallWS(message);
};
const legacyHandle = [...sr().querySelectorAll('.rszhandle')].find((entry) =>
entry.getAttribute('aria-disabled') === 'false'
&& Math.abs(+entry.getAttribute('cx') - 500) < 0.5
&& Math.abs(+entry.getAttribute('cy') - 335) < 0.5);
out.legacyHandleEnabled = !!legacyHandle;
if (legacyHandle) {
const x = +legacyHandle.getAttribute('cx');
const y = +legacyHandle.getAttribute('cy');
pointer('pointerdown', legacyHandle, x, y);
pointer('pointermove', legacyHandle, x + c._gridPitch, y);
await c.updateComplete;
out.legacyPreviewRejected = !!c._rszDrag && !c._rszPreview;
pointer('pointerup', legacyHandle, x + c._gridPitch, y);
await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 650));
out.legacyNoHistory = (c._geometryHistory?.size || 0) === legacyHistoryBefore;
out.legacyNoConfigWrite = legacyWrites === 0;
out.legacySourceUnchanged = JSON.stringify({ rooms: sp().rooms, walls: sp().walls })
=== legacyBefore;
}
return out;
});
+124 -124
View File
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -138,7 +138,7 @@ with zero Undo entries and zero writes.
## Thickness, virtual spans and openings
`rekeyWallsAfterMove()` and `rekeyOpenSpansAfterMove()` map the immutable
`rekeyWallsAfterMoveChecked()` and `rekeyOpenSpansAfterMove()` map the immutable
snapshot to the fixed-topology candidate. Physical centimetre values and open
span count must survive; the production geometry check is fail-closed.
@@ -148,9 +148,14 @@ whose length changes, only the old topology endpoint moves to its paired new
vertex; an interior thickness breakpoint stays on its physical boundary rather
than keeping a proportional fraction of the new edge. Unrelated exact records
remain byte-equivalent. The candidate then proves that every new exact record
is lattice-safe and continuously covered by room-wall/partition carriers. An
is lattice-safe and continuously covered by room-wall carriers. An
unchanged historical endpoint may remain readable even when its record changes
around it, but Resize cannot add or replace it with a different violation.
Key-only legacy records move only when their key identifies one whole changed
edge with one destination. An affected partial/ambiguous midpoint returns an
explicit rejected result; preview, history and persistence remain untouched.
The generic array-only helper retains its old affine fallback outside Safe
Resize for compatibility with historical pure transforms.
When the two owners of a shared moving seam split one physically continuous
side-wall record at their meeting point, the mapped atoms are joined back only
+9 -4
View File
@@ -57,10 +57,15 @@
unit/production smoke guards.
- [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate
rigidly, side-wall interior endpoints never scale proportionally,
unrelated records remain byte-equivalent, and a full-span carrier/lattice
proof rejects gaps before preview [unit: `wall-thickness.test.mjs`;
auto: `smoke_resize_pointer_real_plan`, six `smoke_edit_walk` runs;
mutation: `safe-resize-wall-endpoints-affine-scaled`].
the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated
records remain byte-equivalent, and a full-span carrier/lattice proof
rejects gaps before preview. Key-only legacy records move only by one
whole-edge identity; partial midpoint ambiguity produces no preview,
history or config write [unit: `wall-thickness.test.mjs`; auto:
`smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six
`smoke_edit_walk` runs; mutation:
`safe-resize-wall-endpoints-affine-scaled`,
`safe-resize-legacy-midpoint-fail-open`].
## Decor composition order (#231)
+5 -3
View File
@@ -59,9 +59,11 @@ from the immutable pre-drag snapshot and leaves every uncovered remainder on
its old carrier. Equivalent transforms from two owners collapse to one; a
conflicting pair fails closed by retaining the source atom. Results deduplicate
only when canonical exact endpoints **and** centimetres match — the quantised
compatibility key alone may never erase a record. Legacy entries without
endpoints keep the unambiguous whole-key/midpoint fallback and are never split
by inventing a length. `walls` stays in the resize snapshot. If lossless
compatibility key alone may never erase a record. Generic affine transforms
retain the historical key-only midpoint fallback and never invent a legacy
length. Production fixed-topology Resize is stricter: only one whole-edge key
with one destination can move; an affected partial or ambiguous key rejects the
candidate before preview/commit. `walls` stays in the resize snapshot. If lossless
partitioning takes a valid 500-record input above the backend limit, the
frontend keeps every result so persistence rejects the transaction atomically;
it does not truncate masonry to make the write fit.
+17 -2
View File
@@ -242,8 +242,23 @@ export const MUTANTS = [
+ 'but proportional scaling invents a wall-record coordinate with no carrier (#298)',
patches: [{
file: 'src/wall-thickness.ts',
find: " if (mode === 'fixed-topology') {\n",
replace: " if (false && mode === 'fixed-topology') {\n",
find: " if (mode === 'fixed-topology') {\n"
+ ' const adx = move.na[0] - move.oa[0], ady = move.na[1] - move.oa[1];',
replace: " if (false && mode === 'fixed-topology') {\n"
+ ' const adx = move.na[0] - move.oa[0], ady = move.na[1] - move.oa[1];',
}],
},
{
id: 'safe-resize-legacy-midpoint-fail-open',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 298 fixed-topology legacy" '
+ 'test/wall-thickness.test.mjs',
because: 'Safe Resize must reject an affected key-only midpoint unless it names '
+ 'one unambiguous whole changed edge (#298)',
patches: [{
file: 'src/wall-thickness.ts',
find: " if (mode === 'fixed-topology') {\n const direct = wholeEdgeMoves.get(w.key);",
replace: " if (false && mode === 'fixed-topology') {\n const direct = wholeEdgeMoves.get(w.key);",
}],
},
{
+4 -9
View File
@@ -60,7 +60,7 @@ import {
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
} from './furniture';
import {
degradeWalls, rekeyWallsAfterMove, wallRecordCarrierViolations,
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
floorFootprintGeometry,
@@ -8541,9 +8541,11 @@ class HouseplanCard extends LitElement {
if (movedOpen.length) (sp as any).open_spans = movedOpen;
else delete (sp as any).open_spans;
if (Array.isArray(sp.walls) && sp.walls.length) {
sp.walls = rekeyWallsAfterMove(
const rekeyed = rekeyWallsAfterMoveChecked(
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W, 'fixed-topology',
);
if (rekeyed.rejected) return { ok: false, reason: 'wall-metadata' };
sp.walls = rekeyed.walls;
}
}
// Rekeying may change coordinates/keys, never the number or physical
@@ -8571,13 +8573,6 @@ class HouseplanCard extends LitElement {
]);
}
}
for (const partition of sp.partitions || []) {
if (!Array.isArray(partition?.a) || !Array.isArray(partition?.b)) continue;
wallCarriers.push([
[Number(partition.a[0]) * NORM_W, Number(partition.a[1]) * NORM_W],
[Number(partition.b[0]) * NORM_W, Number(partition.b[1]) * NORM_W],
]);
}
// Old plans may contain explicit historical debt (for example an authored
// off-grid thickness breakpoint). Safe Resize must not silently repair it,
// but it may not create a new invalid record either. Remove byte-identical
+91 -11
View File
@@ -531,16 +531,28 @@ export function wallAngleMatches(
* so an exact whole-edge key map is insufficient: project every unmatched key
* onto the old edge and carry that relative point onto the new one.
*/
export function rekeyWallsAfterMove(
export type WallRekeyMode = 'affine' | 'fixed-topology';
export interface WallRekeyResult {
walls: WallEntry[];
/** Fixed-topology candidate is unsafe and must not reach preview/commit. */
rejected: boolean;
}
function rekeyWallsAfterMoveInternal(
walls: WallEntry[] | null | undefined,
oldSpans: [number[], number[]][],
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
mode: 'affine' | 'fixed-topology' = 'affine',
mode: WallRekeyMode = 'affine',
reject?: () => void,
): WallEntry[] {
if (!walls?.length) return [];
if (oldSpans.length !== newSpans.length) return walls.slice();
if (oldSpans.length !== newSpans.length) {
if (mode === 'fixed-topology') reject?.();
return walls.slice();
}
const scale = coordScale > 0 ? coordScale : 1;
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
const exactEps = Math.max(pitch * scale * 1e-6, 1e-9);
@@ -550,6 +562,12 @@ export function rekeyWallsAfterMove(
};
const moves: Move[] = [];
const keyMoves = new Map<string, Set<string>>();
const wholeEdgeMoves = new Map<string, Set<string>>();
const addKeyMove = (map: Map<string, Set<string>>, from: string, to: string): void => {
const targets = map.get(from) || new Set<string>();
targets.add(to);
map.set(from, targets);
};
for (let i = 0; i < oldSpans.length; i++) {
const [oa, ob] = oldSpans[i];
const [na, nb] = newSpans[i];
@@ -565,10 +583,14 @@ export function rekeyWallsAfterMove(
moves.push({ oa, ob, na, nb, dx, dy, len2 });
const ok = keyOf(oa, ob, pitch, coordScale);
const nk = keyOf(na, nb, pitch, coordScale);
// Some pre-normalisation configurations carry render-space legacy keys.
// They have no endpoints with which to disambiguate storage generations,
// so recognise only the same whole-edge identity in either historical
// coordinate convention. Partial midpoint projection remains forbidden.
addKeyMove(wholeEdgeMoves, ok, nk);
addKeyMove(wholeEdgeMoves, keyOf(oa, ob, pitch, 1), keyOf(na, nb, pitch, 1));
if (ok !== nk) {
const targets = keyMoves.get(ok) || new Set<string>();
targets.add(nk);
keyMoves.set(ok, targets);
addKeyMove(keyMoves, ok, nk);
}
}
if (!moves.length) return walls.slice();
@@ -730,8 +752,34 @@ export function rekeyWallsAfterMove(
}
// Legacy entries carry only a midpoint/direction key, so they cannot be
// split without inventing a length. Move an unambiguous whole-edge key or
// projected midpoint, and never deduplicate them merely by key.
// split without inventing a length. Safe Resize permits only an exact,
// unambiguous whole-edge identity. A partial/ambiguous affected key rejects
// the complete candidate; an unrelated key stays byte-equivalent.
if (mode === 'fixed-topology') {
const direct = wholeEdgeMoves.get(w.key);
if (direct?.size === 1) {
const key = [...direct][0];
out.push(key === w.key ? { ...w } : { ...w, key });
continue;
}
const parsedVariants = [parseKeys([w], scale)[0]];
if (scale !== 1) parsedVariants.push(parseKeys([w], 1)[0]);
const touchesChangedEdge = parsedVariants.filter(Boolean).some((parsed) =>
moves.some((move) => {
if (!angleClose(parsed!.ang, segAngle(move.oa, move.ob))) return false;
const t = ((parsed!.x - move.oa[0]) * move.dx
+ (parsed!.y - move.oa[1]) * move.dy) / move.len2;
return t >= -1e-6 && t <= 1 + 1e-6
&& distToSeg(parsed!.x, parsed!.y,
move.oa[0], move.oa[1], move.ob[0], move.ob[1]) <= tol;
}));
if ((direct?.size || 0) > 1 || touchesChangedEdge) reject?.();
out.push({ ...w });
continue;
}
// Historical affine transformations retain their projected-midpoint
// compatibility behaviour outside production Safe Resize.
let nk = '';
const direct = keyMoves.get(w.key);
if (direct?.size === 1) nk = [...direct][0];
@@ -763,14 +811,46 @@ export function rekeyWallsAfterMove(
return out;
}
/** Historical array-only API retained for pure affine callers. */
export function rekeyWallsAfterMove(
walls: WallEntry[] | null | undefined,
oldSpans: [number[], number[]][],
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
mode: WallRekeyMode = 'affine',
): WallEntry[] {
return rekeyWallsAfterMoveInternal(
walls, oldSpans, newSpans, pitch, coordScale, mode,
);
}
/** Production result: unsafe legacy correspondence is explicit and atomic. */
export function rekeyWallsAfterMoveChecked(
walls: WallEntry[] | null | undefined,
oldSpans: [number[], number[]][],
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
mode: WallRekeyMode = 'fixed-topology',
): WallRekeyResult {
let rejected = false;
const next = rekeyWallsAfterMoveInternal(
walls, oldSpans, newSpans, pitch, coordScale, mode,
() => { rejected = true; },
);
return { walls: rejected ? (walls || []).map((wall) => ({ ...wall })) : next, rejected };
}
/**
* Fail-closed carrier/lattice proof for exact wall records after Safe Resize.
*
* A compact record may cross several collinear room edges, so checking that
* both endpoints touch one edge is insufficient. Project every collinear
* carrier onto the record and require their union to cover its full interval
* without gaps. Legacy key-only records have no provable extent and remain a
* compatibility concern of `rekeyWallsAfterMove`.
* room-wall carrier onto the record and require their union to cover its full
* interval without gaps. Independent partitions are not carriers for
* `space.walls`. Legacy key-only records have no provable extent and remain a
* compatibility concern of `rekeyWallsAfterMoveChecked`.
*/
export function wallRecordCarrierViolations(
walls: WallEntry[] | null | undefined,
+10
View File
@@ -76,6 +76,16 @@ test('#277 a lossy persistence rekey stops at the last complete preview', () =>
assert.match(card, /g\.d = previousD/);
});
test('#298 production carrier proof uses room edges, never independent partitions', () => {
const carrierBlock = card.match(
/const wallCarriers: \[number\[\], number\[\]\]\[\] = \[\];[\s\S]*?const wallSignature/,
);
assert.ok(carrierBlock, 'production wall-carrier preflight is missing');
assert.match(carrierBlock[0], /for \(const room of sp\.rooms \|\| \[\]\)/);
assert.doesNotMatch(carrierBlock[0], /sp\.partitions/,
'independent partition geometry cannot carry space.walls metadata');
});
test('#277 Resize render fingerprints geometry once for the whole handle layer', () => {
assert.match(card, /const renderSnapshot = this\._rszDrag\?\.snap \|\| this\._rszSnapshot\(\)/);
assert.match(card, /this\._rszResolution\(r\.id, i, renderSnapshot\)/);
+110
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
rekeyWallsAfterMoveChecked,
wallRecordsHaveCarrierCoverage,
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
@@ -685,6 +686,115 @@ test('issue 298 carrier proof covers collinear chains and rejects gaps or off-gr
), false, 'Resize may not replace old debt with a different off-grid endpoint');
});
test('issue 298 fixed-topology legacy records move only by unambiguous whole-edge identity', () => {
const oldEdge = [[0, 0], [80, 0]];
const newEdge = [[0, 0], [92, 0]];
const whole = { key: wallKey(...oldEdge, pitch), cm: 22 };
const moved = rekeyWallsAfterMoveChecked(
[whole], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(moved.rejected, false);
assert.deepEqual(moved.walls, [{ ...whole, key: wallKey(...newEdge, pitch) }]);
const renderSpaceWhole = {
key: wallKey([1 / 480, 0], [80 - 1 / 480, 0], pitch), cm: 22,
};
const renderMoved = rekeyWallsAfterMoveChecked(
[renderSpaceWhole], [oldEdge], [newEdge], pitch, 1000, 'fixed-topology',
);
assert.equal(renderMoved.rejected, false, 'historical render-space whole-edge key is unambiguous');
assert.equal(renderMoved.walls[0].key, wallKey(...newEdge, pitch));
const untouched = { key: wallKey([100, 10], [120, 10], pitch), cm: 19, future: 'kept' };
const untouchedResult = rekeyWallsAfterMoveChecked(
[untouched], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(untouchedResult.rejected, false);
assert.deepEqual(untouchedResult.walls, [untouched]);
const partial = { key: wallKey([10, 0], [30, 0], pitch), cm: 21 };
const ambiguous = rekeyWallsAfterMoveChecked(
[partial], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(ambiguous.rejected, true);
assert.deepEqual(ambiguous.walls, [partial], 'a rejected candidate cannot partially rekey storage');
const conflictingWhole = rekeyWallsAfterMoveChecked(
[whole], [oldEdge, oldEdge], [newEdge, [[0, 0], [96, 0]]],
pitch, 1, 'fixed-topology',
);
assert.equal(conflictingWhole.rejected, true,
'one whole-edge key with conflicting destinations must reject atomically');
assert.deepEqual(conflictingWhole.walls, [whole]);
const exactWithBadCompatibilityKey = {
key: 'stale-compatibility-key', cm: 24, a: [0, 0], b: [0.08, 0],
};
const exact = rekeyWallsAfterMoveChecked(
[exactWithBadCompatibilityKey], [oldEdge], [newEdge], pitch, 1000, 'fixed-topology',
);
assert.equal(exact.rejected, false);
assert.deepEqual(exact.walls[0].b, [0.092, 0]);
assert.equal(exact.walls[0].key, wallKey([0, 0], [0.092, 0], pitch));
});
test('issue 298 first-floor fixture keeps the room-b seam on 17/52/57/101 boundaries', () => {
const fixture = JSON.parse(readFileSync(new URL(
'./fixtures/real-plan-first-floor.json', import.meta.url,
), 'utf8')).space;
const changedIds = new Set(['room-b', 'room-g']);
const nextRooms = fixture.rooms.map((room) => {
const copy = structuredClone(room);
if (copy.id === 'room-b') copy.poly[0][0] = copy.poly[1][0] = 52 / 240;
if (copy.id === 'room-g') copy.poly[2][0] = copy.poly[3][0] = 52 / 240;
return copy;
});
const oldSpans = [];
const newSpans = [];
for (const oldRoom of fixture.rooms.filter((room) => changedIds.has(room.id))) {
const nextRoom = nextRooms.find((room) => room.id === oldRoom.id);
const oldPoly = oldRoom.poly.map((point) => point.map((value) => value * 1000));
const newPoly = nextRoom.poly.map((point) => point.map((value) => value * 1000));
for (let index = 0; index < oldPoly.length; index++) {
oldSpans.push([oldPoly[index], oldPoly[(index + 1) % oldPoly.length]]);
newSpans.push([newPoly[index], newPoly[(index + 1) % newPoly.length]]);
}
}
const result = rekeyWallsAfterMoveChecked(
fixture.walls, oldSpans, newSpans, pitch, 1000, 'fixed-topology',
);
assert.equal(result.rejected, false);
const next = result.walls;
const step = (value) => Number((value * 240).toFixed(6));
const y155 = next.filter((wall) => wall.a && wall.b
&& Math.abs(step(wall.a[1]) - 155) < 0.001
&& Math.abs(step(wall.b[1]) - 155) < 0.001);
const spans = y155.map((wall) => [step(wall.a[0]), step(wall.b[0])].sort((a, b) => a - b))
.sort((a, b) => a[0] - b[0]);
assert.deepEqual(spans, [[17, 57], [52, 101]]);
assert.equal(spans.flat().some((value) => Math.abs(value - 59.538461) < 0.001), false);
const untouched = fixture.walls.find((wall) => wall.key === '0.479167,0.050000@0.0000');
assert.ok(next.some((wall) => JSON.stringify(wall) === JSON.stringify(untouched)),
'an unrelated first-floor record stays byte-semantic');
const carriers = nextRooms.flatMap((room) => room.poly.map((point, index) => [
point.map((value) => value * 1000),
room.poly[(index + 1) % room.poly.length].map((value) => value * 1000),
]));
assert.equal(wallRecordsHaveCarrierCoverage(
y155, carriers, pitch, 1000, fixture.walls,
), true, 'all surviving first-floor endpoints remain lattice/carrier safe');
const affine = rekeyWallsAfterMove(
fixture.walls, oldSpans, newSpans, pitch, 1000, 'affine',
);
assert.equal(affine.some((wall) => [wall.a, wall.b].flat()
.some((value) => Math.abs(step(value) - 59.538461) < 0.001)), true,
'fixture must kill the historical proportional endpoint mapping');
});
test('issue 253 key collisions never erase different exact or legacy records', () => {
const exact = [
{ key: wallKey([-1, 0], [1, 0], pitch), cm: 20, a: [-1, 0], b: [1, 0] },