fix: preserve resize wall thickness carriers

Issue: #298
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 22:27:05 +03:00
parent 451932c8e9
commit 3fb2edc904
26 changed files with 563 additions and 269 deletions
+45 -2
View File
@@ -60,7 +60,7 @@ import {
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
} from './furniture';
import {
degradeWalls, rekeyWallsAfterMove,
degradeWalls, rekeyWallsAfterMove, wallRecordCarrierViolations,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
floorFootprintGeometry,
@@ -8542,7 +8542,7 @@ class HouseplanCard extends LitElement {
else delete (sp as any).open_spans;
if (Array.isArray(sp.walls) && sp.walls.length) {
sp.walls = rekeyWallsAfterMove(
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W,
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W, 'fixed-topology',
);
}
}
@@ -8559,6 +8559,49 @@ class HouseplanCard extends LitElement {
if ((s.open_spans || []).length !== ((sp as any).open_spans || []).length) {
return { ok: false, reason: 'open-span-metadata' };
}
const wallCarriers: [number[], number[]][] = [];
for (const room of sp.rooms || []) {
const poly = roomPoly(room);
if (!poly || poly.length < 2) continue;
for (let index = 0; index < poly.length; index++) {
wallCarriers.push([
[poly[index][0] * NORM_W, poly[index][1] * NORM_W],
[poly[(index + 1) % poly.length][0] * NORM_W,
poly[(index + 1) % poly.length][1] * NORM_W],
]);
}
}
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
// old records as a multiset and prove only newly written/split records.
// That keeps pointermove bounded by the touched thickness profile instead
// of comparing every historical record with every carrier twice per frame.
const wallSignature = (wall: any): string => JSON.stringify([
wall?.key, wall?.cm, wall?.a, wall?.b,
]);
const oldWallCounts = new Map<string, number>();
for (const wall of s.walls || []) {
const key = wallSignature(wall);
oldWallCounts.set(key, (oldWallCounts.get(key) || 0) + 1);
}
const changedWalls: WallEntry[] = [];
for (const wall of sp.walls || []) {
const key = wallSignature(wall);
const remaining = oldWallCounts.get(key) || 0;
if (remaining) oldWallCounts.set(key, remaining - 1);
else changedWalls.push(wall);
}
if (wallRecordCarrierViolations(
changedWalls, wallCarriers, this._wallKeyPitch, NORM_W,
).length) return { ok: false, reason: 'wall-metadata' };
if (!this._rszSpaceCandidateRenderable(this._space, sp)) {
return { ok: false, reason: 'physical-geometry' };
}
+117 -3
View File
@@ -9,6 +9,7 @@
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
import { NEAR_AXIS_MAX_DEGREES } from './near-axis';
import { LATTICE_NOISE_STEPS } from './coordinate-canonicalization';
export interface WallEntry {
key: string;
@@ -536,6 +537,7 @@ export function rekeyWallsAfterMove(
newSpans: [number[], number[]][],
pitch: number,
coordScale = 1,
mode: 'affine' | 'fixed-topology' = 'affine',
): WallEntry[] {
if (!walls?.length) return [];
if (oldSpans.length !== newSpans.length) return walls.slice();
@@ -575,13 +577,32 @@ export function rekeyWallsAfterMove(
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
];
const closePoint = (a: number[], b: number[]): boolean =>
Math.hypot(a[0] - b[0], a[1] - b[1]) <= exactEps;
const mapPoint = (p: number[], move: Move): number[] => {
if (mode === 'fixed-topology') {
const adx = move.na[0] - move.oa[0], ady = move.na[1] - move.oa[1];
const bdx = move.nb[0] - move.ob[0], bdy = move.nb[1] - move.ob[1];
// Safe Resize has only two legal transforms for one source edge:
//
// - the moving wall translates rigidly, so every physical breakpoint
// follows by the same vector;
// - a perpendicular side wall changes length, so only its topology
// endpoint moves and interior thickness breakpoints stay put.
//
// Reusing the historical affine `t` mapping for the second case is the
// producer behind #298: it creates a point that belongs to no polygon.
if (Math.hypot(adx - bdx, ady - bdy) <= exactEps) {
return [p[0] + adx, p[1] + ady];
}
if (closePoint(p, move.oa)) return [...move.na];
if (closePoint(p, move.ob)) return [...move.nb];
return [...p];
}
const t = Math.max(0, Math.min(1,
((p[0] - move.oa[0]) * move.dx + (p[1] - move.oa[1]) * move.dy) / move.len2));
return pointAt(move.na, move.nb, t);
};
const closePoint = (a: number[], b: number[]): boolean =>
Math.hypot(a[0] - b[0], a[1] - b[1]) <= exactEps;
const canonicalSpan = (a: number[], b: number[]): [number[], number[]] => {
const [ux, uy] = wallDir(a, b);
return (b[0] - a[0]) * ux + (b[1] - a[1]) * uy >= 0
@@ -633,7 +654,12 @@ export function rekeyWallsAfterMove(
}
if (!overlaps.length) {
pushExact(wa, wb, w.cm);
// A fixed-topology edit is not allowed to canonicalise an unrelated
// record as a side effect: its compatibility key and endpoints are
// observable storage identity. The generic historical transform keeps
// its previous normalising behaviour for isolated callers/tests.
if (mode === 'fixed-topology') out.push({ ...w });
else pushExact(wa, wb, w.cm);
continue;
}
@@ -688,6 +714,15 @@ export function rekeyWallsAfterMove(
coalesced.push([[...atom[0]], [...atom[1]]]);
}
}
if (mode === 'fixed-topology' && coalesced.length === 1) {
const [nextA, nextB] = coalesced[0];
const sameSpan = (closePoint(nextA, exact[0]) && closePoint(nextB, exact[1]))
|| (closePoint(nextA, exact[1]) && closePoint(nextB, exact[0]));
if (sameSpan) {
out.push({ ...w });
continue;
}
}
for (const [a, b] of coalesced) {
pushExact(a, b, w.cm);
}
@@ -728,6 +763,85 @@ export function rekeyWallsAfterMove(
return out;
}
/**
* 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`.
*/
export function wallRecordCarrierViolations(
walls: WallEntry[] | null | undefined,
carriers: [number[], number[]][],
pitch: number,
coordScale = 1,
): string[] {
const scale = coordScale > 0 ? coordScale : 1;
const latticePitch = Math.abs(pitch);
const eps = Math.max(latticePitch * scale * LATTICE_NOISE_STEPS, 1e-9);
const onLattice = (value: number): boolean => {
if (!(latticePitch > 0)) return true;
const normalised = value / scale;
const steps = normalised / latticePitch;
return Math.abs(steps - Math.round(steps)) < LATTICE_NOISE_STEPS;
};
const violations: string[] = [];
const signature = (wall: WallEntry): string => JSON.stringify([
wall.key, wall.cm, wall.a, wall.b,
]);
for (const wall of walls || []) {
const span = entrySpan(wall, scale);
if (!span) continue;
const [a, b] = span;
if (![a[0], a[1], b[0], b[1]].every(Number.isFinite)
|| ![a[0], a[1], b[0], b[1]].every(onLattice)) {
violations.push(signature(wall));
continue;
}
const dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length <= eps) {
violations.push(signature(wall));
continue;
}
const ux = dx / length, uy = dy / length;
const intervals: [number, number][] = [];
for (const carrier of carriers) {
const [ca, cb] = carrier;
if (![ca?.[0], ca?.[1], cb?.[0], cb?.[1]].every(Number.isFinite)) continue;
const lineDistance = (point: number[]): number =>
Math.abs((point[0] - a[0]) * uy - (point[1] - a[1]) * ux);
if (lineDistance(ca) > eps || lineDistance(cb) > eps) continue;
const ta = (ca[0] - a[0]) * ux + (ca[1] - a[1]) * uy;
const tb = (cb[0] - a[0]) * ux + (cb[1] - a[1]) * uy;
const lo = Math.max(0, Math.min(ta, tb));
const hi = Math.min(length, Math.max(ta, tb));
if (hi - lo > eps) intervals.push([lo, hi]);
}
intervals.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
let covered = 0;
for (const [lo, hi] of intervals) {
if (lo > covered + eps) break;
covered = Math.max(covered, hi);
if (covered >= length - eps) break;
}
if (covered < length - eps) violations.push(signature(wall));
}
return violations;
}
export function wallRecordsHaveCarrierCoverage(
walls: WallEntry[] | null | undefined,
carriers: [number[], number[]][],
pitch: number,
coordScale = 1,
): boolean {
return wallRecordCarrierViolations(walls, carriers, pitch, coordScale).length === 0;
}
/** Upsert or remove a wall entry by endpoints. */
export function setWallThickness(
walls: WallEntry[] | null | undefined,