mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Vendored
+124
-124
File diff suppressed because one or more lines are too long
+7
-2
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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] },
|
||||
|
||||
Reference in New Issue
Block a user