Files
houseplan-card/src/resize.ts
T
2026-08-28 05:40:28 +00:00

1351 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Room resize geometry — pure functions only (docs/RESIZE.md).
*
* The production path is the fixed-topology safe resolver/apply/validate
* pipeline: one horizontal or vertical wall moves along its normal, with at
* most one exact endpoint-matched neighbour. Historical permissive helpers
* remain below only for migration regression tests; the card does not import
* their vertex-insertion or whole-room-scale paths.
*
* All coordinates are render units (NORM_W-scaled), same as the card's
* space model. Nothing here touches Lit or the DOM.
*/
import { intersection } from 'polyclip-ts';
import {
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
} from './logic';
import { classifyNearAxisSegment } from './near-axis';
export { areaM2, formatArea } from './area-format';
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
export const MIN_ROOM_CM = 30;
export interface RoomIn { id: string; poly: number[][]; wall_ids?: string[] }
/** Opening in render units: centre, wall angle (deg), full length. */
export interface OpeningIn { id: string; x: number; y: number; length: number }
/** Fields relevant to the fixed-topology resize contract. Hosted openings
* belong to independent walls and therefore never travel with a room edge. */
export interface SafeOpeningIn extends OpeningIn {
hosted?: boolean;
angle?: number;
type?: string;
}
export const SAFE_RESIZE_REASONS = [
'diagonal',
'side-angle',
'duplicate-physical-wall',
'partial-shared',
'unequal-shared',
'multiple-rooms',
'thickness-conflict',
'opening-conflict',
'invalid-geometry',
] as const;
export type SafeResizeReason = typeof SAFE_RESIZE_REASONS[number];
export type SafeResizeObstacle =
| { kind: 'segment'; a: number[]; b: number[]; half?: number }
| { kind: 'circle'; center: number[]; radius: number };
export interface SafeResizeOptions extends StopOpts {
/** One snapped editor step; eligibility must permit a non-zero neighbour. */
step?: number;
/** Physical half-depth of the moving wall in render units. */
movingHalf?: number;
/** Independent walls, drafts and columns. They are immutable hard stops. */
obstacles?: SafeResizeObstacle[];
/** The controller found incompatible exact thickness records on this axis. */
thicknessConflict?: boolean;
}
/** One immutable fixed-topology gesture plan. `edgeByRoom` contains either one
* room or one exact endpoint-to-endpoint shared pair — never a cascade. */
export interface SafeResizePlan extends EdgeDragPlan {
roomIds: string[];
edgeByRoom: Record<string, number>;
topology: Record<string, number>;
movingOpeningIds: string[];
/** Prepared physical-owner profiles for the two side walls of every owner. */
sideOwnership: SafeResizeSideOwnership[];
}
export interface SafeResizeOwnershipInterval {
roomId: string;
edge: number;
lo: number;
hi: number;
}
export interface SafeResizeOwnershipRun {
lo: number;
hi: number;
owners: string[];
}
/**
* One side edge whose moving endpoint follows the selected wall. Intervals are
* collected once from the immutable gesture snapshot; candidate validation
* only adjusts intervals belonging to the one/two changed rooms.
*/
export interface SafeResizeSideOwnership {
roomId: string;
edge: number;
movedEndpoint: 0 | 1;
axis: Axis;
line: number;
fixed: number;
moving: number;
intervals: SafeResizeOwnershipInterval[];
baseline: SafeResizeOwnershipRun[];
}
export type SafeResizeResolution =
| { enabled: true; plan: SafeResizePlan }
| { enabled: false; reason: SafeResizeReason };
export interface SafeResizeAuditHandle {
id: string;
roomId: string;
edge: number;
endpoints: [number[], number[]];
resolution: SafeResizeResolution;
}
export interface SafeResizeEligibilityAudit {
total: number;
enabled: number;
disabled: Record<SafeResizeReason, number>;
handles: SafeResizeAuditHandle[];
}
/**
* The v8 wall catalogue stores every structural breakpoint as a polygon
* vertex. Resize is a user-facing side gesture, however, and must not expose
* those identity-only atoms as extra handles. Collapse consecutive forward
* collinear edges for the resize projection; the structural write barrier
* atomises the committed result again afterwards.
*/
export function coalesceResizeRooms<T extends RoomIn>(rooms: readonly T[], eps: number): T[] {
return rooms.map((room) => {
if (!Array.isArray(room.wall_ids) || room.wall_ids.length !== room.poly.length) {
return { ...room, poly: room.poly.map((point) => [...point]) };
}
const poly = room.poly.map((point) => [...point]);
let changed = true;
while (changed && poly.length > 3) {
changed = false;
for (let index = 0; index < poly.length; index++) {
const previous = poly[(index - 1 + poly.length) % poly.length];
const current = poly[index];
const next = poly[(index + 1) % poly.length];
const first = sub(current, previous);
const second = sub(next, current);
const firstLength = len2d(first), secondLength = len2d(second);
if (firstLength <= eps || secondLength <= eps) continue;
const cross = Math.abs(first[0] * second[1] - first[1] * second[0]);
const dot = first[0] * second[0] + first[1] * second[1];
if (cross <= eps * (firstLength + secondLength) && dot > 0) {
poly.splice(index, 1);
changed = true;
break;
}
}
}
return { ...room, poly };
});
}
export interface EdgeDragPlan {
roomId: string;
edge: number; // edge index i: v[i] -> v[i+1]
a: number[]; // edge endpoints BEFORE the drag
b: number[];
n: [number, number]; // outward unit normal (d > 0 grows the room)
}
export interface EdgeDragResult {
/** roomId -> new outline (only rooms that changed). */
polys: Record<string, number[][]>;
/** openingId -> new centre (only openings that travelled with the wall). */
openings: Record<string, [number, number]>;
/** roomId -> the moved stretches AFTER the move (for clearance/labels). */
movedSpans: Record<string, [number[], number[]][]>;
}
// ---------------- tiny vector helpers ----------------
const sub = (p: number[], q: number[]) => [p[0] - q[0], p[1] - q[1]];
const add2 = (p: number[], d: number[]) => [p[0] + d[0], p[1] + d[1]];
const dot = (p: number[], q: number[]) => p[0] * q[0] + p[1] * q[1];
const len2d = (p: number[]) => Math.hypot(p[0], p[1]);
/** Signed pointer travel along the immutable wall normal (#293). The click
* point itself is the origin: clicking near either jamb must not add the
* wall's absolute position to the gesture. */
export function safeResizePointerDisplacement(
start: number[], current: number[], normal: [number, number],
): number {
if (![start?.[0], start?.[1], current?.[0], current?.[1], normal?.[0], normal?.[1]]
.every(Number.isFinite)) return 0;
return (current[0] - start[0]) * normal[0]
+ (current[1] - start[1]) * normal[1];
}
function signedArea(poly: number[][]): number {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return s / 2;
}
function pointInPoly(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function distPointToSpan(p: number[], a: number[], b: number[]): number {
const ab = sub(b, a);
const l2 = dot(ab, ab);
if (l2 < 1e-12) return len2d(sub(p, a));
let t = dot(sub(p, a), ab) / l2;
t = Math.max(0, Math.min(1, t));
return len2d(sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t]));
}
/**
* Outward unit normal of edge i. Candidate is the +90° rotation of the edge
* direction; a probe point decides the sign, so polygon orientation (either
* winding survives in real configs) does not matter.
*/
export function edgeNormal(poly: number[][], i: number): [number, number] {
const a = poly[i], b = poly[(i + 1) % poly.length];
const d = sub(b, a);
const l = len2d(d) || 1;
let n: [number, number] = [d[1] / l, -d[0] / l];
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const probe = Math.max(l * 0.01, 1e-4);
if (pointInPoly([mid[0] + n[0] * probe, mid[1] + n[1] * probe], poly)) n = [-n[0], -n[1]];
return n;
}
/** Translate BOTH endpoints of edge i by the normal times d (docs/RESIZE.md, mechanism A). */
export function movePolyEdge(poly: number[][], i: number, d: number, n?: [number, number]): number[][] {
const nn = n || edgeNormal(poly, i);
const j = (i + 1) % poly.length;
return poly.map((p, k) => (k === i || k === j ? [p[0] + nn[0] * d, p[1] + nn[1] * d] : [...p]));
}
/** Collinear overlap stretches of `poly`'s edges with segment a-b, as [p,q] pairs (pre-move). */
export function sharedSpansWith(poly: number[][], a: number[], b: number[], eps: number): [number[], number[]][] {
const out: [number[], number[]][] = [];
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) return out;
const u = [ab[0] / L, ab[1] / L];
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const off1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
const off2 = Math.abs((q2[0] - a[0]) * u[1] - (q2[1] - a[1]) * u[0]);
if (off1 > eps || off2 > eps) continue; // not collinear with a-b
const t1 = dot(sub(q1, a), u);
const t2 = dot(sub(q2, a), u);
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(L, Math.max(t1, t2));
if (hi - lo > eps) out.push([[a[0] + u[0] * lo, a[1] + u[1] * lo], [a[0] + u[0] * hi, a[1] + u[1] * hi]]);
}
return out;
}
/**
* Neighbour sync: translate the stretches of `poly` that coincide with segment
* a-b by vector D, inserting vertices at partial-contact boundaries (T-junction
* → the neighbour may become L-shaped). Returns null when nothing coincides.
*/
export function shiftSharedSpans(
poly: number[][], a: number[], b: number[], D: [number, number], eps: number,
): number[][] | null {
const spans = sharedSpansWith(poly, a, b, eps);
if (!spans.length) return null;
const onSpan = (p: number[]) => spans.some(([p1, p2]) => distPointToSpan(p, p1, p2) <= eps);
const n = poly.length;
const out: number[][] = [];
for (let j = 0; j < n; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % n];
out.push(onSpan(q1) ? add2(q1, D) : [...q1]);
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const u = [e[0] / elen, e[1] / elen];
// collinear with a-b? (both endpoints on the a-b LINE)
const abL = len2d(sub(b, a)) || 1;
const uv = [(b[0] - a[0]) / abL, (b[1] - a[1]) / abL];
const o1 = Math.abs((q1[0] - a[0]) * uv[1] - (q1[1] - a[1]) * uv[0]);
const o2 = Math.abs((q2[0] - a[0]) * uv[1] - (q2[1] - a[1]) * uv[0]);
if (o1 > eps || o2 > eps) continue;
const tA = dot(sub(a, q1), u);
const tB = dot(sub(b, q1), u);
const lo = Math.max(0, Math.min(tA, tB));
const hi = Math.min(elen, Math.max(tA, tB));
if (hi - lo <= eps) continue;
// interior boundaries split the edge: entering the overlap emits the static
// point then its moved copy, leaving it emits the moved copy then the static
if (lo > eps && lo < elen - eps) {
const p = [q1[0] + u[0] * lo, q1[1] + u[1] * lo];
out.push([...p], add2(p, D));
}
if (hi > eps && hi < elen - eps) {
const p = [q1[0] + u[0] * hi, q1[1] + u[1] * hi];
out.push(add2(p, D), [...p]);
}
}
return out;
}
/** Drop consecutive duplicates and collinear middle vertices (commit-time cleanup). */
export function simplifyPoly(poly: number[][], eps = 1e-6): number[][] {
let pts = poly.filter((p, i) => len2d(sub(p, poly[(i + 1) % poly.length])) > eps);
for (let pass = 0; pass < 2; pass++) {
pts = pts.filter((p, i) => {
const prev = pts[(i - 1 + pts.length) % pts.length];
const next = pts[(i + 1) % pts.length];
const cross = (p[0] - prev[0]) * (next[1] - prev[1]) - (p[1] - prev[1]) * (next[0] - prev[0]);
const span = len2d(sub(next, prev)) || 1;
return Math.abs(cross) / span > eps; // keep only real corners
});
}
return pts.length >= 3 ? pts : poly;
}
/** Simple polygon: no properly crossing edges (shared walls touching is fine). */
export function polyIsSimple(poly: number[][]): boolean {
const n = poly.length;
if (n < 3) return false;
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) {
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
if (segmentsProperlyCross(poly[i], poly[(i + 1) % n], poly[j], poly[(j + 1) % n])) return false;
}
return true;
}
/**
* Normal clearance between the moved stretches and every PARALLEL wall of the
* same room with an overlapping projection — the «opposite wall» distance that
* enforces the 30 cm minimum. Infinity when no opposite wall exists.
*/
export function minParallelClearance(
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
): number {
let best = Infinity;
for (const [a, b] of spans) {
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) continue;
const u = [ab[0] / L, ab[1] / L];
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const cosang = Math.abs((e[0] * u[0] + e[1] * u[1]) / elen);
if (cosang < 1 - 1e-4) continue; // not parallel
// projection overlap along the span direction
const t1 = dot(sub(q1, a), u);
const t2 = dot(sub(q2, a), u);
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(L, Math.max(t1, t2));
if (hi - lo <= eps) continue;
const d1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
if (d1 <= eps) continue; // the span itself / collinear leftovers
if (d1 < best) best = d1;
}
}
return best;
}
/**
* HP-1550-02: orientation-independent clearance of the moved stretches.
*
* minParallelClearance only saw PARALLEL opposite walls, so a triangle (no
* parallel wall at all) reported Infinity and the 30 cm floor was simply off —
* the base could be dragged to a 5-unit sliver. This measure looks at the whole
* band the span sweeps along its normal: every vertex strictly inside the band
* and every edge crossing the band interior counts with its perpendicular
* distance from the span line. Two exclusions keep it honest:
* - anything ON the span line (offset ≤ eps) is the span itself, a collinear
* wall remainder or a T-insert — not an opposite obstacle;
* - the band ENDS (projection ≤ eps or ≥ L − eps) are excluded, so the
* |d|-long step edge a T-junction inserts at the very end of the span does
* not read as a paper-thin room on every small drag.
* Offsets cannot change sign inside the band (that would cross the span —
* polyIsSimple already rejected it), so an edge's minimum lies at a clip bound.
* Infinity still means «nothing opposite at all» (e.g. growing outward).
*/
export function minSpanClearance(
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
): number {
let best = Infinity;
for (const [a, b] of spans) {
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) continue;
const u = [ab[0] / L, ab[1] / L];
const soff = (p: number[]) => (p[0] - a[0]) * u[1] - (p[1] - a[1]) * u[0];
const tOf = (p: number[]) => (p[0] - a[0]) * u[0] + (p[1] - a[1]) * u[1];
const lo = eps, hi = L - eps;
for (const v of poly) {
const o = Math.abs(soff(v));
if (o <= eps) continue;
const tv = tOf(v);
if (tv <= lo || tv >= hi) continue;
if (o < best) best = o;
}
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const o1 = soff(q1), o2 = soff(q2);
if (Math.abs(o1) <= eps || Math.abs(o2) <= eps) continue; // attached to the moving wall
const t1 = tOf(q1), t2 = tOf(q2);
const tlo = Math.max(lo, Math.min(t1, t2));
const thi = Math.min(hi, Math.max(t1, t2));
if (thi - tlo <= eps) continue; // casts no shadow on the span interior
const dt = t2 - t1;
if (Math.abs(dt) < eps) { // perpendicular-ish edge fully inside the band
best = Math.min(best, Math.abs(o1), Math.abs(o2));
continue;
}
const offAt = (tt: number) => Math.abs(o1 + ((tt - t1) / dt) * (o2 - o1));
best = Math.min(best, offAt(tlo), offAt(thi));
}
}
return best;
}
/** Convex hull (monotone chain) — only the width measure below needs it. */
function convexHull(pts: number[][]): number[][] {
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (p.length < 3) return p;
const cross = (o: number[], a: number[], b: number[]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: number[][] = [];
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
lower.push(pt);
}
const upper: number[][] = [];
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
upper.push(pt);
}
lower.pop(); upper.pop();
return lower.concat(upper);
}
/**
* HP-1550-02: the TRUE minimum width of a polygon — rotating calipers over the
* convex hull (the min over hull edge directions of the perpendicular extent).
* The axis-aligned bbox lied under rotation: a 500×100 rectangle turned 45° has
* a ≈424×424 bbox, so a 0.1 scale slid the real 100-side down to 10 unchecked.
* A similarity scales every distance by k, which makes k·minPolyWidth exact —
* and a concave room is judged by its overall silhouette, so a small notch
* that takes no part in the operation cannot veto a legal scale.
*/
export function minPolyWidth(poly: number[][]): number {
const h = convexHull(poly);
if (h.length < 3) return 0;
let best = Infinity;
for (let i = 0; i < h.length; i++) {
const a = h[i], b = h[(i + 1) % h.length];
const e = sub(b, a);
const L = len2d(e);
if (L < 1e-12) continue;
const u = [e[0] / L, e[1] / L];
let w = 0;
for (const pt of h) w = Math.max(w, Math.abs((pt[0] - a[0]) * u[1] - (pt[1] - a[1]) * u[0]));
if (w < best) best = w;
}
return Number.isFinite(best) ? best : 0;
}
/**
* Do two outlines ILLEGALLY share floor area? `roomsOverlap` alone misses the
* «slide-over» case: equal-height rectangles overlapping horizontally have all
* their edge intersections on collinear stretches, so nothing «properly
* crosses» and nothing is strictly inside. A real polygon intersection area
* settles it; legal full nesting (island rooms) stays legal.
*/
export function illegalOverlap(a: number[][], b: number[][], eps: number): boolean {
if (roomsOverlap(a, b, eps)) return true;
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
let area = 0;
try {
const res = intersection(
[[...a.map((p) => [p[0], p[1]]), [a[0][0], a[0][1]]]] as any,
[[...b.map((p) => [p[0], p[1]]), [b[0][0], b[0][1]]]] as any,
);
for (const poly of res as any) if (poly?.[0]) area += polygonArea(poly[0]);
} catch {
return false; // a degenerate clip must not block the drag; the other stops still hold
}
return area > Math.max(1e-7, eps * eps);
}
// ---------------- mechanism A: the full drag pipeline ----------------
export function planEdgeDrag(rooms: RoomIn[], roomId: string, edge: number): EdgeDragPlan | null {
const room = rooms.find((r) => r.id === roomId);
if (!room || !room.poly || room.poly.length < 3) return null;
if (edge < 0 || edge >= room.poly.length) return null;
const a = [...room.poly[edge]];
const b = [...room.poly[(edge + 1) % room.poly.length]];
return { roomId, edge, a, b, n: edgeNormal(room.poly, edge) };
}
/** Apply the drag at distance d: own edge + every coinciding neighbour stretch. */
export function applyEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, eps: number,
): EdgeDragResult {
const D: [number, number] = [plan.n[0] * d, plan.n[1] * d];
const res: EdgeDragResult = { polys: {}, openings: {}, movedSpans: {} };
if (Math.abs(d) < 1e-9) return res;
for (const r of rooms) {
if (r.id === plan.roomId) {
res.polys[r.id] = movePolyEdge(r.poly, plan.edge, d, plan.n);
res.movedSpans[r.id] = [[add2(plan.a, D), add2(plan.b, D)]];
continue;
}
const spans = sharedSpansWith(r.poly, plan.a, plan.b, eps);
if (!spans.length) continue;
const shifted = shiftSharedSpans(r.poly, plan.a, plan.b, D, eps);
if (shifted) {
res.polys[r.id] = shifted;
res.movedSpans[r.id] = spans.map(([p, q]) => [add2(p, D), add2(q, D)] as [number[], number[]]);
}
}
// openings ON the moving wall travel with it (docs/RESIZE.md: anchors)
for (const o of openings) {
if (distPointToSpan([o.x, o.y], plan.a, plan.b) <= eps) res.openings[o.id] = [o.x + D[0], o.y + D[1]];
}
return res;
}
/** An opening must sit fully on ONE wall of some room: centre on the edge, both ends within it. */
function openingFits(o: { x: number; y: number; length: number }, polys: number[][][], eps: number): boolean {
for (const poly of polys) {
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const u = [e[0] / elen, e[1] / elen];
const off = Math.abs((o.x - q1[0]) * u[1] - (o.y - q1[1]) * u[0]);
if (off > eps) continue;
const t = (o.x - q1[0]) * u[0] + (o.y - q1[1]) * u[1];
if (t - o.length / 2 >= -eps && t + o.length / 2 <= elen + eps) return true;
}
}
return false;
}
/** Openings that sit on any wall of any of the given (pre-move) outlines. */
function openingsOnRooms(openings: OpeningIn[], polys: number[][][], eps: number): OpeningIn[] {
return openings.filter((o) =>
polys.some((poly) => {
for (let j = 0; j < poly.length; j++)
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
return false;
}),
);
}
export interface StopOpts {
minDim: number; // canvas units (≈30 cm through cell_cm)
eps: number; // collinearity epsilon (canvas units)
}
/** All the stops of docs/RESIZE.md for one candidate distance. */
export function validateEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, opts: StopOpts,
): boolean {
const { minDim, eps } = opts;
if (!Number.isFinite(d)) return false;
if (Math.abs(d) < 1e-9) return true;
const res = applyEdgeDrag(rooms, openings, plan, d, eps);
const changedIds = Object.keys(res.polys);
const newPolyOf = (r: RoomIn) => res.polys[r.id] || r.poly;
for (const id of changedIds) {
const r = rooms.find((x) => x.id === id)!;
const np = res.polys[id];
// simple + orientation preserved + not degenerate
if (!polyIsSimple(np)) return false;
const s0 = signedArea(r.poly), s1 = signedArea(np);
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
// minimum size: orientation-independent clearance of the moved stretches
// (HP-1550-02 — the parallel-walls-only measure left triangles and other
// non-parallel geometry without the 30 cm floor); a room already thinner
// keeps its clearance (improving is allowed, worsening is not)
const oldSpans: [number[], number[]][] = id === plan.roomId
? [[plan.a, plan.b]]
: sharedSpansWith(r.poly, plan.a, plan.b, eps);
const cOld = minSpanClearance(r.poly, oldSpans, eps);
const cNew = minSpanClearance(np, res.movedSpans[id] || [], eps);
if (cNew < Math.min(minDim, cOld) - eps) return false;
// every pre-existing room relationship must SURVIVE the drag: an island
// stays an island (a jump fully past a thin island crosses no edge, so
// containment is checked explicitly), a nested room stays inside its
// parent, and unrelated rooms must not start sharing area or nesting
for (const other of rooms) {
if (other.id === id) continue;
const otherNew = newPolyOf(other);
if (polyContainsPoly(r.poly, other.poly, eps)) { // our island
if (!polyContainsPoly(np, otherNew, eps)) return false;
continue;
}
if (polyContainsPoly(other.poly, r.poly, eps)) { // we are the island
if (!polyContainsPoly(otherNew, np, eps)) return false;
continue;
}
if (polyContainsPoly(np, otherNew, eps) || polyContainsPoly(otherNew, np, eps)) return false;
if (illegalOverlap(np, otherNew, eps)) return false;
}
}
// openings: everything that sat on a wall of an affected room must still fit
const oldPolys = changedIds.map((id) => rooms.find((x) => x.id === id)!.poly);
const allNew = rooms.map(newPolyOf);
for (const o of openingsOnRooms(openings, oldPolys, eps * 2)) {
const c = res.openings[o.id];
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
if (!openingFits(moved, allNew, eps * 2)) return false;
}
return true;
}
/**
* Largest valid distance toward dWanted, stepping back by `step` (the grid
* pitch, so a stopped wall still lands on the grid). 0 = the wall stays put.
*/
export function clampEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, dWanted: number, step: number, opts: StopOpts,
): number {
if (!Number.isFinite(dWanted) || Math.abs(dWanted) < 1e-9) return 0;
const sign = Math.sign(dWanted);
let mag = Math.abs(dWanted);
const s = Math.max(step, 1e-6);
for (let guard = 0; guard < 4096 && mag > 1e-9; guard++, mag -= s) {
const d = sign * mag;
if (validateEdgeDrag(rooms, openings, plan, d, opts)) return d;
}
return 0;
}
// ---------------- safe fixed-topology wall move (#277) ----------------
type Axis = 'h' | 'v';
function axisOf(a: number[], b: number[], eps: number): Axis | null {
const dx = Math.abs(b[0] - a[0]);
const dy = Math.abs(b[1] - a[1]);
if (dx <= eps && dy > eps) return 'v';
if (dy <= eps && dx > eps) return 'h';
return null;
}
function samePt(a: number[], b: number[], eps: number): boolean {
return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
}
function sameEndpoints(a: number[], b: number[], c: number[], d: number[], eps: number): boolean {
return (samePt(a, c, eps) && samePt(b, d, eps))
|| (samePt(a, d, eps) && samePt(b, c, eps));
}
function collinearOverlapLength(
a: number[], b: number[], c: number[], d: number[], eps: number,
): number {
const axis = axisOf(a, b, eps);
if (!axis || axisOf(c, d, eps) !== axis) return 0;
if (axis === 'h') {
if (Math.abs(a[1] - c[1]) > eps || Math.abs(a[1] - d[1]) > eps) return 0;
return Math.max(0, Math.min(Math.max(a[0], b[0]), Math.max(c[0], d[0]))
- Math.max(Math.min(a[0], b[0]), Math.min(c[0], d[0])));
}
if (Math.abs(a[0] - c[0]) > eps || Math.abs(a[0] - d[0]) > eps) return 0;
return Math.max(0, Math.min(Math.max(a[1], b[1]), Math.max(c[1], d[1]))
- Math.max(Math.min(a[1], b[1]), Math.min(c[1], d[1])));
}
function sideEdgesArePerpendicular(poly: number[][], edge: number, moving: Axis, eps: number): boolean {
const n = poly.length;
const prev = axisOf(poly[(edge - 1 + n) % n], poly[edge], eps);
const next = axisOf(poly[(edge + 1) % n], poly[(edge + 2) % n], eps);
const side = moving === 'h' ? 'v' : 'h';
return prev === side && next === side;
}
function segmentDistance(a: number[], b: number[], c: number[], d: number[]): number {
if (segmentsProperlyCross(a, b, c, d)) return 0;
return Math.min(
distPointToSpan(a, c, d), distPointToSpan(b, c, d),
distPointToSpan(c, a, b), distPointToSpan(d, a, b),
);
}
function bboxOf(poly: number[][]): [number, number, number, number] {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const point of poly) {
x0 = Math.min(x0, point[0]); y0 = Math.min(y0, point[1]);
x1 = Math.max(x1, point[0]); y1 = Math.max(y1, point[1]);
}
return [x0, y0, x1, y1];
}
function bboxesDisjoint(a: number[][], b: number[][], eps: number): boolean {
const aa = bboxOf(a), bb = bboxOf(b);
return aa[2] < bb[0] - eps || bb[2] < aa[0] - eps
|| aa[3] < bb[1] - eps || bb[3] < aa[1] - eps;
}
function obstacleOverlaysMovingEdge(
obstacle: SafeResizeObstacle, a: number[], b: number[], opts: SafeResizeOptions,
): boolean {
const eps = opts.eps;
if (obstacle.kind === 'circle') {
return distPointToSpan(obstacle.center, a, b)
< obstacle.radius + (opts.movingHalf || 0) - eps;
}
return collinearOverlapLength(a, b, obstacle.a, obstacle.b, eps) > eps;
}
const axisCoordinate = (point: number[], axis: Axis): number => (
axis === 'h' ? point[0] : point[1]
);
const lineCoordinate = (point: number[], axis: Axis): number => (
axis === 'h' ? point[1] : point[0]
);
function ownershipIntervalsOnLine(
rooms: RoomIn[], axis: Axis, line: number, eps: number,
): SafeResizeOwnershipInterval[] {
const intervals: SafeResizeOwnershipInterval[] = [];
for (const room of rooms) {
for (let edge = 0; edge < room.poly.length; edge++) {
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
if (axisOf(a, b, eps) !== axis
|| Math.abs(lineCoordinate(a, axis) - line) > eps
|| Math.abs(lineCoordinate(b, axis) - line) > eps) continue;
const ca = axisCoordinate(a, axis);
const cb = axisCoordinate(b, axis);
const lo = Math.min(ca, cb), hi = Math.max(ca, cb);
if (hi - lo > eps) intervals.push({ roomId: room.id, edge, lo, hi });
}
}
return intervals.sort((a, b) => a.lo - b.lo || a.hi - b.hi
|| a.roomId.localeCompare(b.roomId) || a.edge - b.edge);
}
function ownershipRuns(
intervals: SafeResizeOwnershipInterval[], lo: number, hi: number, eps: number,
): SafeResizeOwnershipRun[] {
const breaks = [lo, hi];
for (const interval of intervals) {
const start = Math.max(lo, interval.lo);
const end = Math.min(hi, interval.hi);
if (end - start > eps) breaks.push(start, end);
}
const sorted = [...new Set(breaks)].sort((a, b) => a - b);
const runs: SafeResizeOwnershipRun[] = [];
for (let i = 0; i + 1 < sorted.length; i++) {
const start = sorted[i], end = sorted[i + 1];
if (end - start <= eps) continue;
const mid = (start + end) / 2;
const owners = [...new Set(intervals.filter((interval) => (
mid > interval.lo - eps && mid < interval.hi + eps
)).map((interval) => interval.roomId))].sort();
runs.push({ lo: start, hi: end, owners });
}
return runs;
}
function buildSideOwnership(
rooms: RoomIn[], roomId: string, edge: number, movedEndpoint: 0 | 1, eps: number,
): SafeResizeSideOwnership | null {
const room = rooms.find((candidate) => candidate.id === roomId);
if (!room) return null;
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
const axis = axisOf(a, b, eps);
if (!axis) return null;
const fixedPoint = movedEndpoint === 0 ? b : a;
const movingPoint = movedEndpoint === 0 ? a : b;
const fixed = axisCoordinate(fixedPoint, axis);
const moving = axisCoordinate(movingPoint, axis);
const line = lineCoordinate(fixedPoint, axis);
const intervals = ownershipIntervalsOnLine(rooms, axis, line, eps);
const baseline = ownershipRuns(intervals, Math.min(fixed, moving), Math.max(fixed, moving), eps);
if (!baseline.length || baseline.some((run) => run.owners.length < 1 || run.owners.length > 2)) {
return null;
}
return { roomId, edge, movedEndpoint, axis, line, fixed, moving, intervals, baseline };
}
function candidateOwnershipIntervals(
profile: SafeResizeSideOwnership, result: EdgeDragResult, eps: number,
): SafeResizeOwnershipInterval[] {
const intervals: SafeResizeOwnershipInterval[] = [];
for (const source of profile.intervals) {
const poly = result.polys[source.roomId];
if (!poly) {
intervals.push(source);
continue;
}
if (source.edge < 0 || source.edge >= poly.length) continue;
const a = poly[source.edge];
const b = poly[(source.edge + 1) % poly.length];
if (axisOf(a, b, eps) !== profile.axis
|| Math.abs(lineCoordinate(a, profile.axis) - profile.line) > eps
|| Math.abs(lineCoordinate(b, profile.axis) - profile.line) > eps) continue;
const ca = axisCoordinate(a, profile.axis);
const cb = axisCoordinate(b, profile.axis);
const lo = Math.min(ca, cb), hi = Math.max(ca, cb);
if (hi - lo > eps) intervals.push({ ...source, lo, hi });
}
return intervals;
}
function ownerCountAt(intervals: SafeResizeOwnershipInterval[], coordinate: number, eps: number): number {
return new Set(intervals.filter((interval) => (
coordinate > interval.lo - eps && coordinate < interval.hi + eps
)).map((interval) => interval.roomId)).size;
}
/**
* Preserve the physical role (outer/shared) of every atomic side-wall run.
* Extending a shared run into outer space, or shortening it so the neighbour
* keeps an outer continuation, is the mixed-role corruption from #289.
*/
function sideOwnershipPreserved(
result: EdgeDragResult, plan: SafeResizePlan, eps: number,
): boolean {
for (const profile of plan.sideOwnership) {
const nextRoom = result.polys[profile.roomId];
if (!nextRoom) return false;
const a = nextRoom[profile.edge];
const b = nextRoom[(profile.edge + 1) % nextRoom.length];
if (axisOf(a, b, eps) !== profile.axis) return false;
const nextMoving = axisCoordinate(profile.movedEndpoint === 0 ? a : b, profile.axis);
const candidate = candidateOwnershipIntervals(profile, result, eps);
const terminalRun = profile.moving >= profile.fixed
? profile.baseline[profile.baseline.length - 1]
: profile.baseline[0];
const terminalRole = terminalRun.owners.length;
if (terminalRole < 1 || terminalRole > 2) return false;
const lo = Math.min(profile.fixed, profile.moving, nextMoving);
const hi = Math.max(profile.fixed, profile.moving, nextMoving);
const breaks = [lo, hi, profile.fixed, profile.moving, nextMoving];
for (const interval of profile.intervals) breaks.push(interval.lo, interval.hi);
for (const interval of candidate) breaks.push(interval.lo, interval.hi);
const sorted = [...new Set(breaks.filter((value) => value >= lo && value <= hi))]
.sort((x, y) => x - y);
const oldLo = Math.min(profile.fixed, profile.moving);
const oldHi = Math.max(profile.fixed, profile.moving);
const newLo = Math.min(profile.fixed, nextMoving);
const newHi = Math.max(profile.fixed, nextMoving);
for (let i = 0; i + 1 < sorted.length; i++) {
const start = sorted[i], end = sorted[i + 1];
if (end - start <= eps) continue;
const mid = (start + end) / 2;
const oldOwns = mid > oldLo - eps && mid < oldHi + eps;
const newOwns = mid > newLo - eps && mid < newHi + eps;
if (!oldOwns && !newOwns) continue;
const oldRole = ownerCountAt(profile.intervals, mid, eps);
const nextRole = ownerCountAt(candidate, mid, eps);
if (oldRole > 2 || nextRole > 2) return false;
if (oldOwns && newOwns) {
if (oldRole !== nextRole) return false;
} else if (!oldOwns && newOwns) {
// Empty space may receive a new outer continuation. Existing physical
// material, however, must keep its role: an outer wall cannot become
// shared merely because this room grows onto it (and vice versa).
if (nextRole !== terminalRole || (oldRole > 0 && oldRole !== nextRole)) return false;
} else if (oldRole === 2 ? nextRole !== 2 : nextRole > 1) {
return false;
}
}
}
return true;
}
/** Resolve eligibility once at gesture start. Partial shared stretches and a
* third owner fail closed; the old vertex-insertion cascade is never planned. */
export function resolveSafeResize(
rooms: RoomIn[], openings: SafeOpeningIn[], roomId: string, edge: number,
opts: SafeResizeOptions,
): SafeResizeResolution {
const { eps } = opts;
const room = rooms.find((candidate) => candidate.id === roomId);
if (!room || edge < 0 || edge >= (room.poly?.length || 0)
|| room.poly.length < 4 || !polyIsSimple(room.poly)) {
return { enabled: false, reason: 'invalid-geometry' };
}
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
const movingAxis = axisOf(a, b, eps);
if (!movingAxis) return { enabled: false, reason: 'diagonal' };
if (!sideEdgesArePerpendicular(room.poly, edge, movingAxis, eps)) {
return { enabled: false, reason: 'side-angle' };
}
if (opts.thicknessConflict) {
return { enabled: false, reason: 'thickness-conflict' };
}
for (const obstacle of opts.obstacles || []) {
if (obstacleOverlaysMovingEdge(obstacle, a, b, opts)) {
return { enabled: false, reason: 'duplicate-physical-wall' };
}
}
const targetLength = len2d(sub(b, a));
const exact: Array<{ room: RoomIn; edge: number }> = [];
const touchedRooms = new Set<string>();
let partial = false;
let unequal = false;
for (const other of rooms) {
if (other.id === roomId) continue;
for (let i = 0; i < other.poly.length; i++) {
const c = other.poly[i];
const d = other.poly[(i + 1) % other.poly.length];
const overlap = collinearOverlapLength(a, b, c, d, eps);
if (overlap <= eps) continue;
touchedRooms.add(other.id);
if (sameEndpoints(a, b, c, d, eps)) exact.push({ room: other, edge: i });
else if (overlap < targetLength - eps) partial = true;
else unequal = true;
}
}
if (partial) return { enabled: false, reason: 'partial-shared' };
if (unequal) return { enabled: false, reason: 'unequal-shared' };
if (touchedRooms.size > 1 || exact.length > 1) {
return { enabled: false, reason: 'multiple-rooms' };
}
const edgeByRoom: Record<string, number> = { [roomId]: edge };
const roomIds = [roomId];
if (exact.length === 1) {
const neighbour = exact[0];
if (!polyIsSimple(neighbour.room.poly)) {
return { enabled: false, reason: 'invalid-geometry' };
}
if (!sideEdgesArePerpendicular(neighbour.room.poly, neighbour.edge, movingAxis, eps)) {
return { enabled: false, reason: 'side-angle' };
}
roomIds.push(neighbour.room.id);
edgeByRoom[neighbour.room.id] = neighbour.edge;
}
const movingOpeningIds: string[] = [];
for (const opening of openings) {
if (distPointToSpan([opening.x, opening.y], a, b) > eps * 2) continue;
if (opening.hosted || opening.length > targetLength + eps * 2) {
return { enabled: false, reason: 'opening-conflict' };
}
movingOpeningIds.push(opening.id);
}
const topology = Object.fromEntries(roomIds.map((id) => [
id, rooms.find((candidate) => candidate.id === id)!.poly.length,
]));
const sideOwnership: SafeResizeSideOwnership[] = [];
for (const id of roomIds) {
const owner = rooms.find((candidate) => candidate.id === id)!;
const ownerEdge = edgeByRoom[id];
const prev = (ownerEdge - 1 + owner.poly.length) % owner.poly.length;
const next = (ownerEdge + 1) % owner.poly.length;
const before = buildSideOwnership(rooms, id, prev, 1, eps);
const after = buildSideOwnership(rooms, id, next, 0, eps);
if (!before || !after) return { enabled: false, reason: 'partial-shared' };
sideOwnership.push(before, after);
}
const plan: SafeResizePlan = {
roomId, edge, a: [...a], b: [...b], n: edgeNormal(room.poly, edge),
roomIds, edgeByRoom, topology, movingOpeningIds, sideOwnership,
};
if (!validateSafeResize(rooms, openings, plan, 0, opts)) {
return { enabled: false, reason: 'invalid-geometry' };
}
const step = Math.abs(Number(opts.step));
if (Number.isFinite(step) && step > eps) {
const neighbours = [-step, step];
if (!neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, opts,
))) {
if (!neighbours.some((delta) => sideOwnershipPreserved(
applySafeResize(rooms, openings, plan, delta), plan, eps,
))) return { enabled: false, reason: 'partial-shared' };
const withoutObstacles = { ...opts, obstacles: [] };
if (neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, withoutObstacles,
))) return { enabled: false, reason: 'duplicate-physical-wall' };
if (openings.length && neighbours.some((delta) => validateSafeResize(
rooms, [], plan, delta, opts,
))) return { enabled: false, reason: 'opening-conflict' };
return { enabled: false, reason: 'invalid-geometry' };
}
}
return { enabled: true, plan };
}
/** Test/diagnostic audit over the exact production resolver. The caller owns
* controller-specific options (wall thickness and physical obstacles); this
* helper owns only enumeration, stable handle identity and reason accounting,
* so it cannot drift into a second eligibility implementation (#292). */
export function auditSafeResizeEligibility(
rooms: RoomIn[], openings: SafeOpeningIn[],
optionsFor: (
roomId: string, edge: number, a: number[], b: number[],
) => SafeResizeOptions,
): SafeResizeEligibilityAudit {
rooms = coalesceResizeRooms(rooms, Math.max(1e-12, ...rooms.map((room) => (
room.poly.length ? Math.max(...room.poly.flatMap((point) => point.map(Math.abs))) * 1e-12 : 0
))));
const disabled = Object.fromEntries(
SAFE_RESIZE_REASONS.map((reason) => [reason, 0]),
) as Record<SafeResizeReason, number>;
const pointId = (point: number[]) => point.slice(0, 2).map((value) => {
const finite = Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : 0;
return finite.toFixed(9);
}).join(',');
const handles: SafeResizeAuditHandle[] = [];
let enabled = 0;
for (const room of rooms) {
for (let edge = 0; edge < (room.poly?.length || 0); edge++) {
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
const endpoints = [[...a], [...b]].sort((left, right) => (
left[0] - right[0] || left[1] - right[1]
)) as [number[], number[]];
const resolution = resolveSafeResize(
rooms, openings, room.id, edge, optionsFor(room.id, edge, a, b),
);
if (resolution.enabled) enabled++;
else disabled[resolution.reason]++;
handles.push({
id: `${room.id}:${edge}:${pointId(endpoints[0])}~${pointId(endpoints[1])}`,
roomId: room.id,
edge,
endpoints,
resolution,
});
}
}
handles.sort((left, right) => left.id.localeCompare(right.id));
return { total: handles.length, enabled, disabled, handles };
}
/** Apply the same vector to the same two vertices in one or two rooms. No
* insertion, simplification, sorting or ownership inference occurs here. */
export function applySafeResize(
rooms: RoomIn[], openings: SafeOpeningIn[], plan: SafeResizePlan, d: number,
): EdgeDragResult {
const D: [number, number] = [plan.n[0] * d, plan.n[1] * d];
const result: EdgeDragResult = { polys: {}, openings: {}, movedSpans: {} };
for (const roomId of plan.roomIds) {
const room = rooms.find((candidate) => candidate.id === roomId);
const edge = plan.edgeByRoom[roomId];
if (!room || edge == null) continue;
const next = (edge + 1) % room.poly.length;
result.polys[roomId] = room.poly.map((point, index) => (
index === edge || index === next ? add2(point, D) : [...point]
));
result.movedSpans[roomId] = [[
add2(room.poly[edge], D), add2(room.poly[next], D),
]];
}
for (const opening of openings) {
if (plan.movingOpeningIds.includes(opening.id)) {
result.openings[opening.id] = [opening.x + D[0], opening.y + D[1]];
}
}
return result;
}
function openingOnEdge(opening: SafeOpeningIn, a: number[], b: number[], eps: number): boolean {
if (distPointToSpan([opening.x, opening.y], a, b) > eps * 2) return false;
const edgeLength = len2d(sub(b, a));
if (edgeLength <= eps) return false;
const u = [(b[0] - a[0]) / edgeLength, (b[1] - a[1]) / edgeLength];
const t = (opening.x - a[0]) * u[0] + (opening.y - a[1]) * u[1];
return t >= -eps && t <= edgeLength + eps;
}
function sideOpeningFits(
opening: SafeOpeningIn, oldA: number[], oldB: number[], newA: number[], newB: number[],
movedEndpoint: 0 | 1, movingHalf: number, eps: number,
): boolean {
if (!openingOnEdge(opening, oldA, oldB, eps)) return true;
if (distPointToSpan([opening.x, opening.y], newA, newB) > eps * 2) return false;
const moved = movedEndpoint === 0 ? newA : newB;
return len2d(sub([opening.x, opening.y], moved))
>= opening.length / 2 + movingHalf - eps;
}
function obstacleBlocksCandidate(
obstacle: SafeResizeObstacle, a: number[], b: number[], opts: SafeResizeOptions,
): boolean {
const half = opts.movingHalf || 0;
if (obstacle.kind === 'circle') {
return distPointToSpan(obstacle.center, a, b) < obstacle.radius + half - opts.eps;
}
return segmentDistance(a, b, obstacle.a, obstacle.b)
< (obstacle.half || 0) + half - opts.eps;
}
/** Exact candidate check used by preview and pointerup. The check is deliberately
* stricter than the historical polygon-only validator: changed-room identity,
* topology and shared endpoints are part of validity. */
export function validateSafeResize(
rooms: RoomIn[], openings: SafeOpeningIn[], plan: SafeResizePlan, d: number,
opts: SafeResizeOptions,
): boolean {
const { eps, minDim } = opts;
if (!Number.isFinite(d) || plan.roomIds.length < 1 || plan.roomIds.length > 2) return false;
const result = applySafeResize(rooms, openings, plan, d);
if (Object.keys(result.polys).length !== plan.roomIds.length) return false;
if (!sideOwnershipPreserved(result, plan, eps)) return false;
const changed = new Set(plan.roomIds);
const polyOf = (room: RoomIn) => result.polys[room.id] || room.poly;
for (const roomId of plan.roomIds) {
const original = rooms.find((room) => room.id === roomId);
const next = result.polys[roomId];
const edge = plan.edgeByRoom[roomId];
if (!original || !next || next.length !== plan.topology[roomId]
|| next.length !== original.poly.length || !polyIsSimple(next)) return false;
const n = original.poly.length;
const prev = (edge - 1 + n) % n;
const involvedEdges = [prev, edge, (edge + 1) % n];
for (const index of involvedEdges) {
if (classifyNearAxisSegment(next[index], next[(index + 1) % n])) return false;
}
const s0 = signedArea(original.poly);
const s1 = signedArea(next);
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
const oldSpan: [number[], number[]] = [
original.poly[edge], original.poly[(edge + 1) % original.poly.length],
];
const newSpan = result.movedSpans[roomId];
const oldClearance = minSpanClearance(original.poly, [oldSpan], eps);
const newClearance = minSpanClearance(next, newSpan, eps);
if (newClearance < Math.min(minDim, oldClearance) - eps) return false;
const sideAOld: [number[], number[]] = [original.poly[prev], original.poly[edge]];
const sideANew: [number[], number[]] = [next[prev], next[edge]];
const sideBOld: [number[], number[]] = [original.poly[(edge + 1) % n], original.poly[(edge + 2) % n]];
const sideBNew: [number[], number[]] = [next[(edge + 1) % n], next[(edge + 2) % n]];
const movingAxis = axisOf(next[edge], next[(edge + 1) % n], eps);
const sideAxis = movingAxis === 'h' ? 'v' : movingAxis === 'v' ? 'h' : null;
if (!sideAxis || axisOf(...sideANew, eps) !== sideAxis
|| axisOf(...sideBNew, eps) !== sideAxis) return false;
for (const opening of openings) {
if (plan.movingOpeningIds.includes(opening.id)) continue;
if (!sideOpeningFits(opening, ...sideAOld, ...sideANew, 1, opts.movingHalf || 0, eps)) return false;
if (!sideOpeningFits(opening, ...sideBOld, ...sideBNew, 0, opts.movingHalf || 0, eps)) return false;
}
}
if (plan.roomIds.length === 2) {
const [leftId, rightId] = plan.roomIds;
const left = result.polys[leftId];
const right = result.polys[rightId];
const li = plan.edgeByRoom[leftId];
const ri = plan.edgeByRoom[rightId];
if (!sameEndpoints(
left[li], left[(li + 1) % left.length],
right[ri], right[(ri + 1) % right.length], eps,
)) return false;
}
for (const roomId of plan.roomIds) {
const original = rooms.find((room) => room.id === roomId)!;
const next = result.polys[roomId];
for (const other of rooms) {
if (other.id === roomId || changed.has(other.id)) continue;
const otherNext = polyOf(other);
if (bboxesDisjoint(original.poly, other.poly, eps)
&& bboxesDisjoint(next, otherNext, eps)) continue;
if (polyContainsPoly(original.poly, other.poly, eps)) {
if (!polyContainsPoly(next, otherNext, eps)) return false;
} else if (polyContainsPoly(other.poly, original.poly, eps)) {
if (!polyContainsPoly(otherNext, next, eps)) return false;
} else if (polyContainsPoly(next, otherNext, eps)
|| polyContainsPoly(otherNext, next, eps)
|| illegalOverlap(next, otherNext, eps)) return false;
}
}
const movingRoom = rooms.find((room) => room.id === plan.roomId)!;
const movingPoly = result.polys[plan.roomId];
const movingEdge = plan.edgeByRoom[plan.roomId];
const ma = movingPoly[movingEdge];
const mb = movingPoly[(movingEdge + 1) % movingPoly.length];
if (Math.abs(d) > eps) {
for (const obstacle of opts.obstacles || []) {
if (obstacleBlocksCandidate(obstacle, ma, mb, opts)) return false;
}
}
for (const opening of openings) {
if (!plan.movingOpeningIds.includes(opening.id)) continue;
const center = result.openings[opening.id];
if (!center || opening.hosted) return false;
if (!openingFits({ ...opening, x: center[0], y: center[1] }, [ma, mb].length ? [movingPoly] : [], eps * 2)) {
return false;
}
}
return true;
}
/** Contiguous clamp from zero: once a wall reaches the first unsafe grid node,
* it cannot jump through an opening/corner and become valid again beyond it. */
export function clampSafeResize(
rooms: RoomIn[], openings: SafeOpeningIn[], plan: SafeResizePlan,
dWanted: number, step: number, opts: SafeResizeOptions,
): number {
if (!Number.isFinite(dWanted) || Math.abs(dWanted) < 1e-9) return 0;
const sign = Math.sign(dWanted);
const wanted = Math.abs(dWanted);
const stride = Math.max(Math.abs(step), 1e-6);
let cached = safeClampValidationCache.get(plan);
if (!cached || cached.opts !== opts) {
cached = { opts, values: new Map() };
safeClampValidationCache.set(plan, cached);
}
const cache = cached.values;
let good = 0;
for (let magnitude = Math.min(stride, wanted), guard = 0;
guard < 4096 && magnitude <= wanted + 1e-9;
guard++, magnitude = Math.min(wanted, magnitude + stride)) {
const candidate = sign * magnitude;
const cacheKey = `${candidate.toFixed(9)}|${stride.toFixed(9)}`;
let valid = cache.get(cacheKey);
if (valid === undefined) {
valid = validateSafeResize(rooms, openings, plan, candidate, opts);
if (cache.size < 4096) cache.set(cacheKey, valid);
}
if (!valid) break;
good = candidate;
if (Math.abs(magnitude - wanted) <= 1e-9) break;
}
return good;
}
const safeClampValidationCache = new WeakMap<
SafeResizePlan, { opts: SafeResizeOptions; values: Map<string, boolean> }
>();
/** Test/benchmark diagnostic; active plans are weakly held and individually bounded. */
export function safeResizeCachedDeltaCount(plan: SafeResizePlan): number {
return safeClampValidationCache.get(plan)?.values.size || 0;
}
// ---------------- mechanism B: the scale frame ----------------
export interface ScaleResult {
poly: number[][];
openings: Record<string, [number, number]>;
}
/** Uniform scale of the room about `fixed`; exclusive openings follow, shared ones stay. */
export function applyRoomScale(
room: RoomIn, openings: OpeningIn[], otherPolys: number[][][], fixed: [number, number], k: number, eps: number,
): ScaleResult {
const scalePt = (p: number[]) => [fixed[0] + (p[0] - fixed[0]) * k, fixed[1] + (p[1] - fixed[1]) * k];
const res: ScaleResult = { poly: room.poly.map(scalePt), openings: {} };
for (const o of openings) {
let on = false;
for (let j = 0; j < room.poly.length; j++)
if (distPointToSpan([o.x, o.y], room.poly[j], room.poly[(j + 1) % room.poly.length]) <= eps) { on = true; break; }
if (!on) continue;
// an opening on a wall shared with a neighbour belongs to the neighbour's
// wall once the scale detaches ours — it stays put (docs/RESIZE.md)
const shared = otherPolys.some((poly) => {
for (let j = 0; j < poly.length; j++)
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
return false;
});
if (!shared) {
const c = scalePt([o.x, o.y]);
res.openings[o.id] = [c[0], c[1]];
}
}
return res;
}
/** Stops for one candidate scale factor (neighbours are never dragged along). */
export function validateRoomScale(
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], k: number, opts: StopOpts,
): boolean {
const { minDim, eps } = opts;
if (!Number.isFinite(k) || k <= 0) return false;
if (Math.abs(k - 1) < 1e-9) return true;
const room = rooms.find((r) => r.id === roomId);
if (!room) return false;
const otherPolys = rooms.filter((r) => r.id !== roomId).map((r) => r.poly);
const res = applyRoomScale(room, openings, otherPolys, fixed, k, eps * 2);
const np = res.poly;
// minimum size: a similarity scales every distance by exactly k, so the TRUE
// minimum width of the original scales to k·w0 (HP-1550-02 — the axis-aligned
// bbox side let a rotated rectangle shrink its real short side unchecked);
// an already-thin room keeps the improve-only rule
const w0 = minPolyWidth(room.poly);
if (w0 * k < Math.min(minDim, w0) - eps) return false;
// the neighbour is a wall to hit: pre-existing nesting must survive,
// everything else must not gain shared area or become nested (engulfing a
// foreign room via scale is a stop, not a new island)
for (const r of rooms) {
if (r.id === roomId) continue;
if (polyContainsPoly(room.poly, r.poly, eps)) { // our island
if (!polyContainsPoly(np, r.poly, eps)) return false;
continue;
}
if (polyContainsPoly(r.poly, room.poly, eps)) { // we are the island
if (!polyContainsPoly(r.poly, np, eps)) return false;
continue;
}
if (polyContainsPoly(np, r.poly, eps) || polyContainsPoly(r.poly, np, eps)) return false;
if (illegalOverlap(np, r.poly, eps)) return false;
}
// openings of this room (moved or kept) must still fit on some wall
const allNew = rooms.map((r) => (r.id === roomId ? np : r.poly));
for (const o of openingsOnRooms(openings, [room.poly], eps * 2)) {
const c = res.openings[o.id];
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
if (!openingFits(moved, allNew, eps * 2)) return false;
}
return true;
}
/** Closest valid factor to kWanted (bisecting toward 1, which is always valid). */
export function clampRoomScale(
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], kWanted: number, opts: StopOpts,
): number {
if (!Number.isFinite(kWanted) || kWanted <= 0) return 1;
if (validateRoomScale(rooms, openings, roomId, fixed, kWanted, opts)) return kWanted;
let good = 1, bad = kWanted;
for (let i = 0; i < 28; i++) {
const mid = (good + bad) / 2;
if (validateRoomScale(rooms, openings, roomId, fixed, mid, opts)) good = mid;
else bad = mid;
}
return good;
}