Merge issue #298 into dev

Owner-approved review exception: the external reviewer is unavailable. Both r1 High findings were fixed, the exact branch SHA passed all recorded gates, and generated bundles were rebuilt after conflict resolution with #296.

Issue: #298
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 23:12:16 +03:00
22 changed files with 1823 additions and 272 deletions
+41 -3
View File
@@ -60,7 +60,7 @@ import {
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
} from './furniture';
import {
degradeWalls, rekeyWallsAfterMove,
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
floorFootprintGeometry,
@@ -8571,9 +8571,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(
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W,
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
@@ -8589,6 +8591,42 @@ 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],
]);
}
}
// 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, s.walls || [],
).length) return { ok: false, reason: 'wall-metadata' };
if (!this._rszSpaceCandidateRenderable(this._space, sp)) {
return { ok: false, reason: 'physical-geometry' };
}
+218 -10
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;
@@ -530,15 +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: 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);
@@ -548,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];
@@ -563,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();
@@ -575,13 +599,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 +676,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 +736,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);
}
@@ -695,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];
@@ -728,6 +811,131 @@ 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
* 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,
carriers: [number[], number[]][],
pitch: number,
coordScale = 1,
latticeDebt: WallEntry[] | null | undefined = [],
): 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;
};
// A Resize may need to rewrite a record whose other endpoint was authored
// off-grid historically. That coordinate is not newly produced by Resize:
// allow it only when the exact same physical endpoint already existed in the
// immutable source snapshot. Carrier coverage is still proved below.
const oldEndpoints = (latticeDebt || []).flatMap((wall) => {
const span = entrySpan(wall, scale);
return span ? span.map((point) => [...point]) : [];
});
const pointIsLatticeSafe = (point: number[]): boolean => point.every((value, axis) =>
onLattice(value) || oldEndpoints.some((old) => Math.abs(value - old[axis]) <= eps));
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)
|| !pointIsLatticeSafe(a) || !pointIsLatticeSafe(b)) {
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,
latticeDebt: WallEntry[] | null | undefined = [],
): boolean {
return wallRecordCarrierViolations(
walls, carriers, pitch, coordScale, latticeDebt,
).length === 0;
}
/** Upsert or remove a wall entry by endpoints. */
export function setWallThickness(
walls: WallEntry[] | null | undefined,