mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Карточка и редакторский рантайм держали ≈380 неиспользуемых импортов, 56 мёртвых объявлений и дублей типов (warm-boot, LS_*, GLOW_*, debounce, navigate, lruRead — копии карточки в рантайме) и 112 приватных членов карточки, которых не читал никто — делегаты `_editorRuntimeOrThrow()._x()`, оставшиеся от выноса #425, и аксессоры glow-состояния. Всё это снято; в 9 других файлах — по одиночной ошибке. Делегаты и поля, которых касаются браузерные смоки (`card._x(...)`), оставлены и посчитаны отдельно. Гейт `npm run lint:unused` (scripts/unused-locals-gate.mjs, в gate:small и Validate после сборки): `tsc --noUnusedLocals` чист, кроме приватных членов карточки из порта HouseplanEditorHostPort / `host.` (portPrivates) и членов, которых зовёт харнесс (harnessPrivates); храповик по шести числам scripts/monolith-metrics.mjs против scripts/monolith-baseline.json — delegates 260→159, portMembers 350, hostRefs 4948, portPrivates 96, harnessPrivates 107, bundleBytes 2 510 141→2 500 387. `npm run inventory` печатает те же числа. Заморозка 54 тестов, читающих монолит как текст (test/monolith-text-anchors.test.mjs); PROCESS.md §2.7 — правило. Логический исходник для контрактных тестов (test/houseplan-source.mjs) дописывает члены рантайма без делегата в карточке — контракт продукта не зависит от наличия заглушки. Потолки ядер и initial gzip опущены на выигрыш (292 000 → 290 400). Бандл пересобран, три копии синхронны. Issue: #624 User-Visible: no
4826 lines
190 KiB
TypeScript
4826 lines
190 KiB
TypeScript
/**
|
||
* Wall thickness — pure geometry (docs/WALL-THICKNESS.md).
|
||
*
|
||
* Thickness is a rendering layer keyed by a segment identity that survives
|
||
* resize. Wall bodies grow ±½ from the centreline; fills, glow, sun and
|
||
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
|
||
* centreline.
|
||
*/
|
||
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;
|
||
cm: number;
|
||
/** Optional exact interval endpoints in config coordinates (new writes). */
|
||
a?: number[];
|
||
b?: number[];
|
||
}
|
||
|
||
export type WallGeometryStatus =
|
||
| 'ok'
|
||
| 'degraded-extra'
|
||
| 'failed-core'
|
||
| 'not-applicable';
|
||
|
||
/** One independently drawable/physical polygon-clipping component. */
|
||
export interface WallGeometryComponent {
|
||
id: string;
|
||
geom: any;
|
||
}
|
||
|
||
export interface WallBodiesGeometryResult {
|
||
status: WallGeometryStatus;
|
||
/** Last successful primary union. Isolated fallback components stay separate. */
|
||
geom: any;
|
||
components: readonly WallGeometryComponent[];
|
||
/** Canonical room masonry before independent bodies. */
|
||
roomGeom: any;
|
||
/** Uncut room-masonry components. Usually one primary component; a rare
|
||
* boolean fallback keeps an isolated exterior shell here as well. */
|
||
roomComponents?: readonly WallGeometryComponent[];
|
||
paperGeom: any;
|
||
depthUnits: number;
|
||
/** Exact transverse slot padding used by the opening-cut pass. */
|
||
openingPadUnits?: number;
|
||
openingIndex: OpeningWallIndex | null;
|
||
/** Canonical junction topology reused by per-room inner contours. */
|
||
multiWallNodes: MultiWallNodeMap | null;
|
||
degradedExtraCount: number;
|
||
}
|
||
|
||
export interface WallGeometryOperations {
|
||
/** Test seam around the transaction which may fail for one independent body. */
|
||
mergeExtra?: (primary: any, extra: any, index: number) => any;
|
||
/** Bounded diagnostic seam; never receives coordinates, ids or exceptions. */
|
||
onCoreFailure?: (phase: string) => void;
|
||
}
|
||
|
||
export const WALL_MIN_CM = 1;
|
||
export const WALL_MAX_CM = 100;
|
||
/** Default thickness offered in the Draw toolbar (docs/WALL-THICKNESS.md §6). */
|
||
export const DRAW_WALL_DEFAULT_CM = 15;
|
||
/** Below this screen depth the diagonal hatch becomes visual noise. */
|
||
export const WALL_HATCH_MIN_PX = 3;
|
||
|
||
/**
|
||
* Hatch density is a physical quantity, not a coordinate one (#230).
|
||
*
|
||
* The pattern step used to be a constant 8 units while wall thickness converts
|
||
* through `cell_cm`, so the same 15 cm wall carried 7.8 stripes on a 1 cm grid
|
||
* and 0.3 on a 25 cm one — a 25× spread for identical data. The step now
|
||
* follows the plan's centimetres: 8 units at the reference `cell_cm: 5`, which
|
||
* is 9.6 cm, and that distance holds at every grid scale.
|
||
*/
|
||
export const HATCH_REFERENCE_CELL_CM = 5;
|
||
export const HATCH_BASE_STEP_UNITS = 8;
|
||
/** Below this the stripes fuse into a fill even at maximum zoom (cell ≈ 80). */
|
||
export const HATCH_MIN_STEP_UNITS = 0.5;
|
||
/** Above this further thickening no longer reads (cell ≈ 0.5). */
|
||
export const HATCH_MAX_STEP_UNITS = 80;
|
||
/** A step thinner than this on screen is noise, not hatching. */
|
||
export const HATCH_MIN_STEP_PX = 2;
|
||
|
||
/** Mitre spikes longer than this × thickness fall back to a bevel. */
|
||
export const MITRE_LIMIT = 4;
|
||
|
||
/**
|
||
* Visual mitre limit (#309, owner decision 2026-08-25). A mitre apex may
|
||
* protrude at most this many maximal half-depths from the node; anything
|
||
* longer is closed with a flat chamfer perpendicular to the apex direction.
|
||
* A square corner of equal depths peaks at ~1.41·h, so 1.5 keeps every
|
||
* right and obtuse corner byte-identical and only trims acute spikes.
|
||
* MITRE_LIMIT above stays as the sanity bound for candidate construction.
|
||
*/
|
||
export const VISUAL_MITRE_LIMIT = 1.5;
|
||
|
||
/**
|
||
* Flat chamfer of an over-long mitre apex (#309). Returns the clipped
|
||
* polygon [node, pA, cA, cB, pB] where cA/cB sit on the fan edges at the
|
||
* visual limit along the apex direction, or null when the apex is within
|
||
* the limit (keep the mitre) or the cut degenerates (fall back to the
|
||
* caller's bevel/chord).
|
||
*/
|
||
function chamferApex(
|
||
node: number[], pA: number[], apex: number[], pB: number[], limit: number,
|
||
): number[][] | null {
|
||
const ux = apex[0] - node[0], uy = apex[1] - node[1];
|
||
const d = Math.hypot(ux, uy);
|
||
if (!(d > 0) || d <= limit + 1e-9) return null;
|
||
const nx = ux / d, ny = uy / d;
|
||
const cut = (from: number[]): number[] | null => {
|
||
const f = (from[0] - node[0]) * nx + (from[1] - node[1]) * ny;
|
||
const t = (limit - f) / (d - f);
|
||
if (!Number.isFinite(t) || t < -1e-9 || t > 1 + 1e-9) return null;
|
||
return [from[0] + (apex[0] - from[0]) * t, from[1] + (apex[1] - from[1]) * t];
|
||
};
|
||
const cA = cut(pA), cB = cut(pB);
|
||
if (!cA || !cB) return null;
|
||
return [[node[0], node[1]], pA, cA, cB, pB];
|
||
}
|
||
|
||
/** Multi-ray joins stay inside this × the largest incident half-depth (#249). */
|
||
export const MULTI_WALL_JOIN_LIMIT = 1.25;
|
||
|
||
/** Maximum drafting deviation still rendered as a physical T/X junction (#279). */
|
||
export const MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES = NEAR_AXIS_MAX_DEGREES;
|
||
|
||
/** Normalized dot-product tolerance for a physically near-orthogonal ray pair. */
|
||
export const MULTI_WALL_ORTHOGONAL_DOT_EPSILON = Math.sin(
|
||
MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES * Math.PI / 180,
|
||
);
|
||
|
||
export interface MultiWallNodeRaySupport {
|
||
/** Physical half-depth owned by this finite co-directional interval. */
|
||
halfDepth: number;
|
||
/** Distance from the canonical node to the interval's real endpoint. */
|
||
length: number;
|
||
}
|
||
|
||
/** A finite shared strip attached to an incident support's far endpoint. */
|
||
export interface MultiWallNodeRayContinuation extends MultiWallNodeRaySupport {
|
||
/** Real near endpoint; it may turn instead of continuing co-directionally. */
|
||
start: [number, number];
|
||
/** Unit direction from `start` to the continuation's real far endpoint. */
|
||
u: [number, number];
|
||
}
|
||
|
||
export interface MultiWallNodeRay {
|
||
/** Unit direction from the canonical node toward the interval's other end. */
|
||
u: [number, number];
|
||
/** Largest incident half-depth at the node; used by the join formula. */
|
||
halfDepth: number;
|
||
/** Furthest real endpoint in this direction. */
|
||
length: number;
|
||
/** Non-dominated finite strips whose union is the physical ray support. */
|
||
supports: MultiWallNodeRaySupport[];
|
||
/** Shared finite strips attached at a support's far endpoint (#288). */
|
||
continuations: MultiWallNodeRayContinuation[];
|
||
}
|
||
|
||
export interface MultiWallNode {
|
||
point: [number, number];
|
||
rays: MultiWallNodeRay[];
|
||
halfDepth: number;
|
||
limit: number;
|
||
}
|
||
|
||
/** Scale-relative lookup shared by every contour producer in one structural pass. */
|
||
export interface MultiWallNodeMap {
|
||
epsilon: number;
|
||
coordinateScale: number;
|
||
nodes: MultiWallNode[];
|
||
/** Spatial buckets keep vertex lookup linear instead of scanning all nodes. */
|
||
index: Map<string, MultiWallNode[]>;
|
||
}
|
||
|
||
/** One finite physical wall centreline with its already-converted half depth. */
|
||
export interface LinearWallSegment {
|
||
a: number[];
|
||
b: number[];
|
||
halfDepth: number;
|
||
}
|
||
|
||
// ------------------------------- units --------------------------------------
|
||
|
||
/** Shared full/static render policy for the thin-on-screen fallback. */
|
||
export function wallBodyNeedsSolid(depthUnits: number, pxPerUnit: number): boolean {
|
||
return Number.isFinite(depthUnits) && depthUnits > 0
|
||
&& Number.isFinite(pxPerUnit) && pxPerUnit > 0
|
||
&& depthUnits * pxPerUnit < WALL_HATCH_MIN_PX;
|
||
}
|
||
|
||
/**
|
||
* Pattern step in plan units for a given grid scale (#230, spec §8.1).
|
||
*
|
||
* At the reference scale this returns exactly the historical 8, so plans on
|
||
* `cell_cm: 5` — every golden fixture among them — render byte for byte as
|
||
* before.
|
||
*/
|
||
export function wallHatchStepUnits(cellCm: number): number {
|
||
const c = Number(cellCm) > 0 ? Number(cellCm) : HATCH_REFERENCE_CELL_CM;
|
||
if (c === HATCH_REFERENCE_CELL_CM) return HATCH_BASE_STEP_UNITS;
|
||
const step = HATCH_BASE_STEP_UNITS * (HATCH_REFERENCE_CELL_CM / c);
|
||
return Math.min(HATCH_MAX_STEP_UNITS, Math.max(HATCH_MIN_STEP_UNITS, step));
|
||
}
|
||
|
||
/**
|
||
* Stripes too close together on screen: fill the body instead (#230, §8.4).
|
||
*
|
||
* Companion to `wallBodyNeedsSolid`, which watches the body's depth. Now that
|
||
* the step is physical it no longer shrinks with zoom, so the far end of the
|
||
* zoom range needs its own guard.
|
||
*/
|
||
export function wallHatchNeedsSolid(stepUnits: number, pxPerUnit: number): boolean {
|
||
return Number.isFinite(stepUnits) && stepUnits > 0
|
||
&& Number.isFinite(pxPerUnit) && pxPerUnit > 0
|
||
&& stepUnits * pxPerUnit < HATCH_MIN_STEP_PX;
|
||
}
|
||
|
||
export function clampWallCm(cm: number): number {
|
||
if (!Number.isFinite(cm)) return WALL_MIN_CM;
|
||
return Math.max(WALL_MIN_CM, Math.min(WALL_MAX_CM, cm));
|
||
}
|
||
|
||
/** Config cm → the thickness field (cm, or inches when HA is imperial). */
|
||
export function cmToField(cm: number, imperial: boolean): string {
|
||
if (!Number.isFinite(cm) || cm < 0) return '';
|
||
if (imperial) return String(Math.round((cm / 2.54) * 100) / 100);
|
||
return String(Math.round(cm * 100) / 100);
|
||
}
|
||
|
||
/**
|
||
* Field value → cm. Empty / non-finite / ≤0 means "remove thickness"
|
||
* (returns null). Imperial field is inches.
|
||
*/
|
||
export function fieldToCm(raw: string | number, imperial: boolean): number | null {
|
||
const v = typeof raw === 'number' ? raw : parseFloat(String(raw).trim().replace(',', '.'));
|
||
if (!Number.isFinite(v) || v <= 0) return null;
|
||
const cm = imperial ? v * 2.54 : v;
|
||
return clampWallCm(cm);
|
||
}
|
||
|
||
/** Real cm → length in the same units as the room polygon (via cell_cm). */
|
||
export function wallCmToUnits(cm: number, cellCm: number, gridPitch: number): number {
|
||
if (!Number.isFinite(cm) || cm <= 0) return 0;
|
||
const c = Number(cellCm) > 0 ? Number(cellCm) : 5;
|
||
return (clampWallCm(cm) / c) * gridPitch;
|
||
}
|
||
|
||
// ------------------------------- segment key --------------------------------
|
||
|
||
function q(v: number, pitch: number): number {
|
||
if (!(pitch > 0) || !Number.isFinite(v)) return v;
|
||
return Math.round(v / pitch) * pitch;
|
||
}
|
||
|
||
/** Storage-noise tolerance used only to stabilise wall identity near a node. */
|
||
function keyEpsilon(pitch: number): number {
|
||
return Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||
}
|
||
|
||
/**
|
||
* Treat a coordinate already within storage precision of a grid node as that
|
||
* exact node. Arbitrary off-grid geometry remains off-grid: this is identity
|
||
* canonicalisation, not an implicit geometry snap.
|
||
*/
|
||
function canonicalKeyCoordinate(v: number, pitch: number): number {
|
||
if (!(pitch > 0) || !Number.isFinite(v)) return v;
|
||
const snapped = q(v, pitch);
|
||
return Math.abs(snapped - v) <= keyEpsilon(pitch) ? snapped : v;
|
||
}
|
||
|
||
/**
|
||
* Direction of a wall, modulo 180° (a wall is the same from either end),
|
||
* as a unit vector with a stable sign (prefer +x, then +y).
|
||
*/
|
||
export function wallDir(a: number[], b: number[]): [number, number] {
|
||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const L = Math.hypot(dx, dy);
|
||
if (L < 1e-12) return [1, 0];
|
||
dx /= L; dy /= L;
|
||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
|
||
dx = -dx; dy = -dy;
|
||
}
|
||
return [dx, dy];
|
||
}
|
||
|
||
/**
|
||
* Segment key: quantised midpoint + direction. Same wall from either end,
|
||
* survives whole-grid moves when re-keyed by the resize commit.
|
||
*/
|
||
export function wallKey(a: number[], b: number[], pitch: number): string {
|
||
const ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
|
||
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
|
||
const mx = q((ca[0] + cb[0]) / 2, pitch);
|
||
const my = q((ca[1] + cb[1]) / 2, pitch);
|
||
const [dx, dy] = wallDir(ca, cb);
|
||
// angle bucket: round to ~0.1° so float noise does not fork keys
|
||
let ang = Math.atan2(dy, dx);
|
||
if (ang < 0) ang += Math.PI;
|
||
const aq = Math.round(ang * 1800) / 1800; // π rad ≈ 3.14 → 0.1° steps
|
||
const prec = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||
return `${mx.toFixed(prec)},${my.toFixed(prec)}@${aq.toFixed(4)}`;
|
||
}
|
||
|
||
/**
|
||
* Scale applied to endpoints before keying. Render-space edges use
|
||
* `coordScale = NORM_W` with `pitch = GRID_STEP_N` so keys match the
|
||
* normalised config; config-space edges use `coordScale = 1`.
|
||
*/
|
||
function keyOf(a: number[], b: number[], pitch: number, scale: number): string {
|
||
if (scale === 1) return wallKey(a, b, pitch);
|
||
return wallKey([a[0] / scale, a[1] / scale], [b[0] / scale, b[1] / scale], pitch);
|
||
}
|
||
|
||
/** Exact stored interval in the caller's coordinate space, when available. */
|
||
function entrySpan(w: WallEntry, coordScale: number): [number[], number[]] | null {
|
||
if (!Array.isArray(w.a) || !Array.isArray(w.b) || w.a.length < 2 || w.b.length < 2) return null;
|
||
const nums = [Number(w.a[0]), Number(w.a[1]), Number(w.b[0]), Number(w.b[1])];
|
||
if (!nums.every(Number.isFinite)) return null;
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
return [[nums[0] * scale, nums[1] * scale], [nums[2] * scale, nums[3] * scale]];
|
||
}
|
||
|
||
/** Persist an interval with both its compatible key and lossless endpoints. */
|
||
function wallEntry(a: number[], b: number[], cm: number, pitch: number, coordScale: number): WallEntry {
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
return {
|
||
key: keyOf(a, b, pitch, scale),
|
||
cm: clampWallCm(cm),
|
||
a: [a[0] / scale, a[1] / scale],
|
||
b: [b[0] / scale, b[1] / scale],
|
||
};
|
||
}
|
||
|
||
/** One parsed key: midpoint in the caller's coordinate space + angle bucket. */
|
||
interface ParsedKey {
|
||
w: WallEntry;
|
||
x: number;
|
||
y: number;
|
||
ang: number;
|
||
}
|
||
|
||
function parseKeys(walls: WallEntry[], coordScale: number): ParsedKey[] {
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
const out: ParsedKey[] = [];
|
||
for (const w of walls) {
|
||
const at = w.key.lastIndexOf('@');
|
||
if (at < 0) continue;
|
||
const [sx, sy] = w.key.slice(0, at).split(',').map(Number);
|
||
const aq = Number(w.key.slice(at + 1));
|
||
if (![sx, sy, aq].every(Number.isFinite)) continue;
|
||
out.push({ w, x: sx * scale, y: sy * scale, ang: aq });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Direction of a segment as a 0..π bucket, matching the key's angle field. */
|
||
function segAngle(a: number[], b: number[]): number {
|
||
const [dx, dy] = wallDir(a, b);
|
||
let ang = Math.atan2(dy, dx);
|
||
if (ang < 0) ang += Math.PI;
|
||
return ang;
|
||
}
|
||
|
||
function angleClose(x: number, y: number): boolean {
|
||
let d = Math.abs(x - y);
|
||
if (d > Math.PI / 2) d = Math.PI - d;
|
||
return d < 0.02; // ~1°
|
||
}
|
||
|
||
/**
|
||
* Match within half a grid step on the midpoint (direction must agree).
|
||
*
|
||
* AUD-159B6-01: this used to also accept a key whose midpoint merely LAY
|
||
* SOMEWHERE on the queried segment, so 30 cm set on a 4-unit shared stretch
|
||
* was reported for the whole 10-unit edge that contains it and the thickness
|
||
* visibly leaked past the physical wall. A key now identifies ONE stretch;
|
||
* callers query atomic intervals (see wallIntervals) and old whole-edge keys
|
||
* are resolved separately, per parent edge, in cmsForPoly().
|
||
*/
|
||
export function lookupWall(
|
||
walls: WallEntry[] | null | undefined,
|
||
a: number[], b: number[],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
): WallEntry | null {
|
||
if (!walls?.length) return null;
|
||
const want = keyOf(a, b, pitch, coordScale);
|
||
const hit = walls.find((w) => w.key === want);
|
||
if (hit) return hit;
|
||
// A lossless entry can prove that it names this exact physical stretch even
|
||
// when an older midpoint key landed on the other side of a rounding tie.
|
||
// This is deliberately same-span only: parent containment remains the
|
||
// separate exactCoveringWall/cmsForPoly compatibility contract.
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
const exactEps = keyEpsilon(pitch) * scale;
|
||
const closePoint = (x: number[], y: number[]): boolean => (
|
||
Math.abs(x[0] - y[0]) <= exactEps && Math.abs(x[1] - y[1]) <= exactEps
|
||
);
|
||
for (const wall of walls) {
|
||
const span = entrySpan(wall, scale);
|
||
if (!span) continue;
|
||
if ((closePoint(span[0], a) && closePoint(span[1], b))
|
||
|| (closePoint(span[0], b) && closePoint(span[1], a))) return wall;
|
||
}
|
||
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
|
||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||
const ang = segAngle(a, b);
|
||
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
|
||
for (const e of parseKeys(walls, scale)) {
|
||
if (!angleClose(e.ang, ang)) continue;
|
||
if (Math.hypot(e.x - mx, e.y - my) <= tol) return e.w;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function thicknessCmAt(
|
||
walls: WallEntry[] | null | undefined,
|
||
a: number[], b: number[],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
): number {
|
||
const e = lookupWall(walls, a, b, pitch, coordScale);
|
||
if (e && e.cm > 0) return clampWallCm(e.cm);
|
||
const exact = exactCoveringWall(walls, a, b, pitch, coordScale);
|
||
return exact ? clampWallCm(exact.cm) : 0;
|
||
}
|
||
|
||
/**
|
||
* Lossless parent-run fallback for an atomic child query.
|
||
*
|
||
* `lookupWall` intentionally keeps the narrow "one key = one stretch"
|
||
* contract (AUD-159B6-01). Closing a virtual span, however, asks about the
|
||
* atomic solid children around it while persisted exact endpoints may describe
|
||
* their longer parent run. Exact endpoints can prove containment without
|
||
* broadening the ambiguous legacy key-only fallback.
|
||
*/
|
||
function exactCoveringWall(
|
||
walls: WallEntry[] | null | undefined,
|
||
a: number[], b: number[],
|
||
pitch: number,
|
||
coordScale: number,
|
||
): WallEntry | null {
|
||
if (!walls?.length) return null;
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
const queryLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||
if (queryLen < 1e-12) return null;
|
||
const queryAngle = segAngle(a, b);
|
||
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
|
||
let best: { wall: WallEntry; extra: number; stable: string } | null = null;
|
||
for (const wall of walls) {
|
||
if (!(wall.cm > 0)) continue;
|
||
const span = entrySpan(wall, scale);
|
||
if (!span) continue;
|
||
const spanLen = Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]);
|
||
if (spanLen < 1e-12 || !angleClose(segAngle(span[0], span[1]), queryAngle)) continue;
|
||
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|
||
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
|
||
// A shorter span may sit within endpoint tolerance but cannot prove that
|
||
// it covers the query. Keep that tolerance scale-relative, like lookup.
|
||
if (spanLen + tol < queryLen) continue;
|
||
const extra = Math.max(0, spanLen - queryLen);
|
||
const stable = `${wall.key}|${clampWallCm(wall.cm)}|${span.flat().join(',')}`;
|
||
if (!best || extra < best.extra - 1e-12
|
||
|| (Math.abs(extra - best.extra) <= 1e-12 && stable < best.stable)) {
|
||
best = { wall, extra, stable };
|
||
}
|
||
}
|
||
return best?.wall || null;
|
||
}
|
||
|
||
/**
|
||
* Drop entries whose key matches no current wall stretch.
|
||
*
|
||
* "Stretch" means an ATOMIC interval (AUD-159B6-01): whole polygon edges,
|
||
* shared overlaps AND the pieces an open span cuts an edge into — the last of
|
||
* which is where a legitimately split thickness lives, so leaving them out
|
||
* would delete the solid remainder of a partially opened wall on the next save.
|
||
*/
|
||
export function degradeWalls(
|
||
walls: WallEntry[] | null | undefined,
|
||
rooms: any[],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
openCuts: number[][] = [],
|
||
): WallEntry[] {
|
||
if (!walls?.length) return [];
|
||
const live = new Set<string>();
|
||
const edges = roomEdges(rooms);
|
||
for (const seg of edges) {
|
||
live.add(keyOf([seg[0], seg[1]], [seg[2], seg[3]], pitch, coordScale));
|
||
}
|
||
// partial shared overlaps are keyed by their own mid — keep those too
|
||
const list = rooms || [];
|
||
const eps = Math.max(pitch * coordScale * 0.02, 1e-9);
|
||
for (let i = 0; i < list.length; i++) {
|
||
const pa = roomPoly(list[i]);
|
||
if (!pa) continue;
|
||
for (let j = i + 1; j < list.length; j++) {
|
||
const pb = roomPoly(list[j]);
|
||
if (!pb) continue;
|
||
for (const sg of sharedBoundary(pa, pb, eps)) {
|
||
live.add(keyOf([sg[0], sg[1]], [sg[2], sg[3]], pitch, coordScale));
|
||
}
|
||
}
|
||
}
|
||
for (const room of list) {
|
||
if (!room?.id) continue;
|
||
const at = atomicPolyForRoom(list, room.id, openCuts, pitch, coordScale, walls);
|
||
if (!at) continue;
|
||
for (let i = 0; i < at.poly.length; i++) {
|
||
live.add(keyOf(at.poly[i], at.poly[(i + 1) % at.poly.length], pitch, coordScale));
|
||
}
|
||
}
|
||
const exactStillLive = (w: WallEntry): boolean => {
|
||
const span = entrySpan(w, coordScale);
|
||
if (!span) return false;
|
||
const [a, b] = span;
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const L = Math.hypot(dx, dy);
|
||
if (L <= eps) return false;
|
||
const onCurrentEdge = edges.some((sg) => {
|
||
const ea = [sg[0], sg[1]], eb = [sg[2], sg[3]];
|
||
return angleClose(segAngle(a, b), segAngle(ea, eb))
|
||
&& distToSeg(a[0], a[1], ea[0], ea[1], eb[0], eb[1]) <= eps
|
||
&& distToSeg(b[0], b[1], ea[0], ea[1], eb[0], eb[1]) <= eps;
|
||
});
|
||
if (!onCurrentEdge) return false;
|
||
// A stored solid interval must not straddle a newly virtual piece.
|
||
const overlapsCut = (openCuts || []).some((c) => {
|
||
const ca = [c[0], c[1]], cb = [c[2], c[3]];
|
||
if (!angleClose(segAngle(a, b), segAngle(ca, cb))) return false;
|
||
const lineDist = (p: number[]) => Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / L;
|
||
if (lineDist(ca) > eps || lineDist(cb) > eps) return false;
|
||
const L2 = L * L;
|
||
const t0 = ((ca[0] - a[0]) * dx + (ca[1] - a[1]) * dy) / L2;
|
||
const t1 = ((cb[0] - a[0]) * dx + (cb[1] - a[1]) * dy) / L2;
|
||
return Math.min(1, Math.max(t0, t1)) - Math.max(0, Math.min(t0, t1)) > eps / L;
|
||
});
|
||
return !overlapsCut;
|
||
};
|
||
return walls.filter((w) => (live.has(w.key) || exactStillLive(w))
|
||
&& w.cm >= WALL_MIN_CM && w.cm <= WALL_MAX_CM);
|
||
}
|
||
|
||
/**
|
||
* Wall direction vs opening angle (both mod 180°). Used so a T-junction
|
||
* opening does not bind to the perpendicular receiving wall.
|
||
*/
|
||
export function wallAngleMatches(
|
||
a: number[], b: number[],
|
||
openingAngleDeg: number,
|
||
tolDeg = 8,
|
||
): boolean {
|
||
const [dx, dy] = wallDir(a, b);
|
||
let wang = Math.atan2(dy, dx);
|
||
if (wang < 0) wang += Math.PI;
|
||
let oang = ((openingAngleDeg * Math.PI) / 180) % Math.PI;
|
||
if (oang < 0) oang += Math.PI;
|
||
let d = Math.abs(wang - oang);
|
||
if (d > Math.PI / 2) d = Math.PI - d;
|
||
return d <= (tolDeg * Math.PI) / 180;
|
||
}
|
||
|
||
/**
|
||
* After an edge drag: rewrite keys whose old span mid/dir map to a moved
|
||
* stretch. `oldSpans` / `newSpans` are parallel lists of [a,b] endpoints.
|
||
*
|
||
* A stored key may name either the whole polygon edge or one atomic remainder
|
||
* left by a partial shared/open stretch. The latter has a different midpoint,
|
||
* 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 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) {
|
||
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);
|
||
type Move = {
|
||
oa: number[]; ob: number[]; na: number[]; nb: number[];
|
||
dx: number; dy: number; len2: number;
|
||
};
|
||
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];
|
||
if (![oa?.[0], oa?.[1], ob?.[0], ob?.[1], na?.[0], na?.[1], nb?.[0], nb?.[1]]
|
||
.every(Number.isFinite)) continue;
|
||
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
|
||
const len2 = dx * dx + dy * dy;
|
||
if (len2 < 1e-18) continue;
|
||
// Unchanged polygon edges are context, not split points. Including them
|
||
// would atomise every long wall on every preview even when no part moved.
|
||
if (Math.max(Math.hypot(na[0] - oa[0], na[1] - oa[1]),
|
||
Math.hypot(nb[0] - ob[0], nb[1] - ob[1])) <= exactEps) continue;
|
||
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) {
|
||
addKeyMove(keyMoves, ok, nk);
|
||
}
|
||
}
|
||
if (!moves.length) return walls.slice();
|
||
|
||
const pointAt = (a: number[], b: number[], t: number): number[] => [
|
||
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 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
|
||
? [[...a], [...b]] : [[...b], [...a]];
|
||
};
|
||
|
||
const out: WallEntry[] = [];
|
||
const exactOut: { entry: WallEntry; span: [number[], number[]] }[] = [];
|
||
const pushExact = (a: number[], b: number[], cm: number): void => {
|
||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) <= exactEps) return;
|
||
const [ca, cb] = canonicalSpan(a, b);
|
||
const value = clampWallCm(cm);
|
||
const duplicate = exactOut.some((candidate) => candidate.entry.cm === value
|
||
&& closePoint(candidate.span[0], ca) && closePoint(candidate.span[1], cb));
|
||
if (duplicate) return;
|
||
const entry = wallEntry(ca, cb, value, pitch, scale);
|
||
out.push(entry);
|
||
exactOut.push({ entry, span: [ca, cb] });
|
||
};
|
||
|
||
for (const w of walls) {
|
||
// Exact endpoints are authoritative for new entries. Never move only their
|
||
// compatibility key while leaving a/b behind on the old wall. A record
|
||
// may be longer than the moved room edge, so partition it at every overlap
|
||
// boundary and transform the covered atoms from this immutable source.
|
||
const exact = entrySpan(w, scale);
|
||
if (exact) {
|
||
const [wa, wb] = canonicalSpan(exact[0], exact[1]);
|
||
const wx = wb[0] - wa[0], wy = wb[1] - wa[1];
|
||
const wallLen2 = wx * wx + wy * wy;
|
||
const wallLen = Math.sqrt(wallLen2);
|
||
if (wallLen <= exactEps) {
|
||
out.push({ ...w, cm: clampWallCm(w.cm) });
|
||
continue;
|
||
}
|
||
|
||
type Overlap = { lo: number; hi: number; move: Move };
|
||
const overlaps: Overlap[] = [];
|
||
for (const move of moves) {
|
||
if (!angleClose(segAngle(wa, wb), segAngle(move.oa, move.ob))) continue;
|
||
const lineDistance = (p: number[]): number =>
|
||
Math.abs((p[0] - wa[0]) * wy - (p[1] - wa[1]) * wx) / wallLen;
|
||
if (lineDistance(move.oa) > tol || lineDistance(move.ob) > tol) continue;
|
||
const ta = ((move.oa[0] - wa[0]) * wx + (move.oa[1] - wa[1]) * wy) / wallLen2;
|
||
const tb = ((move.ob[0] - wa[0]) * wx + (move.ob[1] - wa[1]) * wy) / wallLen2;
|
||
const lo = Math.max(0, Math.min(ta, tb));
|
||
const hi = Math.min(1, Math.max(ta, tb));
|
||
if ((hi - lo) * wallLen > exactEps) overlaps.push({ lo, hi, move });
|
||
}
|
||
|
||
if (!overlaps.length) {
|
||
// 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;
|
||
}
|
||
|
||
const bounds = [0, 1, ...overlaps.flatMap(({ lo, hi }) => [lo, hi])]
|
||
.sort((a, b) => a - b)
|
||
.filter((value, index, list) => index === 0
|
||
|| Math.abs(value - list[index - 1]) * wallLen > exactEps);
|
||
const mappedAtoms: [number[], number[]][] = [];
|
||
for (let i = 0; i + 1 < bounds.length; i++) {
|
||
const lo = bounds[i], hi = bounds[i + 1];
|
||
if ((hi - lo) * wallLen <= exactEps) continue;
|
||
const a = pointAt(wa, wb, lo), b = pointAt(wa, wb, hi);
|
||
const mid = (lo + hi) / 2;
|
||
const candidates = overlaps.filter((overlap) =>
|
||
mid >= overlap.lo - 1e-12 && mid <= overlap.hi + 1e-12);
|
||
if (!candidates.length) {
|
||
mappedAtoms.push([a, b]);
|
||
continue;
|
||
}
|
||
const first: [number[], number[]] = [
|
||
mapPoint(a, candidates[0].move), mapPoint(b, candidates[0].move),
|
||
];
|
||
const conflict = candidates.slice(1).some((candidate) => {
|
||
const ca = mapPoint(a, candidate.move), cb = mapPoint(b, candidate.move);
|
||
return !closePoint(first[0], ca) || !closePoint(first[1], cb);
|
||
});
|
||
// Conflicting room transforms are invalid planner input. Preserve the
|
||
// source atom rather than selecting by array order or losing masonry.
|
||
mappedAtoms.push(conflict ? [a, b] : first);
|
||
}
|
||
|
||
// Two rooms on opposite sides of one shared seam contribute separate
|
||
// side-edge moves. Moving the seam changes their meeting point, but the
|
||
// physical wall covering both side edges is still one straight,
|
||
// continuous record. Reassemble only atoms that meet exactly and stay
|
||
// collinear; a real partial perpendicular move still leaves disjoint or
|
||
// angled atoms and therefore keeps the lossless split from #253.
|
||
const collinearForward = (left: [number[], number[]], right: [number[], number[]]): boolean => {
|
||
const ldx = left[1][0] - left[0][0], ldy = left[1][1] - left[0][1];
|
||
const rdx = right[1][0] - right[0][0], rdy = right[1][1] - right[0][1];
|
||
const leftLength = Math.hypot(ldx, ldy);
|
||
if (leftLength <= exactEps || ldx * rdx + ldy * rdy <= 0) return false;
|
||
return Math.abs(ldx * rdy - ldy * rdx) / leftLength <= exactEps;
|
||
};
|
||
const coalesced: [number[], number[]][] = [];
|
||
for (const atom of mappedAtoms) {
|
||
const previous = coalesced[coalesced.length - 1];
|
||
if (previous && closePoint(previous[1], atom[0])
|
||
&& collinearForward(previous, atom)) {
|
||
previous[1] = atom[1];
|
||
} else {
|
||
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);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Legacy entries carry only a midpoint/direction key, so they cannot be
|
||
// 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];
|
||
if (!nk) {
|
||
const parsed = parseKeys([w], scale)[0];
|
||
if (parsed) {
|
||
const targets = new Set<string>();
|
||
for (const move of moves) {
|
||
if (!angleClose(parsed.ang, segAngle(move.oa, move.ob))) continue;
|
||
const t = ((parsed.x - move.oa[0]) * move.dx
|
||
+ (parsed.y - move.oa[1]) * move.dy) / move.len2;
|
||
if (t < -1e-6 || t > 1 + 1e-6) continue;
|
||
if (distToSeg(parsed.x, parsed.y,
|
||
move.oa[0], move.oa[1], move.ob[0], move.ob[1]) > tol) continue;
|
||
const at = mapPoint([parsed.x, parsed.y], move);
|
||
const [ux, uy] = wallDir(move.na, move.nb);
|
||
const arm = Math.max(pitch * scale, 1e-6);
|
||
targets.add(keyOf(
|
||
[at[0] - ux * arm, at[1] - uy * arm],
|
||
[at[0] + ux * arm, at[1] + uy * arm],
|
||
pitch, scale,
|
||
));
|
||
}
|
||
if (targets.size === 1) nk = [...targets][0];
|
||
}
|
||
}
|
||
out.push({ ...w, key: nk || w.key, cm: clampWallCm(w.cm) });
|
||
}
|
||
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,
|
||
a: number[], b: number[],
|
||
cm: number | null,
|
||
pitch: number,
|
||
coordScale = 1,
|
||
): WallEntry[] {
|
||
const key = keyOf(a, b, pitch, coordScale);
|
||
const base = (walls || []).filter((w) => w.key !== key);
|
||
if (cm == null || cm < WALL_MIN_CM) return base;
|
||
return [...base, wallEntry(a, b, cm, pitch, coordScale)];
|
||
}
|
||
|
||
/**
|
||
* Every atomic stretch of one room that may carry a thickness (open ones are
|
||
* excluded). The unit of a wall is the interval, not the polygon edge.
|
||
*/
|
||
export function solidIntervalsForRoom(
|
||
rooms: any[],
|
||
roomId: string,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
wallBreaks: WallEntry[] | null | undefined = [],
|
||
): Array<{ a: number[]; b: number[] }> {
|
||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, wallBreaks);
|
||
if (!at) return [];
|
||
const out: Array<{ a: number[]; b: number[] }> = [];
|
||
for (let i = 0; i < at.poly.length; i++) {
|
||
const a = at.poly[i], b = at.poly[(i + 1) % at.poly.length];
|
||
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) continue;
|
||
out.push({ a, b });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Apply one thickness to every atomic stretch of a room that is allowed to
|
||
* carry one (skips open-boundary stretches listed in `openCuts`).
|
||
*/
|
||
export function setWallThicknessForRoom(
|
||
walls: WallEntry[] | null | undefined,
|
||
rooms: any[],
|
||
roomId: string,
|
||
cm: number | null,
|
||
pitch: number,
|
||
openCuts: number[][] = [],
|
||
coordScale = 1,
|
||
): WallEntry[] {
|
||
let out = walls ? walls.slice() : [];
|
||
for (const iv of solidIntervalsForRoom(rooms, roomId, openCuts, pitch, coordScale, out)) {
|
||
out = setWallThickness(out, iv.a, iv.b, cm, pitch, coordScale);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* After drawing a new room: set session thickness on stretches that do not yet
|
||
* have one. Shared stretches that already carry a neighbour's cm are left
|
||
* alone (docs/WALL-THICKNESS.md — one physical wall, one thickness).
|
||
*/
|
||
export function applyWallThicknessToNewRoom(
|
||
walls: WallEntry[] | null | undefined,
|
||
rooms: any[],
|
||
roomId: string,
|
||
cm: number | null,
|
||
pitch: number,
|
||
openCuts: number[][] = [],
|
||
coordScale = 1,
|
||
): WallEntry[] {
|
||
if (cm == null || cm < WALL_MIN_CM) return walls ? walls.slice() : [];
|
||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
|
||
if (!at) return walls ? walls.slice() : [];
|
||
// effective cm per interval — a neighbour's thickness counts even when it is
|
||
// still stored under a pre-atomic whole-edge key
|
||
const cms = cmsForPoly(walls, at, pitch, coordScale);
|
||
let out = walls ? walls.slice() : [];
|
||
for (let i = 0; i < at.poly.length; i++) {
|
||
const a = at.poly[i], b = at.poly[(i + 1) % at.poly.length];
|
||
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) continue;
|
||
if (cms[i] > 0) continue;
|
||
out = setWallThickness(out, a, b, cm, pitch, coordScale);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A flat-capped body for one already-scaled centreline segment. */
|
||
export function linearWallBody(segment: LinearWallSegment): number[][] | null {
|
||
const { a, b, halfDepth } = segment;
|
||
if (!Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2
|
||
|| ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) return null;
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const len = Math.hypot(dx, dy);
|
||
if (!(len > 1e-9) || !(halfDepth > 0) || !Number.isFinite(halfDepth)) return null;
|
||
const nx = (-dy / len) * halfDepth, ny = (dx / len) * halfDepth;
|
||
return [
|
||
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
|
||
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
|
||
];
|
||
}
|
||
|
||
interface JunctionRay {
|
||
u: [number, number];
|
||
halfDepth: number;
|
||
}
|
||
|
||
function closePoint(a: number[], b: number[], epsilon: number): boolean {
|
||
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon;
|
||
}
|
||
|
||
function pointOnSegmentInterior(
|
||
point: number[], segment: LinearWallSegment, epsilon: number,
|
||
): boolean {
|
||
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
|
||
const len2 = dx * dx + dy * dy;
|
||
if (!(len2 > epsilon * epsilon)) return false;
|
||
const t = ((point[0] - segment.a[0]) * dx + (point[1] - segment.a[1]) * dy) / len2;
|
||
if (!(t > 0 && t < 1)) return false;
|
||
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
|
||
return Math.hypot(point[0] - q[0], point[1] - q[1]) <= epsilon;
|
||
}
|
||
|
||
function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth: number): void {
|
||
const len = Math.hypot(dx, dy);
|
||
if (!(len > 1e-9) || !(halfDepth > 0)) return;
|
||
const u: [number, number] = [dx / len, dy / len];
|
||
const same = rays.find((ray) =>
|
||
Math.abs(ray.u[0] * u[1] - ray.u[1] * u[0]) < 1e-9
|
||
&& ray.u[0] * u[0] + ray.u[1] * u[1] > 1 - 1e-9);
|
||
if (same) same.halfDepth = Math.max(same.halfDepth, halfDepth);
|
||
else rays.push({ u, halfDepth });
|
||
}
|
||
|
||
/**
|
||
* Missing node volumes for flat-capped linear wall segments.
|
||
*
|
||
* Endpoints are the only nodes. An endpoint may also land in another segment's
|
||
* interior (the non-persisted T produced by #137); that through segment then
|
||
* contributes two incident rays. Each non-collinear ray pair receives the
|
||
* same bounded mitre/bevel used by room contours. Unioning these patches with
|
||
* the raw bodies removes the tooth without changing caps at degree-one nodes.
|
||
*/
|
||
/** #310: subtract one butt-end wedge from a simple body (largest ring wins). */
|
||
function clipBodyByWedge(body: number[][], wedge: number[][]): number[][] | null {
|
||
try {
|
||
const result: any = difference(
|
||
closedRing(body) as any, closedRing(wedge) as any,
|
||
);
|
||
let best: number[][] | null = null;
|
||
let bestArea = 0;
|
||
for (const polygon of result || []) {
|
||
const ring = (polygon?.[0] || []) as number[][];
|
||
const area = Math.abs(signedArea(ring));
|
||
if (ring.length >= 4 && area > bestArea) {
|
||
bestArea = area;
|
||
best = ring.slice(0, -1).map((point) => [point[0], point[1]]);
|
||
}
|
||
}
|
||
return best;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Butt-end trim of a two-ray node (#310, owner decision). With the full pair
|
||
* mitre restored, the rectangular butt end of the deeper wall can still poke
|
||
* sideways past the outer face of its thinner partner right at the node — the
|
||
* «tooth sticking out of the thin wall» of the owner report. For every
|
||
* two-ray node with an accepted mitre this returns, per input segment, the
|
||
* wedges to subtract: the part of that segment's body OUTSIDE the partner's
|
||
* outer face and within 2·halfDepth of the node along the segment's axis.
|
||
* The rule is symmetric; for the thinner wall the wedge is empty. This is the
|
||
* SECOND addressed subtraction of the junction pipeline, next to the lateral
|
||
* trim of #271 — both strictly local to their node.
|
||
*/
|
||
export function pairButtEndTrimWedges(
|
||
input: LinearWallSegment[], epsilon = 1e-6,
|
||
): { segmentIndex: number; wedge: number[][] }[] {
|
||
const segments = (input || []).map((segment, index) => ({ segment, index }))
|
||
.filter(({ segment }) =>
|
||
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
|
||
&& segment.a.length >= 2 && segment.b.length >= 2
|
||
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
|
||
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
|
||
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
|
||
if (segments.length < 2) return [];
|
||
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
|
||
const endpoints = segments.flatMap(({ segment }) => [segment.a, segment.b])
|
||
.map((point) => [point[0], point[1]])
|
||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
const nodes: number[][] = [];
|
||
for (const point of endpoints) {
|
||
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
|
||
}
|
||
const out: { segmentIndex: number; wedge: number[][] }[] = [];
|
||
for (const node of nodes) {
|
||
// Endpoint rays only: an interior (T) hit makes the node degree-3+ and
|
||
// the fans of the multi-wall machinery own it, not the pair mitre.
|
||
const rays: { u: number[]; halfDepth: number; length: number; index: number }[] = [];
|
||
let interior = false;
|
||
for (const { segment, index } of segments) {
|
||
const length = Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]);
|
||
if (closePoint(node, segment.a, eps)) {
|
||
rays.push({ u: [(segment.b[0] - segment.a[0]) / length,
|
||
(segment.b[1] - segment.a[1]) / length], halfDepth: segment.halfDepth, length, index });
|
||
} else if (closePoint(node, segment.b, eps)) {
|
||
rays.push({ u: [(segment.a[0] - segment.b[0]) / length,
|
||
(segment.a[1] - segment.b[1]) / length], halfDepth: segment.halfDepth, length, index });
|
||
} else if (pointOnSegmentInterior(node, segment, eps)) {
|
||
interior = true;
|
||
}
|
||
}
|
||
if (interior || rays.length !== 2) continue;
|
||
const [a, b] = rays;
|
||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||
if (Math.abs(cross) < 1e-9) continue;
|
||
const sign = cross < 0 ? 1 : -1;
|
||
const nA = [-a.u[1], a.u[0]];
|
||
const nB = [-b.u[1], b.u[0]];
|
||
const pA = [node[0] + nA[0] * a.halfDepth * sign, node[1] + nA[1] * a.halfDepth * sign];
|
||
const pB = [node[0] - nB[0] * b.halfDepth * sign, node[1] - nB[1] * b.halfDepth * sign];
|
||
if (!lineIntersect(pA, a.u, pB, b.u)) continue; // no mitre — nothing pokes
|
||
// For each wall: clip its near-node body rectangle by the OUTSIDE
|
||
// half-plane of the partner's outer face (the face owning the apex side).
|
||
const pairs: [typeof a, typeof b, number[], number[]][] = [
|
||
[a, b, pB, [nB[0] * -sign, nB[1] * -sign]],
|
||
[b, a, pA, [nA[0] * sign, nA[1] * sign]],
|
||
];
|
||
for (const [self, , faceP, faceOut] of pairs) {
|
||
const reach = Math.min(2 * self.halfDepth, self.length);
|
||
const ex = [-self.u[1] * self.halfDepth, self.u[0] * self.halfDepth];
|
||
const rect = [
|
||
[node[0] + ex[0], node[1] + ex[1]],
|
||
[node[0] + self.u[0] * reach + ex[0], node[1] + self.u[1] * reach + ex[1]],
|
||
[node[0] + self.u[0] * reach - ex[0], node[1] + self.u[1] * reach - ex[1]],
|
||
[node[0] - ex[0], node[1] - ex[1]],
|
||
];
|
||
// Sutherland–Hodgman clip of the rectangle by dot(x - faceP, faceOut) >= 0.
|
||
const side = (point: number[]): number =>
|
||
(point[0] - faceP[0]) * faceOut[0] + (point[1] - faceP[1]) * faceOut[1];
|
||
const clipped: number[][] = [];
|
||
for (let i = 0; i < rect.length; i++) {
|
||
const cur = rect[i], nxt = rect[(i + 1) % rect.length];
|
||
const sc = side(cur), sn = side(nxt);
|
||
if (sc >= -1e-12) clipped.push(cur);
|
||
if ((sc > 1e-12 && sn < -1e-12) || (sc < -1e-12 && sn > 1e-12)) {
|
||
const t = sc / (sc - sn);
|
||
clipped.push([cur[0] + (nxt[0] - cur[0]) * t, cur[1] + (nxt[1] - cur[1]) * t]);
|
||
}
|
||
}
|
||
if (clipped.length >= 3 && Math.abs(signedArea(clipped)) > eps * eps) {
|
||
out.push({ segmentIndex: self.index, wedge: clipped });
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function linearWallJoinPatches(
|
||
input: LinearWallSegment[], epsilon = 1e-6,
|
||
): number[][][] {
|
||
const segments = (input || []).filter((segment) =>
|
||
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
|
||
&& segment.a.length >= 2 && segment.b.length >= 2
|
||
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
|
||
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
|
||
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
|
||
if (segments.length < 2) return [];
|
||
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
|
||
const endpoints = segments.flatMap((segment) => [segment.a, segment.b])
|
||
.map((point) => [point[0], point[1]])
|
||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
const nodes: number[][] = [];
|
||
for (const point of endpoints) {
|
||
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
|
||
}
|
||
|
||
const patches: number[][][] = [];
|
||
// #309: a node of three or more canonical rays is closed with the sector
|
||
// fans of the multi-wall junction machinery (visual mitre limit included)
|
||
// instead of pair patches. A pair patch lives in the sector OPPOSITE its
|
||
// pair and, at such a node, paints a step over the thinner strips that own
|
||
// that sector (owner report: the 15/15/30/30 cross).
|
||
const intervals: WallInterval[] = segments.map((segment, i) => ({
|
||
roomId: '', a: [segment.a[0], segment.a[1]], b: [segment.b[0], segment.b[1]],
|
||
key: `join-${i}`, kind: 'outer', cm: 0, open: false,
|
||
half: segment.halfDepth,
|
||
}));
|
||
const multiWallNodes = buildMultiWallNodeMap(intervals, eps);
|
||
for (const fan of junctionNodeGeometry(multiWallNodes).fans) patches.push(fan);
|
||
const coveredByFans = (point: number[]): boolean =>
|
||
!!multiWallNodeAt(multiWallNodes, point);
|
||
for (const node of nodes) {
|
||
if (coveredByFans(node)) continue;
|
||
const rays: JunctionRay[] = [];
|
||
for (const segment of segments) {
|
||
if (closePoint(node, segment.a, eps)) {
|
||
addJunctionRay(
|
||
rays, segment.b[0] - segment.a[0], segment.b[1] - segment.a[1],
|
||
segment.halfDepth,
|
||
);
|
||
} else if (closePoint(node, segment.b, eps)) {
|
||
addJunctionRay(
|
||
rays, segment.a[0] - segment.b[0], segment.a[1] - segment.b[1],
|
||
segment.halfDepth,
|
||
);
|
||
} else if (pointOnSegmentInterior(node, segment, eps)) {
|
||
addJunctionRay(
|
||
rays, segment.a[0] - node[0], segment.a[1] - node[1], segment.halfDepth,
|
||
);
|
||
addJunctionRay(
|
||
rays, segment.b[0] - node[0], segment.b[1] - node[1], segment.halfDepth,
|
||
);
|
||
}
|
||
}
|
||
if (rays.length < 2) continue;
|
||
rays.sort((a, b) => Math.atan2(a.u[1], a.u[0]) - Math.atan2(b.u[1], b.u[0])
|
||
|| a.halfDepth - b.halfDepth);
|
||
for (let i = 0; i < rays.length; i++) {
|
||
for (let j = i + 1; j < rays.length; j++) {
|
||
const a = rays[i], b = rays[j];
|
||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||
if (Math.abs(cross) < 1e-9) continue;
|
||
const nA = [-a.u[1], a.u[0]];
|
||
const nB = [-b.u[1], b.u[0]];
|
||
const sign = cross < 0 ? 1 : -1;
|
||
const pA = [
|
||
node[0] + nA[0] * a.halfDepth * sign,
|
||
node[1] + nA[1] * a.halfDepth * sign,
|
||
];
|
||
const pB = [
|
||
node[0] - nB[0] * b.halfDepth * sign,
|
||
node[1] - nB[1] * b.halfDepth * sign,
|
||
];
|
||
const hit = lineIntersect(pA, a.u, pB, b.u);
|
||
// #310 (owner decision): a node of exactly two rays keeps the FULL
|
||
// mitre — two walls meet in a point like on a drawing. The #309
|
||
// chamfer applies only to the fans of >=3-ray nodes above.
|
||
const patch = hit
|
||
? [node.slice(), pA, hit, pB]
|
||
: [node.slice(), pA, pB];
|
||
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
|
||
}
|
||
}
|
||
}
|
||
return patches;
|
||
}
|
||
|
||
function unionSimpleBodies(bodies: number[][][]): any | null {
|
||
let geom: any = null;
|
||
try {
|
||
for (const body of bodies) {
|
||
if (body.length < 3) continue;
|
||
const piece: any = closedRing(body);
|
||
// Keep the same MultiPolygon shape for one body and for a union. Returning
|
||
// the bare Polygon made `polyclipToPathD()` see points where it expects
|
||
// rings, so every single-segment preview (including Thickness hover)
|
||
// became an empty path.
|
||
geom = geom ? union(geom, piece) : [piece];
|
||
}
|
||
return geom;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* SVG path for the thick-wall preview while drawing a room outline.
|
||
* Closed contours use outset−inset; open polylines use the same bounded joins
|
||
* as persisted independent walls. `segmentHalfDepths` preserves the thickness
|
||
* already committed for each draft segment while the last rubber-band uses the
|
||
* current session value.
|
||
*/
|
||
export function drawWallPreviewD(
|
||
pts: number[][],
|
||
halfDepth: number,
|
||
closed: boolean,
|
||
segmentHalfDepths?: number[],
|
||
): string {
|
||
if (!(halfDepth > 0) || !pts || pts.length < 2) return '';
|
||
if (closed && pts.length >= 3) {
|
||
let poly = pts;
|
||
const last = pts[pts.length - 1];
|
||
if (pts.length >= 4
|
||
&& Math.hypot(pts[0][0] - last[0], pts[0][1] - last[1]) < 1e-9) {
|
||
poly = pts.slice(0, -1);
|
||
}
|
||
if (poly.length >= 3) {
|
||
const offs = poly.map((_, i) => segmentHalfDepths?.[i] || halfDepth);
|
||
const outset = outsetContour(poly, offs);
|
||
const inset = insetContour(poly, offs);
|
||
if (outset && inset) {
|
||
return `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
|
||
}
|
||
}
|
||
}
|
||
const segments: LinearWallSegment[] = [];
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
const a = pts[i], b = pts[i + 1];
|
||
const h = segmentHalfDepths?.[i] || halfDepth;
|
||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
|
||
segments.push({ a, b, halfDepth: h });
|
||
}
|
||
// Index-aligned with `segments`: the #310 wedge below addresses its owner
|
||
// body by segment index, so the null filter happens only at the join.
|
||
const bodies = segments.map(linearWallBody);
|
||
// #310: the preview shares the butt-end trim with persisted masonry, so the
|
||
// rubber-band silhouette matches what the click will save.
|
||
for (const { segmentIndex, wedge } of pairButtEndTrimWedges(segments)) {
|
||
const body = bodies[segmentIndex];
|
||
if (!body) continue;
|
||
const trimmed = clipBodyByWedge(body, wedge);
|
||
if (trimmed) bodies[segmentIndex] = trimmed;
|
||
}
|
||
const joined = [
|
||
...bodies.filter((body): body is number[][] => !!body),
|
||
...linearWallJoinPatches(segments),
|
||
];
|
||
const geom = unionSimpleBodies(joined);
|
||
if (geom) return polyclipToPathD(geom);
|
||
return joined.map((body) => polyToPath(body)).join(' ');
|
||
}
|
||
|
||
/**
|
||
* Is this stretch virtual? Interval-exact (AUD-159B6-01): the midpoint must sit
|
||
* ON the cut, not merely near the cut's own midpoint. Atomic intervals never
|
||
* straddle a cut end (they are split there), so the test is unambiguous —
|
||
* a partial open span no longer has to cover the parent edge's midpoint to
|
||
* count, and no longer opens the parts it does not cover.
|
||
*/
|
||
function edgeIsOpen(a: number[], b: number[], cuts: number[][], pitch: number, coordScale = 1): boolean {
|
||
if (!cuts.length) return false;
|
||
const eps = openEps(pitch, coordScale);
|
||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||
const [dx, dy] = wallDir(a, b);
|
||
for (const c of cuts) {
|
||
const [ex, ey] = wallDir([c[0], c[1]], [c[2], c[3]]);
|
||
if (Math.abs(dx * ey - dy * ex) > 0.05) continue; // not collinear
|
||
if (distToSeg(mx, my, c[0], c[1], c[2], c[3]) <= eps) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Collinearity / on-segment tolerance for interval work (plan units). */
|
||
function openEps(pitch: number, coordScale: number): number {
|
||
return Math.max(pitch * (coordScale > 0 ? coordScale : 1) * 0.04, 1e-9);
|
||
}
|
||
|
||
// ------------------------------- inset / rings ------------------------------
|
||
|
||
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;
|
||
}
|
||
|
||
/** Inward unit normal for edge i (into the polygon). */
|
||
export function inwardNormal(poly: number[][], i: number): [number, number] {
|
||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const L = Math.hypot(dx, dy) || 1;
|
||
// left normal of edge direction; flip if it points outward
|
||
let nx = -dy / L, ny = dx / L;
|
||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
const probe = [mid[0] + nx * 1e-3, mid[1] + ny * 1e-3];
|
||
// winding-agnostic: a probe that leaves the poly means we had the outward normal
|
||
if (!pointInPoly(probe, poly)) {
|
||
nx = -nx; ny = -ny;
|
||
}
|
||
// if area is negative (CW), left normal already points inward for standard math —
|
||
// pointInPoly check above handles both.
|
||
void signedArea;
|
||
return [nx, ny];
|
||
}
|
||
|
||
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 + 0) + xi) inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/** Same direction, no turn: the joint of two pieces of ONE straight wall. */
|
||
function collinearJoint(uA: number[], uB: number[]): boolean {
|
||
const cross = uA[0] * uB[1] - uA[1] * uB[0];
|
||
const dot = uA[0] * uB[0] + uA[1] * uB[1];
|
||
return Math.abs(cross) < 1e-9 && dot > 0;
|
||
}
|
||
|
||
function lineIntersect(
|
||
p: number[], r: number[],
|
||
q: number[], s: number[],
|
||
): number[] | null {
|
||
// p + t r = q + u s
|
||
const rxs = r[0] * s[1] - r[1] * s[0];
|
||
if (Math.abs(rxs) < 1e-12) return null;
|
||
const qp = [q[0] - p[0], q[1] - p[1]];
|
||
const t = (qp[0] * s[1] - qp[1] * s[0]) / rxs;
|
||
return [p[0] + t * r[0], p[1] + t * r[1]];
|
||
}
|
||
|
||
/**
|
||
* Clear distance between the wall faces along edge `index` (#233).
|
||
*
|
||
* The resize labels used to measure centrelines while the area label already
|
||
* measured the floor, so one bubble carried two conventions: "3.00 x 4.00" from
|
||
* wall centres next to an area from inner faces, and neither number could be
|
||
* checked with a tape measure.
|
||
*
|
||
* Indices of `insetContour` are deliberately NOT used: that function emits one
|
||
* point per corner (mitre), two (bevel, collinear joint, zero-thickness joint)
|
||
* or the original vertex, so inner and centre polygons do not share an index
|
||
* space. The span is obtained by intersecting the inner offset LINES instead,
|
||
* which is exact for any angle, diagonals included.
|
||
*
|
||
* `poly` is the room's own polygon and `offsets` carries one half-depth per its
|
||
* edge — resolved from the atomic profile by the caller, because a whole-edge
|
||
* `thicknessCmAt` lookup returns 0 on a split-thickness edge.
|
||
*/
|
||
export function innerEdgeSpan(poly: number[][], index: number, offsets: number[]): number {
|
||
const n = poly?.length || 0;
|
||
if (n < 3 || !Array.isArray(offsets) || offsets.length !== n) return 0;
|
||
const i = ((Math.trunc(index) % n) + n) % n;
|
||
const a = poly[i], b = poly[(i + 1) % n];
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const centre = Math.hypot(dx, dy);
|
||
if (!(centre > 0)) return 0;
|
||
const own = Math.max(0, Number(offsets[i]) || 0);
|
||
// The zero rule comes first (#233, spec review r1/H1): a passage or a side
|
||
// open to the neighbouring room has no face to measure from, so its label is
|
||
// the full centreline. `insetContour` treats the same joint as a flat cap
|
||
// (#172) and shortens nothing either — otherwise length and area would
|
||
// diverge again, merely at a different boundary.
|
||
if (!(own > 0)) return centre;
|
||
|
||
const u: [number, number] = [dx / centre, dy / centre];
|
||
const selfNormal = inwardNormal(poly, i);
|
||
const selfPoint = [a[0] + selfNormal[0] * own, a[1] + selfNormal[1] * own];
|
||
// Distance from `a` measured along the edge; the inner line of a neighbour
|
||
// that has no thickness does not cut this edge at all.
|
||
const cutAt = (edge: number): number | null => {
|
||
const o = Math.max(0, Number(offsets[edge]) || 0);
|
||
if (!(o > 0)) return null;
|
||
const p0 = poly[edge], p1 = poly[(edge + 1) % n];
|
||
const ex = p1[0] - p0[0], ey = p1[1] - p0[1];
|
||
const len = Math.hypot(ex, ey);
|
||
if (!(len > 0)) return null;
|
||
const nrm = inwardNormal(poly, edge);
|
||
const hit = lineIntersect(
|
||
selfPoint, u, [p0[0] + nrm[0] * o, p0[1] + nrm[1] * o], [ex / len, ey / len],
|
||
);
|
||
if (!hit) return null;
|
||
return (hit[0] - a[0]) * u[0] + (hit[1] - a[1]) * u[1];
|
||
};
|
||
|
||
const start = cutAt((i - 1 + n) % n) ?? 0;
|
||
const end = cutAt((i + 1) % n) ?? centre;
|
||
const span = end - start;
|
||
// Walls thicker than the room they enclose: report nothing left, never a
|
||
// negative length.
|
||
return span > 0 ? span : 0;
|
||
}
|
||
|
||
/**
|
||
* One half-depth per edge of the room's OWN polygon (#233).
|
||
*
|
||
* The profile is atomic — its polygon is cut at shared boundaries — so an own
|
||
* edge may cover several stretches. The stretch holding the edge midpoint is
|
||
* the representative: the clear distance between the walls at the two ENDS of
|
||
* the edge is what the label reports, and a differently thick stretch in the
|
||
* middle does not change that distance.
|
||
*/
|
||
export function ownEdgeOffsets(
|
||
rooms: any[],
|
||
roomId: string,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): number[] | null {
|
||
const room = (rooms || []).find((r) => r?.id === roomId);
|
||
const own = roomPoly(room);
|
||
if (!own || own.length < 3) return null;
|
||
const profile = roomWallProfile(
|
||
rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
if (!profile) return own.map(() => 0);
|
||
const eps = openEps(pitch, coordScale) * 4;
|
||
return own.map((a, i) => {
|
||
const b = own[(i + 1) % own.length];
|
||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
for (let k = 0; k < profile.poly.length; k++) {
|
||
const p0 = profile.poly[k], p1 = profile.poly[(k + 1) % profile.poly.length];
|
||
// Уже существующая distToSeg принимает координаты, а не точки.
|
||
if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {
|
||
return Math.max(0, profile.offsets[k] || 0);
|
||
}
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* Inset a polygon by a per-edge inward distance (same units as poly).
|
||
* Zero-offset edges stay on the original. Mitre joins; bevel when the mitre
|
||
* would spike longer than MITRE_LIMIT × max(adjacent offsets).
|
||
*/
|
||
export function insetContour(
|
||
poly: number[][],
|
||
offsets: number[],
|
||
multiWallNodes?: MultiWallNodeMap | null,
|
||
): number[][] | null {
|
||
const n = poly?.length || 0;
|
||
if (n < 3 || offsets.length !== n) return null;
|
||
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
|
||
|
||
const out: number[][] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const iPrev = (i - 1 + n) % n;
|
||
const a0 = poly[iPrev], a1 = poly[i];
|
||
const b0 = poly[i], b1 = poly[(i + 1) % n];
|
||
const oA = Math.max(0, offsets[iPrev]);
|
||
const oB = Math.max(0, offsets[i]);
|
||
|
||
const [nAx, nAy] = inwardNormal(poly, iPrev);
|
||
const [nBx, nBy] = inwardNormal(poly, i);
|
||
|
||
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
|
||
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
|
||
const LA = Math.hypot(dA[0], dA[1]) || 1;
|
||
const LB = Math.hypot(dB[0], dB[1]) || 1;
|
||
const uA = [dA[0] / LA, dA[1] / LA];
|
||
const uB = [dB[0] / LB, dB[1] / LB];
|
||
|
||
const pA = [a0[0] + nAx * oA, a0[1] + nAy * oA];
|
||
const pB = [b0[0] + nBx * oB, b0[1] + nBy * oB];
|
||
|
||
if (!(oA > 0) && !(oB > 0)) {
|
||
out.push([poly[i][0], poly[i][1]]);
|
||
continue;
|
||
}
|
||
|
||
// #172: a physical edge meeting a zero-depth divider owns a square cap,
|
||
// not a mitre into the divider. Keep both sides of that cap explicitly:
|
||
// the offset point of the physical edge and the untouched vertex of the
|
||
// zero edge. Letting the generic mitre/bevel path handle a near-collinear
|
||
// join drops the untouched vertex and stretches the cap along the complete
|
||
// divider as a triangular wall body.
|
||
if ((oA > 0) !== (oB > 0)) {
|
||
const v = poly[i];
|
||
const pa = oA > 0 ? [v[0] + nAx * oA, v[1] + nAy * oA] : [v[0], v[1]];
|
||
const pb = oB > 0 ? [v[0] + nBx * oB, v[1] + nBy * oB] : [v[0], v[1]];
|
||
out.push(pa);
|
||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||
continue;
|
||
}
|
||
|
||
// AUD-159B6-01: atomic intervals put COLLINEAR neighbours in one outline.
|
||
// Two parallel offset lines never intersect, so the mitre branch below would
|
||
// fall through to a bevel that skips the zero side and slants the wall face.
|
||
// Equal offsets collapse to one point, different ones step across.
|
||
if (collinearJoint(uA, uB)) {
|
||
const v = poly[i];
|
||
const pa = [v[0] + nAx * oA, v[1] + nAy * oA];
|
||
const pb = [v[0] + nBx * oB, v[1] + nBy * oB];
|
||
out.push(pa);
|
||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||
continue;
|
||
}
|
||
|
||
const hit = lineIntersect(pA, uA, pB, uB);
|
||
const maxO = Math.max(oA, oB, 1e-9);
|
||
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
|
||
?? MITRE_LIMIT * maxO;
|
||
if (hit) {
|
||
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
|
||
if (Number.isFinite(dist) && dist <= joinLimit) {
|
||
out.push(hit);
|
||
continue;
|
||
}
|
||
// #329: at a degenerate apex the two-point bevel folds the contour over
|
||
// itself (a bow-tie), and subtracting that fold carved notches in the
|
||
// masonry. The interior simply ends in ITS own apex — the single mitre
|
||
// point — mirroring the sharp outer tip.
|
||
if (isDegenerateApexCorner(poly, offsets, i)) {
|
||
out.push(hit);
|
||
continue;
|
||
}
|
||
}
|
||
// bevel: two points, each edge's offset line stopped at the vertex offset
|
||
if (oA > 0) out.push([poly[i][0] + nAx * oA, poly[i][1] + nAy * oA]);
|
||
if (oB > 0) out.push([poly[i][0] + nBx * oB, poly[i][1] + nBy * oB]);
|
||
if (!(oA > 0) && !(oB > 0)) out.push([poly[i][0], poly[i][1]]);
|
||
}
|
||
return out.length >= 3 ? out : null;
|
||
}
|
||
|
||
export type WallKind = 'shared' | 'outer';
|
||
|
||
export interface WallBodyPath {
|
||
/** SVG path `d` for the wall ring (evenodd: outer + reverse inset). */
|
||
d: string;
|
||
key: string;
|
||
kind: WallKind;
|
||
cm: number;
|
||
/** Screen-thickness hint in plan units (full wall depth). */
|
||
depthUnits: number;
|
||
}
|
||
|
||
function polyToPath(poly: number[][], close = true): string {
|
||
if (!poly.length) return '';
|
||
let d = `M ${poly[0][0]} ${poly[0][1]}`;
|
||
for (let i = 1; i < poly.length; i++) d += ` L ${poly[i][0]} ${poly[i][1]}`;
|
||
if (close) d += ' Z';
|
||
return d;
|
||
}
|
||
|
||
function reversePoly(poly: number[][]): number[][] {
|
||
return poly.slice().reverse();
|
||
}
|
||
|
||
// --------------------------- atomic intervals -------------------------------
|
||
//
|
||
// docs/WALL-THICKNESS.md §2. A room edge is NOT the unit of a wall: a single
|
||
// polygon edge can be shared with a neighbour over part of its length, carry a
|
||
// virtual (open) stretch in the middle, and be an outer wall for the rest.
|
||
// Every geometry step below therefore works on ATOMIC INTERVALS — the pieces
|
||
// an edge is cut into by every shared-boundary end and every open-span end.
|
||
// Both the stored key and the rendered ring follow those pieces (AUD-159B6-01).
|
||
|
||
/** Room outline with every atomic breakpoint inserted as a vertex. */
|
||
export interface AtomicPoly {
|
||
/** Subdivided outline (superset of the room polygon's vertices). */
|
||
poly: number[][];
|
||
/** For sub-edge i: index of the original polygon edge it belongs to. */
|
||
parent: number[];
|
||
/** The untouched room polygon. */
|
||
orig: number[][];
|
||
}
|
||
|
||
export function atomicPolyForRoom(
|
||
rooms: any[],
|
||
roomId: string,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
wallBreaks: WallEntry[] | null | undefined = [],
|
||
): AtomicPoly | null {
|
||
const room = (rooms || []).find((r) => r?.id === roomId);
|
||
const orig = roomPoly(room);
|
||
if (!orig || orig.length < 3) return null;
|
||
const eps = openEps(pitch, coordScale);
|
||
const breaks: number[][] = [];
|
||
for (const other of rooms || []) {
|
||
if (!other || other.id === roomId) continue;
|
||
const op = roomPoly(other);
|
||
if (!op) continue;
|
||
for (const sg of sharedBoundary(orig, op, eps)) {
|
||
breaks.push([sg[0], sg[1]], [sg[2], sg[3]]);
|
||
}
|
||
}
|
||
for (const c of openCuts || []) breaks.push([c[0], c[1]], [c[2], c[3]]);
|
||
// A closed virtual span may have been the only geometric breakpoint between
|
||
// two real intervals of different thickness. New wall entries retain their
|
||
// exact endpoints so deleting that span cannot erase the thickness boundary.
|
||
for (const w of wallBreaks || []) {
|
||
const span = entrySpan(w, coordScale);
|
||
if (span) breaks.push(span[0], span[1]);
|
||
}
|
||
const poly: number[][] = [];
|
||
const parent: number[] = [];
|
||
for (let i = 0; i < orig.length; i++) {
|
||
const a = orig[i], b = orig[(i + 1) % orig.length];
|
||
poly.push([a[0], a[1]]);
|
||
parent.push(i);
|
||
const L = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||
if (L < eps * 2 || !breaks.length) continue;
|
||
const gap = Math.min(0.499, (eps * 2) / L);
|
||
const ts: number[] = [];
|
||
for (const p of breaks) {
|
||
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) continue;
|
||
const t = ((p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1])) / (L * L);
|
||
if (t <= gap || t >= 1 - gap) continue;
|
||
if (ts.some((u) => Math.abs(u - t) * L <= eps * 2)) continue;
|
||
ts.push(t);
|
||
}
|
||
ts.sort((x, y) => x - y);
|
||
for (const t of ts) {
|
||
poly.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
||
parent.push(i);
|
||
}
|
||
}
|
||
return { poly, parent, orig };
|
||
}
|
||
|
||
/** Shared-boundary stretches of one room against every other (plan units). */
|
||
function sharedSegsOf(rooms: any[], roomId: string, eps: number): number[][] {
|
||
const room = (rooms || []).find((r) => r?.id === roomId);
|
||
const poly = roomPoly(room);
|
||
if (!poly) return [];
|
||
const out: number[][] = [];
|
||
for (const other of rooms || []) {
|
||
if (!other || other.id === roomId) continue;
|
||
const op = roomPoly(other);
|
||
if (!op) continue;
|
||
for (const sg of sharedBoundary(poly, op, eps)) out.push(sg);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function kindsForPoly(
|
||
poly: number[][],
|
||
shared: number[][],
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
coordScale: number,
|
||
): Array<WallKind | null> {
|
||
const eps = openEps(pitch, coordScale);
|
||
const out: Array<WallKind | null> = [];
|
||
for (let i = 0; i < poly.length; i++) {
|
||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||
if (edgeIsOpen(a, b, openCuts, pitch, coordScale)) { out.push(null); continue; }
|
||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||
const onShared = shared.some((sg) => distToSeg(mx, my, sg[0], sg[1], sg[2], sg[3]) <= eps);
|
||
out.push(onShared ? 'shared' : 'outer');
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Classify each ATOMIC interval of a room: shared with a neighbour, or outer.
|
||
* Open (virtual) stretches are reported as kind null (no thickness allowed).
|
||
* Indices align with `atomicPolyForRoom(...).poly`.
|
||
*/
|
||
export function edgeKinds(
|
||
rooms: any[],
|
||
roomId: string,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
coordScale = 1,
|
||
): Array<WallKind | null> {
|
||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale);
|
||
if (!at) return [];
|
||
const shared = sharedSegsOf(rooms, roomId, openEps(pitch, coordScale));
|
||
return kindsForPoly(at.poly, shared, openCuts, pitch, coordScale);
|
||
}
|
||
|
||
/**
|
||
* Effective thickness (cm) per atomic interval.
|
||
*
|
||
* An interval first looks for its OWN key. What is left over is matched against
|
||
* keys written before the split — a pre-atomic key describes the whole parent
|
||
* edge, so its cm goes to the intervals of that edge nobody claimed. Without
|
||
* that, an existing plan would silently lose thickness the moment a neighbour
|
||
* or an open span cuts one of its walls in two.
|
||
*/
|
||
function cmsForPoly(
|
||
walls: WallEntry[] | null | undefined,
|
||
at: AtomicPoly,
|
||
pitch: number,
|
||
coordScale: number,
|
||
): number[] {
|
||
const n = at.poly.length;
|
||
const cms = new Array<number>(n).fill(0);
|
||
if (!walls?.length) return cms;
|
||
const claimed = new Set<string>();
|
||
const orphans: number[] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const a = at.poly[i], b = at.poly[(i + 1) % n];
|
||
const hit = lookupWall(walls, a, b, pitch, coordScale);
|
||
if (hit && hit.cm > 0) {
|
||
cms[i] = clampWallCm(hit.cm);
|
||
claimed.add(hit.key);
|
||
} else {
|
||
orphans.push(i);
|
||
}
|
||
}
|
||
if (!orphans.length) return cms;
|
||
const scale = coordScale > 0 ? coordScale : 1;
|
||
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
|
||
const parsed = parseKeys(walls, scale).filter((e) => e.w.cm > 0);
|
||
// An exact run materialised before Split may cover only part of the new
|
||
// polygon parent edge. Resolve those lossless spans against each orphaned
|
||
// atomic child first: [0..6] must cover new child [4..6], but never [6..10].
|
||
for (let oi = orphans.length - 1; oi >= 0; oi--) {
|
||
const i = orphans[oi];
|
||
const a = at.poly[i], b = at.poly[(i + 1) % n];
|
||
const ang = segAngle(a, b);
|
||
let best: { cm: number; extra: number } | null = null;
|
||
for (const e of parsed) {
|
||
const span = entrySpan(e.w, scale);
|
||
if (!span || !angleClose(segAngle(span[0], span[1]), ang)) continue;
|
||
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|
||
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
|
||
const childLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||
const spanLen = Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]);
|
||
const extra = Math.max(0, spanLen - childLen);
|
||
if (!best || extra < best.extra) best = { cm: clampWallCm(e.w.cm), extra };
|
||
}
|
||
if (!best) continue;
|
||
cms[i] = best.cm;
|
||
orphans.splice(oi, 1);
|
||
}
|
||
const byParent = new Map<number, number[]>();
|
||
for (const i of orphans) {
|
||
const p = at.parent[i];
|
||
const list = byParent.get(p);
|
||
if (list) list.push(i);
|
||
else byParent.set(p, [i]);
|
||
}
|
||
for (const [pi, idxs] of byParent) {
|
||
const a = at.orig[pi], b = at.orig[(pi + 1) % at.orig.length];
|
||
const ang = segAngle(a, b);
|
||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||
let best: { cm: number; d: number; exact: boolean } | null = null;
|
||
const parentLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||
for (const e of parsed) {
|
||
if (claimed.has(e.w.key)) continue;
|
||
if (!angleClose(e.ang, ang)) continue;
|
||
const span = entrySpan(e.w, scale);
|
||
let exact = false;
|
||
let d = 0;
|
||
if (span) {
|
||
// Normalisation may compact one equal-thickness run through several
|
||
// collinear room sides. Its midpoint can then lie outside a shorter
|
||
// child side, but the lossless endpoints still prove that the run
|
||
// covers that side. Require BOTH endpoints so a partial wall cannot
|
||
// leak into the rest of its parent edge (AUD-159B6-01).
|
||
if (!angleClose(segAngle(span[0], span[1]), ang)) continue;
|
||
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|
||
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
|
||
exact = true;
|
||
d = Math.max(0, Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]) - parentLen);
|
||
} else {
|
||
if (distToSeg(e.x, e.y, a[0], a[1], b[0], b[1]) > tol) continue;
|
||
d = Math.hypot(e.x - mx, e.y - my);
|
||
}
|
||
if (!best || (exact && !best.exact) || (exact === best.exact && d < best.d)) {
|
||
best = { cm: clampWallCm(e.w.cm), d, exact };
|
||
}
|
||
}
|
||
if (!best) continue;
|
||
for (const i of idxs) cms[i] = best.cm;
|
||
}
|
||
return cms;
|
||
}
|
||
|
||
/** One atomic wall stretch of one room, with everything a caller may need. */
|
||
export interface WallInterval {
|
||
roomId: string;
|
||
a: number[];
|
||
b: number[];
|
||
key: string;
|
||
kind: WallKind | null;
|
||
cm: number;
|
||
open: boolean;
|
||
/** Half depth in plan units (0 when there is no thickness). */
|
||
half: number;
|
||
}
|
||
|
||
/** Per-room atomic geometry: subdivided outline + kinds + cms + half offsets. */
|
||
export interface RoomWallProfile extends AtomicPoly {
|
||
kinds: Array<WallKind | null>;
|
||
cms: number[];
|
||
offsets: number[];
|
||
}
|
||
|
||
interface PendingMultiWallNode {
|
||
point: [number, number];
|
||
rays: Array<{
|
||
u: [number, number]; halfDepth: number; length: number; angle: number;
|
||
}>;
|
||
}
|
||
|
||
interface MultiWallEndpoint {
|
||
point: [number, number];
|
||
other: number[];
|
||
halfDepth: number;
|
||
kind: WallKind;
|
||
key: string;
|
||
}
|
||
|
||
function spatialBucket(point: number[], epsilon: number): [number, number] {
|
||
return [Math.floor(point[0] / epsilon), Math.floor(point[1] / epsilon)];
|
||
}
|
||
|
||
function spatialBucketKey(x: number, y: number): string {
|
||
return `${x},${y}`;
|
||
}
|
||
|
||
function nearbyBuckets<T>(
|
||
index: Map<string, T[]>,
|
||
point: number[],
|
||
epsilon: number,
|
||
): T[] {
|
||
const [bx, by] = spatialBucket(point, epsilon);
|
||
const out: T[] = [];
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
for (let dy = -1; dy <= 1; dy++) {
|
||
const values = index.get(spatialBucketKey(bx + dx, by + dy));
|
||
if (values) out.push(...values);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Canonical degree-3+ physical endpoint map (#249).
|
||
*
|
||
* Shared intervals may occur once per owning room. They collapse first by
|
||
* interval key and again by co-directional ray, while opposite directions
|
||
* remain distinct. Sorting makes the representative and ray order independent
|
||
* of room/wall input order and winding.
|
||
*/
|
||
export function buildMultiWallNodeMap(
|
||
input: WallInterval[],
|
||
epsilon = 1e-6,
|
||
coordinateScale = 1,
|
||
): MultiWallNodeMap {
|
||
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
|
||
const scale = Number.isFinite(coordinateScale) && coordinateScale > 0
|
||
? coordinateScale : 1;
|
||
const valid = (input || [])
|
||
.filter((iv) => iv && !iv.open && iv.kind !== null && Number.isFinite(iv.half) && iv.half > 0
|
||
&& Array.isArray(iv.a) && Array.isArray(iv.b)
|
||
&& iv.a.length >= 2 && iv.b.length >= 2
|
||
&& [iv.a[0], iv.a[1], iv.b[0], iv.b[1]].every(Number.isFinite)
|
||
&& Math.hypot(iv.b[0] - iv.a[0], iv.b[1] - iv.a[1]) > eps)
|
||
.sort((a, b) => a.key.localeCompare(b.key)
|
||
|| a.a[0] - b.a[0] || a.a[1] - b.a[1]
|
||
|| a.b[0] - b.b[0] || a.b[1] - b.b[1]
|
||
|| a.half - b.half);
|
||
const byPhysicalKey = new Map<string, WallInterval>();
|
||
for (const interval of valid) {
|
||
const previous = byPhysicalKey.get(interval.key);
|
||
if (!previous) {
|
||
byPhysicalKey.set(interval.key, interval);
|
||
} else if (interval.half > previous.half) {
|
||
// A shared physical interval may be emitted by both room owners. Keep
|
||
// one deterministic axis and the largest effective physical half-depth.
|
||
byPhysicalKey.set(interval.key, { ...previous, half: interval.half });
|
||
}
|
||
}
|
||
const intervals = [...byPhysicalKey.values()];
|
||
|
||
const endpoints: MultiWallEndpoint[] = intervals.flatMap((iv) => [
|
||
{
|
||
point: [iv.a[0], iv.a[1]] as [number, number], other: iv.b, halfDepth: iv.half,
|
||
kind: iv.kind as WallKind, key: iv.key,
|
||
},
|
||
{
|
||
point: [iv.b[0], iv.b[1]] as [number, number], other: iv.a, halfDepth: iv.half,
|
||
kind: iv.kind as WallKind, key: iv.key,
|
||
},
|
||
]).sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]
|
||
|| a.other[0] - b.other[0] || a.other[1] - b.other[1]
|
||
|| a.halfDepth - b.halfDepth);
|
||
const endpointIndex = new Map<string, MultiWallEndpoint[]>();
|
||
for (const endpoint of endpoints) {
|
||
const [bx, by] = spatialBucket(endpoint.point, eps);
|
||
const key = spatialBucketKey(bx, by);
|
||
const bucket = endpointIndex.get(key) || [];
|
||
bucket.push(endpoint);
|
||
endpointIndex.set(key, bucket);
|
||
}
|
||
|
||
const pending: PendingMultiWallNode[] = [];
|
||
const pendingIndex = new Map<string, PendingMultiWallNode[]>();
|
||
for (const endpoint of endpoints) {
|
||
const candidates = nearbyBuckets(pendingIndex, endpoint.point, eps)
|
||
.filter((node) => Math.hypot(
|
||
node.point[0] - endpoint.point[0], node.point[1] - endpoint.point[1],
|
||
) <= eps)
|
||
.sort((a, b) => Math.hypot(
|
||
a.point[0] - endpoint.point[0], a.point[1] - endpoint.point[1],
|
||
) - Math.hypot(
|
||
b.point[0] - endpoint.point[0], b.point[1] - endpoint.point[1],
|
||
) || a.point[0] - b.point[0] || a.point[1] - b.point[1]);
|
||
let node = candidates[0];
|
||
if (!node) {
|
||
node = { point: [...endpoint.point], rays: [] };
|
||
pending.push(node);
|
||
const [bx, by] = spatialBucket(node.point, eps);
|
||
const key = spatialBucketKey(bx, by);
|
||
const bucket = pendingIndex.get(key) || [];
|
||
bucket.push(node);
|
||
pendingIndex.set(key, bucket);
|
||
}
|
||
const dx = endpoint.other[0] - endpoint.point[0];
|
||
const dy = endpoint.other[1] - endpoint.point[1];
|
||
const length = Math.hypot(dx, dy);
|
||
if (!(length > eps)) continue;
|
||
const u: [number, number] = [dx / length, dy / length];
|
||
let angle = Math.atan2(u[1], u[0]);
|
||
if (angle < 0) angle += Math.PI * 2;
|
||
node.rays.push({ u, halfDepth: endpoint.halfDepth, length, angle });
|
||
}
|
||
|
||
const nodes: MultiWallNode[] = [];
|
||
const angleEps = 1e-9;
|
||
const canonicalSupports = (
|
||
input: MultiWallNodeRaySupport[],
|
||
): MultiWallNodeRaySupport[] => {
|
||
// Endpoint clustering uses a deliberately visible plan-space tolerance;
|
||
// dominance between already matched physical strips must not. Otherwise a
|
||
// long thin strip can erase a shorter thick strip merely because their
|
||
// half-depth difference is below the node lookup epsilon.
|
||
const supportEps = 1e-9 * Math.max(1, scale);
|
||
const validSupports = input.filter((support) => Number.isFinite(support.halfDepth)
|
||
&& support.halfDepth > 0 && Number.isFinite(support.length) && support.length > eps);
|
||
return validSupports
|
||
.filter((support, index) => !validSupports.some((other, otherIndex) => (
|
||
otherIndex !== index
|
||
&& other.halfDepth >= support.halfDepth - supportEps
|
||
&& other.length >= support.length - supportEps
|
||
&& (other.halfDepth > support.halfDepth + supportEps
|
||
|| other.length > support.length + supportEps
|
||
|| otherIndex < index)
|
||
)))
|
||
.sort((a, b) => a.length - b.length || a.halfDepth - b.halfDepth)
|
||
.map((support) => ({ ...support }));
|
||
};
|
||
for (const node of pending) {
|
||
const sorted = node.rays.sort((a, b) => a.angle - b.angle
|
||
|| a.length - b.length || a.halfDepth - b.halfDepth);
|
||
const rays: Array<{
|
||
u: [number, number]; angle: number; supports: MultiWallNodeRaySupport[];
|
||
}> = [];
|
||
for (const ray of sorted) {
|
||
const previous = rays[rays.length - 1];
|
||
if (previous && Math.abs(ray.angle - previous.angle) <= angleEps) {
|
||
previous.supports.push({ halfDepth: ray.halfDepth, length: ray.length });
|
||
} else {
|
||
rays.push({
|
||
u: [...ray.u], angle: ray.angle,
|
||
supports: [{ halfDepth: ray.halfDepth, length: ray.length }],
|
||
});
|
||
}
|
||
}
|
||
if (rays.length > 1
|
||
&& Math.PI * 2 - rays[rays.length - 1].angle + rays[0].angle <= angleEps) {
|
||
const last = rays.pop()!;
|
||
rays[0].supports.push(...last.supports);
|
||
}
|
||
if (rays.length < 3) continue;
|
||
const canonicalRays = rays.map((ray) => {
|
||
const supports = canonicalSupports(ray.supports);
|
||
const continuationKeys = new Set<string>();
|
||
const continuations: MultiWallNodeRayContinuation[] = [];
|
||
for (const support of supports) {
|
||
const end = [
|
||
node.point[0] + ray.u[0] * support.length,
|
||
node.point[1] + ray.u[1] * support.length,
|
||
];
|
||
for (const candidate of nearbyBuckets(endpointIndex, end, eps)) {
|
||
if (candidate.kind !== 'shared'
|
||
|| Math.hypot(candidate.point[0] - end[0], candidate.point[1] - end[1]) > eps)
|
||
continue;
|
||
const dx = candidate.other[0] - candidate.point[0];
|
||
const dy = candidate.other[1] - candidate.point[1];
|
||
const length = Math.hypot(dx, dy);
|
||
if (!(length > eps)) continue;
|
||
const ux = dx / length, uy = dy / length;
|
||
// The interval that supplied this support also has an endpoint here,
|
||
// directed back to the node. It is already rebuilt by `supports`;
|
||
// only a different finite shared strip attached at the far endpoint
|
||
// needs protection (it may continue straight or turn a corner).
|
||
if (Math.hypot(
|
||
candidate.other[0] - node.point[0], candidate.other[1] - node.point[1],
|
||
) <= eps) continue;
|
||
const key = `${candidate.key}|${candidate.point[0]}|${candidate.point[1]}`
|
||
+ `|${candidate.other[0]}|${candidate.other[1]}|${candidate.halfDepth}`;
|
||
if (continuationKeys.has(key)) continue;
|
||
continuationKeys.add(key);
|
||
continuations.push({
|
||
start: [candidate.point[0], candidate.point[1]],
|
||
u: [ux, uy],
|
||
length,
|
||
halfDepth: candidate.halfDepth,
|
||
});
|
||
}
|
||
}
|
||
continuations.sort((a, b) => a.start[0] - b.start[0]
|
||
|| a.start[1] - b.start[1] || a.u[0] - b.u[0] || a.u[1] - b.u[1]
|
||
|| a.length - b.length || a.halfDepth - b.halfDepth);
|
||
return {
|
||
u: [...ray.u] as [number, number],
|
||
halfDepth: Math.max(...supports.map((support) => support.halfDepth)),
|
||
length: Math.max(...supports.map((support) => support.length)),
|
||
supports,
|
||
continuations,
|
||
};
|
||
}).filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0
|
||
&& Number.isFinite(ray.length) && ray.length > eps);
|
||
if (canonicalRays.length < 3) continue;
|
||
const halfDepth = Math.max(...canonicalRays.map((ray) => ray.halfDepth));
|
||
if (!(halfDepth > 0) || !Number.isFinite(halfDepth)) continue;
|
||
nodes.push({
|
||
point: [...node.point],
|
||
rays: canonicalRays,
|
||
halfDepth,
|
||
limit: MULTI_WALL_JOIN_LIMIT * halfDepth,
|
||
});
|
||
}
|
||
nodes.sort((a, b) => a.point[0] - b.point[0] || a.point[1] - b.point[1]);
|
||
const index = new Map<string, MultiWallNode[]>();
|
||
for (const node of nodes) {
|
||
const [bx, by] = spatialBucket(node.point, eps);
|
||
const key = spatialBucketKey(bx, by);
|
||
const bucket = index.get(key) || [];
|
||
bucket.push(node);
|
||
index.set(key, bucket);
|
||
}
|
||
return { epsilon: eps, coordinateScale: scale, nodes, index };
|
||
}
|
||
|
||
/**
|
||
* Corner geometry of one node (#302 + the #249 chamfer, owner 2026-08-25).
|
||
*
|
||
* One angular walk produces both halves of the corner rule:
|
||
* - a FAN per pair of adjacent rays — additive sector material from the node
|
||
* out to the mitre point, or to the bevel chord when the mitre runs past
|
||
* the node's approved join limit;
|
||
* - a CUT per over-limit pair — the wedge beyond that same chord, which is
|
||
* how the approved #249 chamfer looks.
|
||
* Fan and cut of one pair meet exactly at the chord and never overlap, and the
|
||
* cut lies strictly between the two strip edges: subtracting it can touch
|
||
* neither strip's interior. That bound is what the old bevel layer kept
|
||
* failing to hold — its cuts reached past the limit with a separate
|
||
* "protection" pass patching the damage after the fact.
|
||
*/
|
||
export interface JunctionNodeGeometry {
|
||
fans: number[][][];
|
||
/** Exact support quads of every ray — the strips the node actually owns. */
|
||
supports: number[][][];
|
||
}
|
||
|
||
export function junctionNodeGeometry(
|
||
map: MultiWallNodeMap | null | undefined,
|
||
): JunctionNodeGeometry {
|
||
const out: JunctionNodeGeometry = { fans: [], supports: [] };
|
||
if (!map?.nodes?.length) return out;
|
||
const areaEps = Math.max(map.epsilon, 1e-9) ** 2;
|
||
for (const node of map.nodes) {
|
||
const rays = node.rays
|
||
.filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0)
|
||
.map((ray) => ({
|
||
...ray,
|
||
// The fan follows the strip that actually exists at the node: the
|
||
// ray's max half-depth is only valid as far as the support that owns
|
||
// it. Walking past a short thick support would paint a phantom beside
|
||
// a thinner continuation (#271).
|
||
thickLength: Math.max(...ray.supports
|
||
.filter((support) => support.halfDepth >= ray.halfDepth - 1e-12)
|
||
.map((support) => support.length), 0),
|
||
angle: (() => {
|
||
const a = Math.atan2(ray.u[1], ray.u[0]);
|
||
return a < 0 ? a + Math.PI * 2 : a;
|
||
})(),
|
||
}))
|
||
.sort((a, b) => a.angle - b.angle);
|
||
if (rays.length < 2) continue;
|
||
const P = node.point;
|
||
// The support quads are the ground truth the chamfer must never eat:
|
||
// each is bounded by its own finite length, so re-adding them can never
|
||
// repaint a lateral phantom beyond a short support (#271).
|
||
for (const ray of rays) {
|
||
for (const support of ray.supports) {
|
||
if (!(support.halfDepth > 0) || !(support.length > 0)) continue;
|
||
const ex = -ray.u[1] * support.halfDepth;
|
||
const ey = ray.u[0] * support.halfDepth;
|
||
const far = [
|
||
P[0] + ray.u[0] * support.length,
|
||
P[1] + ray.u[1] * support.length,
|
||
];
|
||
out.supports.push([
|
||
[P[0] + ex, P[1] + ey],
|
||
[far[0] + ex, far[1] + ey],
|
||
[far[0] - ex, far[1] - ey],
|
||
[P[0] - ex, P[1] - ey],
|
||
]);
|
||
}
|
||
}
|
||
for (let i = 0; i < rays.length; i++) {
|
||
const A = rays[i];
|
||
const B = rays[(i + 1) % rays.length];
|
||
const sector = (() => {
|
||
const raw = B.angle - A.angle;
|
||
return raw > 0 ? raw : raw + Math.PI * 2;
|
||
})();
|
||
if (sector < 1e-9) continue;
|
||
const reflex = sector > Math.PI + 1e-9;
|
||
const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
|
||
// Facing strip edges: A's at angle+90°, B's at angle−90°.
|
||
const EA = [P[0] - A.u[1] * A.halfDepth, P[1] + A.u[0] * A.halfDepth];
|
||
const EB = [P[0] + B.u[1] * B.halfDepth, P[1] - B.u[0] * B.halfDepth];
|
||
const cross = A.u[0] * B.u[1] - A.u[1] * B.u[0];
|
||
const inSector = (point: number[]): boolean => {
|
||
let angle = Math.atan2(point[1] - P[1], point[0] - P[0]) - A.angle;
|
||
while (angle < 0) angle += Math.PI * 2;
|
||
return angle <= sector + 1e-9;
|
||
};
|
||
let mitre: number[] | null = null;
|
||
if (Math.abs(cross) > 1e-9) {
|
||
const tA = ((EB[0] - EA[0]) * B.u[1] - (EB[1] - EA[1]) * B.u[0]) / cross;
|
||
const tB = ((EB[0] - EA[0]) * A.u[1] - (EB[1] - EA[1]) * A.u[0]) / cross;
|
||
const candidate = [EA[0] + A.u[0] * tA, EA[1] + A.u[1] * tA];
|
||
// The mitre is only a corner when it actually sits IN the sector —
|
||
// forward along the rays for an ordinary pair, backward for a reflex
|
||
// outer corner — inside the classic bound, and never past a thick
|
||
// support (#271: overshooting one paints a lateral phantom).
|
||
const directionOk = reflex
|
||
? tA <= 1e-9 && tB <= 1e-9
|
||
: tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;
|
||
if (directionOk
|
||
&& Math.hypot(candidate[0] - P[0], candidate[1] - P[1]) <= limit
|
||
&& inSector(candidate)) {
|
||
mitre = candidate;
|
||
}
|
||
}
|
||
const push = (poly: number[][]) => {
|
||
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
|
||
};
|
||
if (mitre) {
|
||
// #309: the accepted apex may still be visually too long (the classic
|
||
// bound admits 4·h). Past the visual limit the fan is closed with a
|
||
// flat chamfer perpendicular to the apex direction.
|
||
const visual = VISUAL_MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
|
||
push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)
|
||
?? [[P[0], P[1]], EA, mitre, EB]);
|
||
continue;
|
||
}
|
||
if (reflex) {
|
||
// A degenerate reflex mitre (parallel or out-of-bound edges) closes
|
||
// with the plain chord between the two strip edges.
|
||
push([[P[0], P[1]], EA, EB]);
|
||
continue;
|
||
}
|
||
// Bevel: walk each offset line a LOCAL distance — bounded by the thick
|
||
// support, by the classic limit and by twice the pair's depth, so the
|
||
// chord stays a corner detail and cannot fold across the plan.
|
||
const reach = (half: number, length: number) => Math.min(
|
||
length,
|
||
Math.sqrt(Math.max(limit ** 2 - half ** 2, 0)),
|
||
2 * Math.max(A.halfDepth, B.halfDepth),
|
||
);
|
||
const A2 = [
|
||
EA[0] + A.u[0] * reach(A.halfDepth, A.thickLength),
|
||
EA[1] + A.u[1] * reach(A.halfDepth, A.thickLength),
|
||
];
|
||
const B2 = [
|
||
EB[0] + B.u[0] * reach(B.halfDepth, B.thickLength),
|
||
EB[1] + B.u[1] * reach(B.halfDepth, B.thickLength),
|
||
];
|
||
push([[P[0], P[1]], EA, A2, B2, EB]);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The fans alone — kept for callers that only ever add material. */
|
||
export function junctionNodeFans(
|
||
map: MultiWallNodeMap | null | undefined,
|
||
): number[][][] {
|
||
return junctionNodeGeometry(map).fans;
|
||
}
|
||
|
||
/**
|
||
* The objective no-holes invariant of #302 (spec §8.4).
|
||
*
|
||
* A probe is a HOLE when the contract says the point is masonry — inside one
|
||
* of the node's support strips or sector fans, and inside the approved facade
|
||
* bound — yet the produced geometry does not cover it. Legitimate floor of an
|
||
* acute room corner never trips this (it is outside both strips and fans),
|
||
* which is what the first "surrounded by masonry" formulation got wrong.
|
||
*/
|
||
export function junctionContractHoles(
|
||
geometry: any,
|
||
map: MultiWallNodeMap | null | undefined,
|
||
options: { step: number; bound?: any },
|
||
): { node: [number, number]; holes: number[][] }[] {
|
||
if (!map?.nodes?.length || !(options.step > 0)) return [];
|
||
const corners = junctionNodeGeometry(map);
|
||
const inPolygon = (points: number[][], x: number, y: number): boolean => {
|
||
let inside = false;
|
||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||
const [xi, yi] = points[i];
|
||
const [xj, yj] = points[j];
|
||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
|
||
inside = !inside;
|
||
}
|
||
return inside;
|
||
};
|
||
const inGeometry = (geom: any, x: number, y: number): boolean => {
|
||
let inside = false;
|
||
for (const polygon of geom || []) for (const ring of polygon || []) {
|
||
if (inPolygon(ring, x, y)) inside = !inside;
|
||
}
|
||
return inside;
|
||
};
|
||
const reports: { node: [number, number]; holes: number[][] }[] = [];
|
||
for (const node of map.nodes) {
|
||
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
|
||
const holes: number[][] = [];
|
||
for (let dx = -radius; dx <= radius; dx += options.step) {
|
||
for (let dy = -radius; dy <= radius; dy += options.step) {
|
||
const x = node.point[0] + dx;
|
||
const y = node.point[1] + dy;
|
||
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
|
||
const rx = x - node.point[0];
|
||
const ry = y - node.point[1];
|
||
const along = rx * ray.u[0] + ry * ray.u[1];
|
||
if (along < 0 || along > support.length) return false;
|
||
return Math.abs(rx * ray.u[1] - ry * ray.u[0])
|
||
<= support.halfDepth - options.step * 0.25;
|
||
}));
|
||
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
|
||
if (!inStrip && !inFan) continue;
|
||
if (options.bound && !inGeometry(options.bound, x, y)) continue;
|
||
if (!inGeometry(geometry, x, y)) holes.push([x, y]);
|
||
}
|
||
}
|
||
if (holes.length) reports.push({ node: [...node.point], holes });
|
||
}
|
||
return reports;
|
||
}
|
||
|
||
/** Find the canonical degree-3+ node matching a contour vertex. */
|
||
export function multiWallNodeAt(
|
||
map: MultiWallNodeMap | null | undefined,
|
||
point: number[],
|
||
): MultiWallNode | null {
|
||
if (!map || !Array.isArray(point) || point.length < 2
|
||
|| !point.slice(0, 2).every(Number.isFinite)) return null;
|
||
return nearbyBuckets(map.index, point, map.epsilon)
|
||
.filter((node) => Math.hypot(node.point[0] - point[0], node.point[1] - point[1]) <= map.epsilon)
|
||
.sort((a, b) => Math.hypot(a.point[0] - point[0], a.point[1] - point[1])
|
||
- Math.hypot(b.point[0] - point[0], b.point[1] - point[1])
|
||
|| a.point[0] - b.point[0] || a.point[1] - b.point[1])[0] || null;
|
||
}
|
||
|
||
export function multiWallNodesForGeometry(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale: number,
|
||
): MultiWallNodeMap {
|
||
return buildMultiWallNodeMap(
|
||
wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale),
|
||
openEps(pitch, coordScale) * 4,
|
||
coordScale,
|
||
);
|
||
}
|
||
|
||
export function roomWallProfile(
|
||
rooms: any[],
|
||
roomId: string,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): RoomWallProfile | null {
|
||
const at = atomicPolyForRoom(rooms, roomId, openCuts, pitch, coordScale, walls);
|
||
if (!at) return null;
|
||
const shared = sharedSegsOf(rooms, roomId, openEps(pitch, coordScale));
|
||
const kinds = kindsForPoly(at.poly, shared, openCuts, pitch, coordScale);
|
||
const cms = cmsForPoly(walls, at, pitch, coordScale);
|
||
const offsets = cms.map((cm, i) => (
|
||
kinds[i] && cm > 0 ? wallCmToUnits(cm, cellCm, gridPitch) / 2 : 0
|
||
));
|
||
return { ...at, kinds, cms, offsets };
|
||
}
|
||
|
||
/** Every atomic wall stretch of every room (render/plan units). */
|
||
export function wallIntervals(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): WallInterval[] {
|
||
const out: WallInterval[] = [];
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr) continue;
|
||
for (let i = 0; i < pr.poly.length; i++) {
|
||
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
|
||
out.push({
|
||
roomId: room.id,
|
||
a: [a[0], a[1]],
|
||
b: [b[0], b[1]],
|
||
key: keyOf(a, b, pitch, coordScale),
|
||
kind: pr.kinds[i],
|
||
cm: pr.kinds[i] ? pr.cms[i] : 0,
|
||
open: pr.kinds[i] === null,
|
||
half: pr.offsets[i],
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Upgrade the effective current profile to lossless interval endpoints before
|
||
* a room-outline mutation. Legacy entries contain only midpoint + direction,
|
||
* which is enough while the original edge still exists but cannot tell two
|
||
* child edges apart after Split. Materialising first preserves the resolved
|
||
* value without broadening the legacy midpoint fallback to unrelated walls.
|
||
*/
|
||
export function materializeWallIntervals(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): WallEntry[] {
|
||
// Rebuild from the effective profile instead of retaining midpoint-only
|
||
// legacy rows beside their lossless replacements. Keeping both lets the
|
||
// tolerant lookup match a stale collinear stretch between these two calls.
|
||
let out: WallEntry[] = [];
|
||
const resolved = wallIntervals(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
for (const iv of resolved) {
|
||
if (iv.open || !(iv.cm > 0)) continue;
|
||
out = setWallThickness(out, iv.a, iv.b, iv.cm, pitch, coordScale);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Rewrite `walls` so every entry names a maximal equal-thickness interval of
|
||
* the CURRENT geometry, and no entry survives under an open span. Atomic
|
||
* entries compact across every consecutive solid run; a thickness change or
|
||
* virtual gap remains an exact stored breakpoint.
|
||
*
|
||
* This is the single place where the spec invariant "an open span and a
|
||
* positive thickness never share a key" is enforced: opening a stretch splits
|
||
* the parent key and drops the piece under the span, closing it merges the
|
||
* pieces back and inherits the cm of whatever stayed solid.
|
||
*/
|
||
export function normalizeWallIntervals(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): WallEntry[] {
|
||
if (!walls?.length) return [];
|
||
type OwnedInterval = WallInterval & { ownerSignature: string };
|
||
const resolved = wallIntervals(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
const ownersByKey = new Map<string, Set<string>>();
|
||
for (const interval of resolved) {
|
||
if (interval.open || !interval.kind || !interval.roomId) continue;
|
||
const owners = ownersByKey.get(interval.key) || new Set<string>();
|
||
owners.add(interval.roomId);
|
||
ownersByKey.set(interval.key, owners);
|
||
}
|
||
const ownerSignatureFor = (key: string): string => {
|
||
const owners = [...(ownersByKey.get(key) || [])].sort();
|
||
// A physical wall has one outer owner or two shared owners. Invalid
|
||
// multi-owner geometry is preserved fail-closed, one atom at a time: it
|
||
// must not become the bridge which compacts two otherwise separate roles.
|
||
if (owners.length !== 1 && owners.length !== 2) return `ambiguous:${key}`;
|
||
return `${owners.length === 1 ? 'outer' : 'shared'}:${owners.join('|')}`;
|
||
};
|
||
const atomic: OwnedInterval[] = [];
|
||
const atomicKeys = new Set<string>();
|
||
for (const iv of resolved) {
|
||
if (iv.open || !(iv.cm > 0) || atomicKeys.has(iv.key)) continue;
|
||
atomicKeys.add(iv.key);
|
||
atomic.push({ ...iv, ownerSignature: ownerSignatureFor(iv.key) });
|
||
}
|
||
|
||
// Compact every maximal solid run of one thickness AND one physical owner
|
||
// role. Equal centimetres cannot bridge shared(A,B) to outer(A), nor one
|
||
// shared pair to another: that creates a record whose thickness changes
|
||
// meaning halfway through its own span (#299).
|
||
const parents: Array<{
|
||
a: number[]; b: number[]; key: string; cm: number; len: number;
|
||
ownerSignature: string;
|
||
}> = [];
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr) continue;
|
||
for (let pi = 0; pi < pr.orig.length; pi++) {
|
||
const children: number[] = [];
|
||
for (let i = 0; i < pr.parent.length; i++) {
|
||
if (pr.parent[i] === pi) children.push(i);
|
||
}
|
||
if (!children.length) continue;
|
||
for (let at = 0; at < children.length;) {
|
||
const first = children[at];
|
||
const cm = pr.cms[first];
|
||
if (!(cm > 0) || pr.kinds[first] === null) { at++; continue; }
|
||
const firstKey = keyOf(pr.poly[first], pr.poly[(first + 1) % pr.poly.length], pitch, coordScale);
|
||
const ownerSignature = ownerSignatureFor(firstKey);
|
||
let end = at;
|
||
while (end + 1 < children.length) {
|
||
const next = children[end + 1];
|
||
const nextKey = keyOf(
|
||
pr.poly[next], pr.poly[(next + 1) % pr.poly.length], pitch, coordScale,
|
||
);
|
||
if (pr.kinds[next] === null || pr.cms[next] !== cm
|
||
|| ownerSignatureFor(nextKey) !== ownerSignature) break;
|
||
end++;
|
||
}
|
||
const last = children[end];
|
||
const a = pr.poly[first], b = pr.poly[(last + 1) % pr.poly.length];
|
||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||
if (len > 0) parents.push({
|
||
a: [a[0], a[1]], b: [b[0], b[1]],
|
||
key: keyOf(a, b, pitch, coordScale), cm, len, ownerSignature,
|
||
});
|
||
at = end + 1;
|
||
}
|
||
}
|
||
}
|
||
parents.sort((a, b) => b.len - a.len || a.key.localeCompare(b.key));
|
||
|
||
const out: WallEntry[] = [];
|
||
const seen = new Set<string>();
|
||
const covered = new Set<string>();
|
||
const tol = openEps(pitch, coordScale) * 4;
|
||
for (const parent of parents) {
|
||
const matches = atomic.filter((iv) => (
|
||
!covered.has(iv.key) && iv.cm === parent.cm &&
|
||
iv.ownerSignature === parent.ownerSignature &&
|
||
angleClose(segAngle(iv.a, iv.b), segAngle(parent.a, parent.b)) &&
|
||
distToSeg(iv.a[0], iv.a[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol &&
|
||
distToSeg(iv.b[0], iv.b[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol
|
||
));
|
||
if (!matches.length) continue;
|
||
for (const iv of matches) covered.add(iv.key);
|
||
if (seen.has(parent.key)) continue;
|
||
seen.add(parent.key);
|
||
out.push(wallEntry(parent.a, parent.b, parent.cm, pitch, coordScale));
|
||
}
|
||
for (const iv of atomic) {
|
||
if (covered.has(iv.key) || seen.has(iv.key)) continue;
|
||
seen.add(iv.key);
|
||
out.push(wallEntry(iv.a, iv.b, iv.cm, pitch, coordScale));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Effective thickness of the atomic interval that covers a segment's middle. */
|
||
export function intervalCmAt(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
seg: number[],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): number {
|
||
const eps = openEps(pitch, coordScale);
|
||
const mx = (seg[0] + seg[2]) / 2, my = (seg[1] + seg[3]) / 2;
|
||
const ang = segAngle([seg[0], seg[1]], [seg[2], seg[3]]);
|
||
let best: { cm: number; d: number } | null = null;
|
||
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
|
||
if (!angleClose(segAngle(iv.a, iv.b), ang)) continue;
|
||
const d = distToSeg(mx, my, iv.a[0], iv.a[1], iv.b[0], iv.b[1]);
|
||
if (d > eps * 4) continue;
|
||
if (!best || d < best.d) best = { cm: iv.cm, d };
|
||
}
|
||
return best?.cm || 0;
|
||
}
|
||
|
||
function distToSeg(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
|
||
const abx = bx - ax, aby = by - ay;
|
||
const L2 = abx * abx + aby * aby;
|
||
if (L2 < 1e-18) return Math.hypot(px - ax, py - ay);
|
||
let t = ((px - ax) * abx + (py - ay) * aby) / L2;
|
||
t = Math.max(0, Math.min(1, t));
|
||
return Math.hypot(px - (ax + abx * t), py - (ay + aby * t));
|
||
}
|
||
|
||
/**
|
||
* Per-room half-depth offsets (plan units) for inset/outset: every thick edge
|
||
* (shared or outer) → half; open/none → 0. docs/WALL-THICKNESS.md §2.
|
||
*/
|
||
export function insetOffsetsForRoom(
|
||
rooms: any[],
|
||
roomId: string,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): number[] {
|
||
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
return pr ? pr.offsets : [];
|
||
}
|
||
|
||
/** Alias — half offsets drive both inset and outset. */
|
||
export const halfOffsetsForRoom = insetOffsetsForRoom;
|
||
|
||
/**
|
||
* A bounded inward bevel may cross a neighbouring source edge when incident
|
||
* wall depths differ sharply. Keep only the part that is physically inside
|
||
* the room and return its largest outer ring; room consumers accept one simple
|
||
* contour and handle nested-room holes separately.
|
||
*/
|
||
function clipInnerContourToRoom(
|
||
contour: number[][],
|
||
room: number[][],
|
||
): number[][] | null {
|
||
try {
|
||
const clipped = intersection(
|
||
closedRing(contour) as any,
|
||
closedRing(room) as any,
|
||
);
|
||
return largestOuterContour(clipped);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Largest simple outer ring from polygon-clipping geometry. */
|
||
function largestOuterContour(geometry: any): number[][] | null {
|
||
let best: number[][] | null = null;
|
||
let bestArea = 0;
|
||
for (const polygon of geometry || []) {
|
||
const raw = polygon?.[0];
|
||
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||
const ring = raw.slice(0, -1).map((point: number[]) => [point[0], point[1]]);
|
||
const area = Math.abs(signedArea(ring));
|
||
if (ring.length >= 3 && area > bestArea) {
|
||
best = ring;
|
||
bestArea = area;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* Inner (clean-floor) contour of a room: inset by half wall thickness.
|
||
* Returns the original poly when there is no thickness.
|
||
*/
|
||
export function innerContourForRoom(
|
||
rooms: any[],
|
||
roomId: string,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
/** Canonical room-wall masonry before opening cuts; pass the render cache. */
|
||
sharedRoomWallGeometry?: any,
|
||
/** Canonical junction topology from the same wall-geometry pass. */
|
||
sharedMultiWallNodes?: MultiWallNodeMap | null,
|
||
): number[][] | null {
|
||
const room = (rooms || []).find((r) => r?.id === roomId);
|
||
const poly = roomPoly(room);
|
||
if (!poly || poly.length < 3) return null;
|
||
if (!walls?.length) return poly.map((p) => [p[0], p[1]]);
|
||
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr || !pr.offsets.some((o) => o > 0)) return poly.map((p) => [p[0], p[1]]);
|
||
const multiWallNodes = sharedMultiWallNodes || multiWallNodesForGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
|
||
if (!inset) return poly.map((p) => [p[0], p[1]]);
|
||
if (!multiWallNodes.nodes.length) return inset;
|
||
const computedWallGeometry = sharedRoomWallGeometry === undefined
|
||
? wallBodiesGeometry(rooms, walls, openCuts, [], pitch, cellCm, gridPitch, coordScale)
|
||
: null;
|
||
const roomWallGeometry = sharedRoomWallGeometry ?? (
|
||
computedWallGeometry?.status === 'ok' || computedWallGeometry?.status === 'degraded-extra'
|
||
? computedWallGeometry.roomGeom : undefined
|
||
);
|
||
if (roomWallGeometry) {
|
||
try {
|
||
const floor = difference(closedRing(pr.poly) as any, roomWallGeometry);
|
||
const contour = largestOuterContour(floor);
|
||
if (contour) return contour;
|
||
} catch {
|
||
// Fall through to the bounded contour clip; never return an outside tip.
|
||
}
|
||
}
|
||
return clipInnerContourToRoom(inset, pr.poly)
|
||
|| poly.map((p) => [p[0], p[1]]);
|
||
}
|
||
|
||
function closedRing(poly: number[][]): number[][][] {
|
||
const ring = poly.map((p) => [p[0], p[1]]);
|
||
ring.push([poly[0][0], poly[0][1]]);
|
||
return [ring];
|
||
}
|
||
|
||
function structurallyValidWallGeometry(geometry: any): boolean {
|
||
if (!Array.isArray(geometry) || !geometry.length) return false;
|
||
return geometry.every((polygon: any) => Array.isArray(polygon) && polygon.length
|
||
&& polygon.every((ring: any) => Array.isArray(ring) && ring.length >= 4
|
||
&& ring.every((point: any) => Array.isArray(point) && point.length >= 2
|
||
&& Number.isFinite(point[0]) && Number.isFinite(point[1])))
|
||
&& Math.abs(polygonArea(polygon[0])) > 1e-9);
|
||
}
|
||
|
||
interface MultiWallRoomRing {
|
||
outset: number[][];
|
||
inset: number[][] | null;
|
||
}
|
||
|
||
/** Excess pairwise overlap cuts removed to expose the straight bevel. */
|
||
function multiWallBevelCutsAt(
|
||
map: MultiWallNodeMap | null | undefined,
|
||
retainToLimit: boolean,
|
||
connectToExterior = false,
|
||
): number[][][] {
|
||
if (!map) return [];
|
||
const cuts: number[][][] = [];
|
||
for (const node of map.nodes) {
|
||
for (let i = 0; i < node.rays.length; i++) {
|
||
const a = node.rays[i], b = node.rays[(i + 1) % node.rays.length];
|
||
const angleA = Math.atan2(a.u[1], a.u[0]);
|
||
let angleB = Math.atan2(b.u[1], b.u[0]);
|
||
while (angleB <= angleA) angleB += Math.PI * 2;
|
||
const gap = angleB - angleA;
|
||
if (!(gap > 1e-9) || gap >= Math.PI - 1e-9) continue;
|
||
const nA = [-a.u[1], a.u[0]];
|
||
const nB = [-b.u[1], b.u[0]];
|
||
const pA = [
|
||
node.point[0] + nA[0] * a.halfDepth,
|
||
node.point[1] + nA[1] * a.halfDepth,
|
||
];
|
||
const pB = [
|
||
node.point[0] - nB[0] * b.halfDepth,
|
||
node.point[1] - nB[1] * b.halfDepth,
|
||
];
|
||
const hit = lineIntersect(pA, a.u, pB, b.u);
|
||
if (!hit) continue;
|
||
const distance = Math.hypot(
|
||
hit[0] - node.point[0], hit[1] - node.point[1],
|
||
);
|
||
if (!Number.isFinite(distance) || distance <= node.limit) continue;
|
||
// Canonical masonry and its exterior paper retain pairwise overlap up
|
||
// to R so ordinary right-angle arms stay area-connected. Starting the
|
||
// cut at the offset origins removes valid exterior half-wall material.
|
||
const advanceA = retainToLimit ? Math.sqrt(Math.max(
|
||
0, node.limit * node.limit - a.halfDepth * a.halfDepth,
|
||
)) : 0;
|
||
const advanceB = retainToLimit ? Math.sqrt(Math.max(
|
||
0, node.limit * node.limit - b.halfDepth * b.halfDepth,
|
||
)) : 0;
|
||
const qA = [pA[0] + a.u[0] * advanceA, pA[1] + a.u[1] * advanceA];
|
||
const qB = [pB[0] + b.u[0] * advanceB, pB[1] + b.u[1] * advanceB];
|
||
const cut = stableJunctionPatch([qA, qB, hit], map.coordinateScale);
|
||
if (cut) cuts.push(cut);
|
||
if (connectToExterior) {
|
||
// The two offset faces meet at `hit`, so a cut that ends exactly there
|
||
// only touches the exterior at one mathematical point. Polygon
|
||
// clipping and SVG then quite correctly retain it as an enclosed hole
|
||
// (the white junction triangles from #272). Add a small physical
|
||
// corridor across the tip into the already-empty angular sector so
|
||
// the cut has a finite-width exit without changing the approved R
|
||
// endpoints.
|
||
const dx = hit[0] - node.point[0], dy = hit[1] - node.point[1];
|
||
const length = Math.hypot(dx, dy);
|
||
const clearance = distance - node.limit;
|
||
if (length > map.epsilon && clearance > map.epsilon) {
|
||
const bridge = Math.min(
|
||
Math.max(map.epsilon * 8, node.halfDepth * 0.05),
|
||
clearance * 0.25,
|
||
);
|
||
const ux = dx / length, uy = dy / length;
|
||
const vx = -uy, vy = ux;
|
||
// This tapered connector is a strict subset of the original square
|
||
// corridor: it keeps the same finite cross-section at `hit` and the
|
||
// same exterior reach, but avoids two extra contour corners in every
|
||
// large-plan junction path.
|
||
const connector = stableJunctionPatch([
|
||
[hit[0] - ux * bridge + vx * bridge,
|
||
hit[1] - uy * bridge + vy * bridge],
|
||
[hit[0] - ux * bridge - vx * bridge,
|
||
hit[1] - uy * bridge - vy * bridge],
|
||
[hit[0] + ux * bridge, hit[1] + uy * bridge],
|
||
], map.coordinateScale);
|
||
if (connector) cuts.push(connector);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return cuts;
|
||
}
|
||
|
||
export function multiWallBevelTriangles(
|
||
map: MultiWallNodeMap | null | undefined,
|
||
): number[][][] {
|
||
return multiWallBevelCutsAt(map, true);
|
||
}
|
||
|
||
/**
|
||
* Collapse local cut patches before subtracting them from a large wall body.
|
||
*
|
||
* `A − p1 − p2 ...` is geometrically identical to `A − union(p1, p2,
|
||
* ...)`, but the latter traverses the large subject only once. The exterior
|
||
* connectors from #272 made the former path repeat that expensive traversal
|
||
* for every bevel sector. Keep malformed patches isolated just like the old
|
||
* per-patch subtraction loop did.
|
||
*/
|
||
function multiWallCutGeometry(cuts: number[][][]): any {
|
||
let geometry: any = null;
|
||
for (const cut of cuts) {
|
||
const piece: any = closedRing(cut) as any;
|
||
try {
|
||
geometry = geometry ? union(geometry, piece) : piece;
|
||
} catch {
|
||
// One unusable local patch must not discard the remaining valid cuts.
|
||
}
|
||
}
|
||
return geometry;
|
||
}
|
||
|
||
/** Rays whose finite strips must survive every bevel cut at this node (#275). */
|
||
export function multiWallProtectedRayIndexes(
|
||
node: MultiWallNode,
|
||
dotEpsilon = MULTI_WALL_ORTHOGONAL_DOT_EPSILON,
|
||
): number[] {
|
||
const protectedRays = new Set<number>();
|
||
const epsilon = Number.isFinite(dotEpsilon) && dotEpsilon >= 0
|
||
? dotEpsilon
|
||
: MULTI_WALL_ORTHOGONAL_DOT_EPSILON;
|
||
for (let i = 0; i < node.rays.length; i++) {
|
||
for (let j = i + 1; j < node.rays.length; j++) {
|
||
const a = node.rays[i].u, b = node.rays[j].u;
|
||
const dot = Math.abs(a[0] * b[0] + a[1] * b[1]);
|
||
if (dot <= epsilon) {
|
||
protectedRays.add(i);
|
||
protectedRays.add(j);
|
||
}
|
||
}
|
||
}
|
||
return [...protectedRays].sort((a, b) => a - b);
|
||
}
|
||
|
||
function multiWallRayStripGeometry(
|
||
node: MultiWallNode,
|
||
map: MultiWallNodeMap,
|
||
extent: number,
|
||
rayIndexes?: readonly number[],
|
||
): any {
|
||
const selected = rayIndexes ? new Set(rayIndexes) : null;
|
||
let geometry: any = null;
|
||
for (let rayIndex = 0; rayIndex < node.rays.length; rayIndex++) {
|
||
if (selected && !selected.has(rayIndex)) continue;
|
||
const ray = node.rays[rayIndex];
|
||
const n = [-ray.u[1], ray.u[0]];
|
||
// A canonical direction may be owned by overlapping room intervals with
|
||
// different depth/length pairs. Preserve their exact finite union.
|
||
for (const support of ray.supports) {
|
||
const supportExtent = Math.min(extent, support.length);
|
||
if (!(supportExtent > map.epsilon)) continue;
|
||
const rectangle = stableJunctionPatch([
|
||
[node.point[0] + n[0] * support.halfDepth,
|
||
node.point[1] + n[1] * support.halfDepth],
|
||
[node.point[0] + ray.u[0] * supportExtent + n[0] * support.halfDepth,
|
||
node.point[1] + ray.u[1] * supportExtent + n[1] * support.halfDepth],
|
||
[node.point[0] + ray.u[0] * supportExtent - n[0] * support.halfDepth,
|
||
node.point[1] + ray.u[1] * supportExtent - n[1] * support.halfDepth],
|
||
[node.point[0] - n[0] * support.halfDepth,
|
||
node.point[1] - n[1] * support.halfDepth],
|
||
], map.coordinateScale);
|
||
if (!rectangle) continue;
|
||
const piece: any = closedRing(rectangle) as any;
|
||
geometry = geometry ? union(geometry, piece) : piece;
|
||
}
|
||
}
|
||
return geometry;
|
||
}
|
||
|
||
/**
|
||
* Shared masonry that begins at the real far endpoint of a short incident ray.
|
||
*
|
||
* It is not one of this node's rays and must never be rebuilt as one, but the
|
||
* node-wide replacement mask may overlap it. Keeping the exact finite
|
||
* continuation here prevents that mask from deleting a neighbouring shared
|
||
* wall while leaving unrelated/crossing room-ring material under the existing
|
||
* bevel rules.
|
||
*/
|
||
function multiWallContinuationStripGeometry(
|
||
node: MultiWallNode,
|
||
map: MultiWallNodeMap,
|
||
maskGeometry: any,
|
||
): any {
|
||
let geometry: any = null;
|
||
for (const ray of node.rays) {
|
||
for (const continuation of ray.continuations) {
|
||
const n = [-continuation.u[1], continuation.u[0]];
|
||
const end = [
|
||
continuation.start[0] + continuation.u[0] * continuation.length,
|
||
continuation.start[1] + continuation.u[1] * continuation.length,
|
||
];
|
||
const rectangle = stableJunctionPatch([
|
||
[continuation.start[0] + n[0] * continuation.halfDepth,
|
||
continuation.start[1] + n[1] * continuation.halfDepth],
|
||
[end[0] + n[0] * continuation.halfDepth,
|
||
end[1] + n[1] * continuation.halfDepth],
|
||
[end[0] - n[0] * continuation.halfDepth,
|
||
end[1] - n[1] * continuation.halfDepth],
|
||
[continuation.start[0] - n[0] * continuation.halfDepth,
|
||
continuation.start[1] - n[1] * continuation.halfDepth],
|
||
], map.coordinateScale);
|
||
if (!rectangle) continue;
|
||
const piece = intersection(closedRing(rectangle) as any, maskGeometry);
|
||
if (!Array.isArray(piece) || piece.length === 0) continue;
|
||
geometry = geometry ? union(geometry, piece) : piece;
|
||
}
|
||
}
|
||
return geometry;
|
||
}
|
||
|
||
/** Finite local strips protected by at least one perpendicular partner. */
|
||
export function multiWallProtectedStripGeometry(
|
||
node: MultiWallNode,
|
||
map: MultiWallNodeMap,
|
||
extent = (MITRE_LIMIT * node.halfDepth + map.epsilon * 2) * 2,
|
||
): any {
|
||
const protectedRays = multiWallProtectedRayIndexes(node);
|
||
return protectedRays.length
|
||
? multiWallRayStripGeometry(node, map, extent, protectedRays)
|
||
: null;
|
||
}
|
||
|
||
function multiWallProtectedMapGeometry(map: MultiWallNodeMap): any {
|
||
let geometry: any = null;
|
||
for (const node of map.nodes) {
|
||
const protectedStrips = multiWallProtectedStripGeometry(node, map);
|
||
if (protectedStrips) {
|
||
geometry = geometry ? union(geometry, protectedStrips) : protectedStrips;
|
||
}
|
||
}
|
||
return geometry;
|
||
}
|
||
|
||
function multiWallEffectiveCutGeometry(
|
||
node: MultiWallNode,
|
||
map: MultiWallNodeMap,
|
||
retainToLimit: boolean,
|
||
connectToExterior: boolean,
|
||
protectedStrips: any,
|
||
): any {
|
||
const nodeMap = { ...map, nodes: [node] };
|
||
const cuts = multiWallCutGeometry(
|
||
multiWallBevelCutsAt(nodeMap, retainToLimit, connectToExterior),
|
||
);
|
||
return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;
|
||
}
|
||
|
||
function bevelMultiWallBody(
|
||
body: any,
|
||
map: MultiWallNodeMap,
|
||
centre?: any,
|
||
envelope?: any,
|
||
): any {
|
||
if (!body || !map.nodes.length) return body;
|
||
let protectedStrips: any = null;
|
||
try {
|
||
// Node masks may overlap (a short wall can end inside both). Every local
|
||
// pass must therefore preserve the protected strips of neighbouring nodes,
|
||
// not only its own, or the later pass can erase the earlier repair.
|
||
protectedStrips = multiWallProtectedMapGeometry(map);
|
||
} catch {
|
||
// A bevel is optional. If its protection cannot be built, keep the complete
|
||
// pre-bevel body instead of risking another user-visible structural hole.
|
||
return body;
|
||
}
|
||
let current = body;
|
||
for (const node of map.nodes) {
|
||
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
|
||
const extent = radius * 2;
|
||
const mask = [
|
||
[node.point[0] - radius, node.point[1] - radius],
|
||
[node.point[0] + radius, node.point[1] - radius],
|
||
[node.point[0] + radius, node.point[1] + radius],
|
||
[node.point[0] - radius, node.point[1] + radius],
|
||
];
|
||
try {
|
||
let boundedCurrent = current;
|
||
const outerCuts = multiWallEffectiveCutGeometry(
|
||
node, map, false, true, protectedStrips,
|
||
);
|
||
if (outerCuts) boundedCurrent = difference(boundedCurrent, outerCuts);
|
||
let local = multiWallRayStripGeometry(node, map, extent);
|
||
const retainedCuts = multiWallEffectiveCutGeometry(
|
||
node, map, true, true, protectedStrips,
|
||
);
|
||
if (retainedCuts) {
|
||
// Rebuild the physical half-strips first, then remove only their
|
||
// excessive pairwise overlap. Applying this cut to the legacy room
|
||
// ring itself can delete an incident half-strip and strand floor.
|
||
local = difference(local, retainedCuts);
|
||
}
|
||
// The same protected material is restored after subtraction so boolean
|
||
// ordering/rounding cannot turn a right-angle wall into an open notch.
|
||
if (protectedStrips) local = union(local, protectedStrips);
|
||
// Rays share a mathematical endpoint. A tiny physical core turns that
|
||
// point contact into a stable polygon contact for boolean/render paths.
|
||
const coreRadius = Math.min(
|
||
...node.rays.map((ray) => ray.halfDepth),
|
||
) * 0.02;
|
||
local = union(local, closedRing([
|
||
[node.point[0] - coreRadius, node.point[1] - coreRadius],
|
||
[node.point[0] + coreRadius, node.point[1] - coreRadius],
|
||
[node.point[0] + coreRadius, node.point[1] + coreRadius],
|
||
[node.point[0] - coreRadius, node.point[1] + coreRadius],
|
||
]) as any);
|
||
if (!local) continue;
|
||
let localInside = intersection(local, closedRing(mask) as any);
|
||
// `envelope` is the bounded physical paper, including the exterior
|
||
// half-walls. Clipping the repair to the room-centre union first drops
|
||
// exactly the valid T-junction wedge this reconstruction must retain.
|
||
if (envelope) localInside = intersection(localInside, envelope);
|
||
else if (centre) localInside = intersection(localInside, centre);
|
||
const maskGeometry = closedRing(mask) as any;
|
||
const outside = difference(boundedCurrent, maskGeometry);
|
||
const preservedExterior = centre
|
||
? difference(intersection(boundedCurrent, maskGeometry), centre)
|
||
: null;
|
||
// The square replacement removes legacy mitre/room-ring material before
|
||
// rebuilding this node's finite rays. A short ray can end inside it and
|
||
// hand off to a shared wall that is not incident to this node; preserve
|
||
// that exact continuation, never a global square/radius projection.
|
||
const foreignFinite = multiWallContinuationStripGeometry(node, map, maskGeometry);
|
||
current = union(
|
||
outside,
|
||
...(preservedExterior ? [preservedExterior] : []),
|
||
...(foreignFinite ? [foreignFinite] : []),
|
||
localInside,
|
||
);
|
||
} catch {
|
||
// Isolate the failed node. Other valid nodes still receive their repair;
|
||
// mandatory surrounding structural failures remain fail-dark upstream.
|
||
}
|
||
}
|
||
if (protectedStrips) {
|
||
try {
|
||
let protectedInside = protectedStrips;
|
||
if (envelope) protectedInside = intersection(protectedInside, envelope);
|
||
else if (centre) protectedInside = intersection(protectedInside, centre);
|
||
current = union(current, protectedInside);
|
||
} catch {
|
||
// Per-node reconstruction above already retained the same material.
|
||
}
|
||
}
|
||
return current;
|
||
}
|
||
|
||
|
||
/**
|
||
* Collapse arithmetic noise on computed junction vertices before polyclip sees
|
||
* them. The quantum is relative to the caller coordinate scale and remains
|
||
* many orders of magnitude below the geometry epsilon: it must never snap a
|
||
* physical half-depth or mitre to the drawing grid.
|
||
*/
|
||
export function stableJunctionPatch(
|
||
patch: number[][],
|
||
coordScale = 1,
|
||
): number[][] | null {
|
||
if (!Array.isArray(patch) || patch.length < 3) return null;
|
||
const scale = Number.isFinite(coordScale) && coordScale > 0 ? coordScale : 1;
|
||
const quantum = Math.max(1, scale) * 1e-12;
|
||
const stable: number[][] = [];
|
||
for (const point of patch) {
|
||
if (!Array.isArray(point) || point.length < 2) return null;
|
||
const x = Number(point[0]), y = Number(point[1]);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||
const sx = Math.round(x / quantum) * quantum;
|
||
const sy = Math.round(y / quantum) * quantum;
|
||
if (!Number.isFinite(sx) || !Number.isFinite(sy)) return null;
|
||
stable.push([Object.is(sx, -0) ? 0 : sx, Object.is(sy, -0) ? 0 : sy]);
|
||
}
|
||
return Math.abs(signedArea(stable)) > quantum * quantum ? stable : null;
|
||
}
|
||
|
||
type JunctionUnion = (subject: any, clipping: any) => any;
|
||
|
||
/**
|
||
* Add optional virtual-junction patches transactionally. A single rejected
|
||
* patch may retain the local pre-patch contour, but it must not discard the
|
||
* already valid masonry for the entire space or prevent later patches.
|
||
*/
|
||
export function unionJunctionPatches(
|
||
body: any,
|
||
patches: number[][][],
|
||
coordScale = 1,
|
||
unionFn: JunctionUnion = union,
|
||
): any {
|
||
let current = body;
|
||
for (const raw of patches || []) {
|
||
const patch = stableJunctionPatch(raw, coordScale);
|
||
if (!patch) continue;
|
||
try {
|
||
const piece = closedRing(patch) as any;
|
||
const next = current ? unionFn(current, piece) : piece;
|
||
current = next;
|
||
} catch {
|
||
// Keep the last valid body and continue. Returning null here would erase
|
||
// every unrelated wall, floor/light barrier and successful later patch.
|
||
}
|
||
}
|
||
return current;
|
||
}
|
||
|
||
interface ExteriorEnvelopeGeometry {
|
||
/** Union of room centrelines. Shared Split edges disappear from this shape. */
|
||
centre: any;
|
||
/** Wall shell generated only from the surviving exterior boundary. */
|
||
shell: any;
|
||
}
|
||
|
||
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
|
||
function geometryRings(geom: any): number[][][] {
|
||
const out: number[][][] = [];
|
||
for (const polygon of Array.isArray(geom) ? geom : []) {
|
||
if (!Array.isArray(polygon)) continue;
|
||
for (const raw of polygon) {
|
||
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
|
||
if (ring.length >= 3) out.push(ring);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
|
||
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
|
||
const len2 = dx * dx + dy * dy;
|
||
const projectedEps = eps * Math.sqrt(len2);
|
||
return dot >= -projectedEps && dot <= len2 + projectedEps;
|
||
}
|
||
|
||
/**
|
||
* Split a boolean-union boundary at every stored exterior interval endpoint.
|
||
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
|
||
* interval breakpoints is what preserves unequal wall depths on the two sides.
|
||
*/
|
||
function exteriorBoundaryProfile(
|
||
ring: number[][],
|
||
outer: WallInterval[],
|
||
eps: number,
|
||
): { poly: number[][]; offsets: number[] } | null {
|
||
const poly: number[][] = [];
|
||
const offsets: number[] = [];
|
||
for (let i = 0; i < ring.length; i++) {
|
||
const a = ring[i], b = ring[(i + 1) % ring.length];
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const len2 = dx * dx + dy * dy;
|
||
if (!(len2 > eps * eps)) continue;
|
||
// `t` below is a dimensionless fraction of this edge, while `eps` is a
|
||
// render-space distance. Comparing them directly drops every interior cut
|
||
// on production-scale plans (for example eps ~= 0.67 at coordScale=1000).
|
||
// Convert the shared geometry tolerance to the edge's local 0..1 domain.
|
||
const tEps = eps / Math.sqrt(len2);
|
||
const cuts = [0, 1];
|
||
for (const iv of outer) {
|
||
for (const p of [iv.a, iv.b]) {
|
||
if (!pointOnSegment(p, a, b, eps)) continue;
|
||
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
||
if (t > tEps && t < 1 - tEps) cuts.push(t);
|
||
}
|
||
}
|
||
cuts.sort((x, y) => x - y);
|
||
const unique = cuts.filter(
|
||
(t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > tEps,
|
||
);
|
||
for (let at = 0; at < unique.length - 1; at++) {
|
||
const t0 = unique[at], t1 = unique[at + 1];
|
||
const p = [a[0] + dx * t0, a[1] + dy * t0];
|
||
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
|
||
let half = 0;
|
||
for (const iv of outer) {
|
||
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
|
||
}
|
||
poly.push(p);
|
||
offsets.push(half);
|
||
}
|
||
}
|
||
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
|
||
}
|
||
|
||
/**
|
||
* Exterior masonry is derived from the union of room centrelines, not from
|
||
* each room independently. A Split edge therefore vanishes before mitres are
|
||
* built and cannot turn its artificial child corner into part of the facade.
|
||
*/
|
||
function exteriorEnvelopeGeometry(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale: number,
|
||
sharedMultiWallNodes?: MultiWallNodeMap | null,
|
||
): ExteriorEnvelopeGeometry | null {
|
||
const polys = (rooms || []).map(roomPoly)
|
||
.filter((p): p is number[][] => !!p && p.length >= 3);
|
||
if (!polys.length) return null;
|
||
let centre: any = union(closedRing(polys[0]) as any);
|
||
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
|
||
|
||
const intervals = wallIntervals(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
const outer = intervals.filter((iv) => iv.kind === 'outer' && iv.half > 0);
|
||
const eps = openEps(pitch, coordScale) * 4;
|
||
const multiWallNodes = sharedMultiWallNodes
|
||
|| buildMultiWallNodeMap(intervals, eps, coordScale);
|
||
let shell: any = null;
|
||
for (const ring of geometryRings(centre)) {
|
||
const profile = exteriorBoundaryProfile(ring, outer, eps);
|
||
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
|
||
const outset = outsetContour(profile.poly, profile.offsets, multiWallNodes);
|
||
const inset = insetContour(profile.poly, profile.offsets, multiWallNodes);
|
||
if (!outset || !inset) continue;
|
||
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
|
||
shell = shell ? union(shell, piece) : piece;
|
||
}
|
||
return {
|
||
centre,
|
||
shell: shell || [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Canonical Stage floor footprint: room union plus derived exterior masonry.
|
||
* Independent partitions/columns are deliberately not accepted here, so they
|
||
* can never enlarge the slab perimeter. Null is a boolean failure; an empty
|
||
* array is a valid space without room geometry.
|
||
*/
|
||
export function floorFootprintGeometry(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): any | null {
|
||
try {
|
||
const multiWallNodes = multiWallNodesForGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
const exterior = exteriorEnvelopeGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
|
||
);
|
||
if (!exterior) return [];
|
||
const paper = exterior.shell?.length
|
||
? union(exterior.centre, exterior.shell)
|
||
: exterior.centre;
|
||
return paper;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drop zero-area rings a boolean union leaves where two chords coincide
|
||
* exactly (fan chord over chamfer chord). They paint nothing, but they are
|
||
* topological holes and every downstream ring-counting consumer sees them.
|
||
*/
|
||
function dropDegenerateRings(geom: any, areaEps: number): any {
|
||
if (!Array.isArray(geom)) return geom;
|
||
const polygons = geom
|
||
.map((polygon: any) => {
|
||
if (!Array.isArray(polygon) || !polygon.length) return polygon;
|
||
const [outer, ...holes] = polygon;
|
||
if (Math.abs(signedArea(outer || [])) <= areaEps) return null;
|
||
return [outer, ...holes.filter(
|
||
(ring: number[][]) => Math.abs(signedArea(ring || [])) > areaEps,
|
||
)];
|
||
})
|
||
.filter((polygon: any) => !!polygon);
|
||
return polygons;
|
||
}
|
||
|
||
/**
|
||
* The approved outer boundary for node pieces: the building footprint plus
|
||
* the exterior wall band with PLAIN corners — the very shape the contour had
|
||
* before any node existed. Fans and support tips are clipped to it, so the
|
||
* node can never grow new facade (the concave-Split contract), while the
|
||
* plain corners — unlike the node-notched envelope — never reopen the sector
|
||
* holes the pieces exist to close.
|
||
*/
|
||
export function junctionNodeBound(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale: number,
|
||
map: MultiWallNodeMap,
|
||
): any | null {
|
||
try {
|
||
const plain: MultiWallNodeMap = {
|
||
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
|
||
nodes: [], index: new Map(),
|
||
};
|
||
const exterior = exteriorEnvelopeGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, plain,
|
||
);
|
||
if (!exterior) return null;
|
||
return exterior.shell?.length
|
||
? union(exterior.centre, exterior.shell)
|
||
: exterior.centre;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
|
||
export function polyclipToPathD(geom: any): string {
|
||
if (!geom) return '';
|
||
let d = '';
|
||
// polyclip Geom: MultiPolygon = Polygon[]; Polygon = Ring[] where ring[0]
|
||
// is the outer and ring[1..] are holes. We must emit EVERY ring so evenodd
|
||
// fill punches the floor out of the wall body (otherwise a single-room
|
||
// outset fills solid — the whole room looks like hatch).
|
||
for (const poly of geom as any[]) {
|
||
if (!Array.isArray(poly)) continue;
|
||
for (const ring of poly) {
|
||
if (!Array.isArray(ring) || ring.length < 4) continue;
|
||
const pts = ring.slice(0, ring.length - 1);
|
||
if (pts.length < 3) continue;
|
||
d += (d ? ' ' : '') + polyToPath(pts.map((p: number[]) => [p[0], p[1]]));
|
||
}
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* Mitre patches at an endpoint where a virtual stretch meets real walls that
|
||
* belong to different room contours.
|
||
*
|
||
* The normal per-room rings can only join adjacent thick edges of ONE room.
|
||
* At a virtual T, the two real arms may be owned by two point-touching rooms;
|
||
* each ring then ends with a butt cap and their union leaves a stair-step at
|
||
* the outer corner. The patch is the missing offset-line parallelogram. It is
|
||
* restricted to open-span endpoints, so ordinary corners keep the existing
|
||
* contour/mitre/bevel implementation unchanged.
|
||
*/
|
||
export function virtualJunctionPatches(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale: number,
|
||
sharedMultiWallNodes?: MultiWallNodeMap | null,
|
||
): number[][][] {
|
||
if (!walls?.length || !openCuts?.length) return [];
|
||
const eps = openEps(pitch, coordScale) * 4;
|
||
const unique = new Map<string, WallInterval>();
|
||
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
|
||
if (iv.open || !(iv.half > 0) || unique.has(iv.key)) continue;
|
||
unique.set(iv.key, iv);
|
||
}
|
||
const intervals = [...unique.values()];
|
||
if (intervals.length < 2) return [];
|
||
const multiWallNodes = sharedMultiWallNodes
|
||
|| buildMultiWallNodeMap(intervals, eps);
|
||
|
||
const nodes: number[][] = [];
|
||
for (const cut of openCuts) {
|
||
for (const p of [[cut[0], cut[1]], [cut[2], cut[3]]]) {
|
||
if (!nodes.some((q) => Math.hypot(q[0] - p[0], q[1] - p[1]) <= eps)) nodes.push(p);
|
||
}
|
||
}
|
||
const out: number[][][] = [];
|
||
const awayFrom = (iv: WallInterval, v: number[]): number[] | null => {
|
||
let dx = 0, dy = 0;
|
||
if (Math.hypot(iv.a[0] - v[0], iv.a[1] - v[1]) <= eps) {
|
||
dx = iv.b[0] - iv.a[0]; dy = iv.b[1] - iv.a[1];
|
||
} else if (Math.hypot(iv.b[0] - v[0], iv.b[1] - v[1]) <= eps) {
|
||
dx = iv.a[0] - iv.b[0]; dy = iv.a[1] - iv.b[1];
|
||
} else {
|
||
return null;
|
||
}
|
||
const L = Math.hypot(dx, dy);
|
||
return L > eps ? [dx / L, dy / L] : null;
|
||
};
|
||
|
||
for (const v of nodes) {
|
||
const touching = intervals
|
||
.map((iv) => ({ iv, u: awayFrom(iv, v) }))
|
||
.filter((x): x is { iv: WallInterval; u: number[] } => !!x.u);
|
||
for (let i = 0; i < touching.length; i++) {
|
||
for (let j = i + 1; j < touching.length; j++) {
|
||
const a = touching[i], b = touching[j];
|
||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||
const sin = Math.abs(cross);
|
||
if (sin < 1e-3) continue; // one straight wall, no corner to fill
|
||
const da = b.iv.half / sin;
|
||
const db = a.iv.half / sin;
|
||
const pa = [v[0] - a.u[0] * da, v[1] - a.u[1] * da];
|
||
const pb = [v[0] - b.u[0] * db, v[1] - b.u[1] * db];
|
||
const far = [pa[0] + pb[0] - v[0], pa[1] + pb[1] - v[1]];
|
||
const maxHalf = Math.max(a.iv.half, b.iv.half, 1e-9);
|
||
const multiNode = multiWallNodeAt(multiWallNodes, v);
|
||
const limit = multiNode?.limit ?? MITRE_LIMIT * maxHalf;
|
||
const farDistance = Math.hypot(far[0] - v[0], far[1] - v[1]);
|
||
let patch: number[][];
|
||
if (farDistance <= limit) {
|
||
patch = cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa];
|
||
} else if (multiNode) {
|
||
const nA = [-a.u[1], a.u[0]];
|
||
const nB = [-b.u[1], b.u[0]];
|
||
const sign = cross < 0 ? 1 : -1;
|
||
const edgeA = [
|
||
v[0] + nA[0] * a.iv.half * sign,
|
||
v[1] + nA[1] * a.iv.half * sign,
|
||
];
|
||
const edgeB = [
|
||
v[0] - nB[0] * b.iv.half * sign,
|
||
v[1] - nB[1] * b.iv.half * sign,
|
||
];
|
||
patch = cross > 0 ? [v.slice(), edgeA, edgeB] : [v.slice(), edgeB, edgeA];
|
||
} else {
|
||
// Preserve the exact two-ray contract: an over-limit legacy mitre
|
||
// produces no virtual patch, just as before #249.
|
||
continue;
|
||
}
|
||
if (Math.abs(signedArea(patch)) > eps * eps) out.push(patch);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* One evenodd ring path per room: outset(half) − inset(half). Shared walls
|
||
* meet as two half-rings; callers may union them via wallBodiesUnionPath.
|
||
*/
|
||
export function wallBodyRings(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): WallBodyPath[] {
|
||
if (!walls?.length) return [];
|
||
const out: WallBodyPath[] = [];
|
||
const multiWallNodes = multiWallNodesForGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
|
||
const outset = outsetContour(pr.poly, pr.offsets, multiWallNodes);
|
||
const inset = insetContour(pr.poly, pr.offsets, multiWallNodes);
|
||
if (!outset || !inset) continue;
|
||
const d = `${polyToPath(outset)} ${polyToPath(reversePoly(inset))}`;
|
||
let key = '';
|
||
let kind: WallKind = 'outer';
|
||
let cm = 0;
|
||
let depth = 0;
|
||
for (let i = 0; i < pr.poly.length; i++) {
|
||
if (!(pr.offsets[i] > 0)) continue;
|
||
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
|
||
key = keyOf(a, b, pitch, coordScale);
|
||
kind = pr.kinds[i] || 'outer';
|
||
cm = pr.cms[i];
|
||
depth = wallCmToUnits(cm, cellCm, gridPitch);
|
||
break;
|
||
}
|
||
out.push({ d, key, kind, cm, depthUnits: depth });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Seamless wall hatch: union of each room's own outset-minus-inset wall ring,
|
||
* with opening slots cut as holes. One continuous body across L and T joins.
|
||
*
|
||
* Do not rewrite this as `(union outsets) - (union insets)`: subtraction does
|
||
* not distribute over union. In a nested/complex layout the clean floor of one
|
||
* room would then erase a wall owned by another room, leaving half-depth strips
|
||
* and tiny holes at junctions.
|
||
*/
|
||
/**
|
||
* Corners at or above this angle are ordinary sharp pairs (#310). Below it a
|
||
* write is refused today (#329 П1), so only a legacy document can still hold
|
||
* such an apex, and only it takes the degenerate path below.
|
||
*/
|
||
export const DEGENERATE_APEX_MAX_DEGREES = 15;
|
||
|
||
/**
|
||
* True when corner `index` is so sharp that the two wall bodies overlap
|
||
* completely near it (#329 §4): their inner faces converge at
|
||
* `h/tan(θ/2)`, inside BOTH edges, so the zone above that point holds no
|
||
* room interior at all. Rendering it with the ordinary bevel produced the
|
||
* "trident" of #329 — pikes and V-notches instead of a tip. Such a corner
|
||
* ends in ONE point on both faces instead: the plan vertex outside, its own
|
||
* mitre inside. A corner whose convergence lies outside the walls is an
|
||
* ordinary sharp pair (#310) and is never degenerate.
|
||
*/
|
||
export function isDegenerateApexCorner(
|
||
poly: number[][], offsets: number[], index: number,
|
||
): boolean {
|
||
const n = poly?.length || 0;
|
||
if (n < 3 || offsets?.length !== n) return false;
|
||
const previous = poly[(index - 1 + n) % n];
|
||
const vertex = poly[index];
|
||
const next = poly[(index + 1) % n];
|
||
const toPrev = [previous[0] - vertex[0], previous[1] - vertex[1]];
|
||
const toNext = [next[0] - vertex[0], next[1] - vertex[1]];
|
||
const lenPrev = Math.hypot(toPrev[0], toPrev[1]);
|
||
const lenNext = Math.hypot(toNext[0], toNext[1]);
|
||
if (!(lenPrev > 1e-9) || !(lenNext > 1e-9)) return false;
|
||
const cos = Math.max(-1, Math.min(1,
|
||
(toPrev[0] * toNext[0] + toPrev[1] * toNext[1]) / (lenPrev * lenNext)));
|
||
const theta = Math.acos(cos);
|
||
if (!(theta > 1e-9)) return false;
|
||
if (theta >= (DEGENERATE_APEX_MAX_DEGREES * Math.PI) / 180) return false;
|
||
const halfPrev = Math.max(0, offsets[(index - 1 + n) % n]);
|
||
const halfNext = Math.max(0, offsets[index]);
|
||
if (!(halfPrev > 0) && !(halfNext > 0)) return false;
|
||
// #339: the inner faces sit halfPrev and halfNext away from their own
|
||
// edges; the true meeting point of two such lines lands at
|
||
// (hOther + hOwn·cosθ)/sinθ along each edge. The corner is degenerate only
|
||
// when the meeting point lies INSIDE both edges. With equal halves this
|
||
// reduces algebraically to the old h/tan(θ/2) — which, taken as
|
||
// max(h₁,h₂)/tan(θ/2) for mixed thicknesses, overstated the distance and
|
||
// left a 15+30 cm legacy apex rendering as the #329 trident.
|
||
const sin = Math.sin(theta);
|
||
if (!(sin > 1e-12)) return false;
|
||
const alongPrev = (halfNext + halfPrev * cos) / sin;
|
||
const alongNext = (halfPrev + halfNext * cos) / sin;
|
||
return Number.isFinite(alongPrev) && Number.isFinite(alongNext)
|
||
&& alongPrev > 0 && alongNext > 0
|
||
&& alongPrev < lenPrev - 1e-9 && alongNext < lenNext - 1e-9;
|
||
}
|
||
|
||
/**
|
||
* The masonry itself, as polygons: room wall rings joined at their mitres,
|
||
* with opening slots cut through. Drawing uses it as one path; the light model
|
||
* uses the same geometry as its occluders, so a wall blocks light exactly
|
||
* where the plan shows a wall — with its real thickness, and with a doorway
|
||
* that is a real gap between two jamb faces. A successful empty operation is
|
||
* returned as an empty typed result. Core failure is `failed-core`; an optional
|
||
* merge failure is `degraded-extra` with every known-valid component retained.
|
||
*/
|
||
export function wallBodiesGeometry(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
extraBodies: number[][][] = [],
|
||
operations: WallGeometryOperations = {},
|
||
): WallBodiesGeometryResult {
|
||
if (!walls?.length && !extraBodies.length) return {
|
||
status: 'not-applicable', geom: [], components: [], roomGeom: [], paperGeom: [],
|
||
roomComponents: [],
|
||
depthUnits: 0, openingIndex: null, multiWallNodes: null, degradedExtraCount: 0,
|
||
};
|
||
const roomRings: MultiWallRoomRing[] = [];
|
||
const multiWallNodes = multiWallNodesForGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
let maxDepth = 0;
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr || pr.poly.length < 3 || !pr.offsets.some((o) => o > 0)) continue;
|
||
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
|
||
const outC = outsetContour(pr.poly, pr.offsets, multiWallNodes);
|
||
const inC = insetContour(pr.poly, pr.offsets, multiWallNodes);
|
||
if (!outC) continue;
|
||
roomRings.push({ outset: outC, inset: inC });
|
||
}
|
||
for (const body of extraBodies) {
|
||
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
|
||
if (xs.length) {
|
||
const bboxDepth = Math.min(
|
||
Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
|
||
// A 96-gon is a circle and its bbox is the diameter. Four-point bodies
|
||
// are partitions/square columns; their shortest edge is the real depth,
|
||
// whereas a rotated bbox exaggerates it and incorrectly enables hatch.
|
||
const edgeDepth = Math.min(...body.map((p, i) => {
|
||
const q = body[(i + 1) % body.length];
|
||
return Math.hypot(q[0] - p[0], q[1] - p[1]);
|
||
}));
|
||
maxDepth = Math.max(maxDepth, body.length > 16 ? bboxDepth : edgeDepth);
|
||
}
|
||
}
|
||
const junctions = virtualJunctionPatches(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
|
||
);
|
||
const openingIndex = openings.length
|
||
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
|
||
: null;
|
||
let corePhase = 'exterior';
|
||
try {
|
||
const exterior = exteriorEnvelopeGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, multiWallNodes,
|
||
);
|
||
// Paper and masonry share this one structural pass. Renderers cache the
|
||
// returned pair, so a live HA state update never repeats exterior topology.
|
||
corePhase = 'paper';
|
||
const rawPaperGeom = exterior
|
||
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
|
||
: [];
|
||
// Paper: the approved chamfer first, then the same additive fans that
|
||
// complete the masonry corner complete the paper beneath it (#302).
|
||
// Paper needs no node pieces: the footprint-plus-shell union already
|
||
// covers every junction (measured on #197 and the owner repro — byte-equal
|
||
// with and without them), and with the subtractive paper bevel gone the
|
||
// #261 white-wedge class is impossible by construction.
|
||
const paperGeom = rawPaperGeom;
|
||
const bodyOf = (ring: typeof roomRings[number]): any => {
|
||
const outset: any = closedRing(ring.outset);
|
||
if (!ring.inset) return outset;
|
||
// #329: the interior now ends in its own apex point (insetContour),
|
||
// so the ring is a plain difference — no fold to clip, no slivers.
|
||
const hole: any = closedRing(ring.inset);
|
||
return difference(outset, hole);
|
||
};
|
||
corePhase = 'room-rings';
|
||
let body: any = null;
|
||
for (const ring of roomRings) {
|
||
try {
|
||
const piece = bodyOf(ring);
|
||
body = body ? union(body, piece) : piece;
|
||
} catch {
|
||
// An acute child contour may be invalid for boolean subtraction. The
|
||
// interval pass below still supplies its physical wall without letting
|
||
// the artificial mitre back into the exterior envelope.
|
||
}
|
||
}
|
||
// Per-room rings preserve established L/T/nested joins. Atomic quads are
|
||
// also included so a rejected acute child ring cannot remove a divider or
|
||
// an interior half-wall. Clipping them to the centre union gives a hard
|
||
// facade boundary; the canonical exterior shell is added afterwards.
|
||
corePhase = 'edge-bodies';
|
||
if (exterior) {
|
||
for (const edge of wallEdgeBodies(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
)) {
|
||
try {
|
||
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
|
||
body = body ? union(body, piece) : piece;
|
||
} catch {
|
||
// A valid per-room ring may already own this interval. If neither
|
||
// representation is usable the final result fails closed below.
|
||
}
|
||
}
|
||
}
|
||
// The room-ring subtraction above cannot infer a mitre between real arms
|
||
// owned by different contours at a virtual T. Add only those missing
|
||
// junction pieces, then let physical openings cut through them as usual.
|
||
corePhase = 'junctions';
|
||
body = unionJunctionPatches(body, junctions, coordScale);
|
||
corePhase = 'facade-clip';
|
||
if (body && exterior) body = intersection(body, exterior.centre);
|
||
corePhase = 'exterior-shell';
|
||
const isolatedCore: WallGeometryComponent[] = [];
|
||
let degradedCoreCount = 0;
|
||
if (exterior?.shell?.length) {
|
||
if (!body) body = exterior.shell;
|
||
else {
|
||
try {
|
||
const merged = union(body, exterior.shell);
|
||
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid shell union');
|
||
body = merged;
|
||
} catch {
|
||
// Both mandatory halves exist; only their boolean merge failed.
|
||
// Preserve them as non-cancelling components for read-only render,
|
||
// while strict writers still reject the degraded structural result.
|
||
if (!structurallyValidWallGeometry(body)
|
||
|| !structurallyValidWallGeometry(exterior.shell)) throw new Error('invalid shell');
|
||
isolatedCore.push({ id: 'exterior-shell', geom: exterior.shell });
|
||
degradedCoreCount++;
|
||
}
|
||
}
|
||
}
|
||
// The old bevel layer survives only as a TARGETED lateral trim: it
|
||
// removes the ring material a base contour paints past a degenerately
|
||
// short thick support (#271) — something no additive piece can undo. It
|
||
// runs ONLY on nodes that actually have such a support: everywhere else
|
||
// it used to leave the steps and horns the owner rejected (decision #5),
|
||
// and the node stays purely additive.
|
||
corePhase = 'multi-wall-trim';
|
||
if (body && multiWallNodes.nodes.length) {
|
||
const needsTrim = (node: MultiWallNode): boolean => node.rays.some(
|
||
(ray) => ray.supports.some(
|
||
(support) => support.length < support.halfDepth * 2,
|
||
),
|
||
);
|
||
const trimNodes = multiWallNodes.nodes.filter(needsTrim);
|
||
if (trimNodes.length) {
|
||
const trimMap: MultiWallNodeMap = {
|
||
...multiWallNodes, nodes: trimNodes,
|
||
};
|
||
body = bevelMultiWallBody(body, trimMap, exterior?.centre, paperGeom);
|
||
}
|
||
}
|
||
// Then the node gets its additive corners: the exact support quads of its
|
||
// rays — each bounded by its own finite length, so the trimmed lateral
|
||
// phantom cannot come back — and one mitre/bevel fan per pair of
|
||
// angularly adjacent rays, bounded by the classic MITRE_LIMIT.
|
||
corePhase = 'junction-corners';
|
||
if (multiWallNodes.nodes.length) {
|
||
const corners = junctionNodeGeometry(multiWallNodes);
|
||
const bound = junctionNodeBound(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
multiWallNodes,
|
||
);
|
||
// Fans only: with the trim now TARGETED (it never cuts an ordinary
|
||
// node's strips) the support re-union became dead weight — measured by
|
||
// the full unit suite staying green without it. The support quads stay
|
||
// exported: the detector and the tests use them as the contract truth.
|
||
for (const piece of corners.fans) {
|
||
try {
|
||
let ring: any = [closedRing(piece)];
|
||
if (bound) ring = intersection(ring, bound);
|
||
if (!ring?.length) continue;
|
||
body = body ? union(body, ring) : ring;
|
||
} catch {
|
||
// A degenerate piece must not take the whole node down; the rest
|
||
// still stands on its own.
|
||
}
|
||
}
|
||
body = dropDegenerateRings(body, Math.max(multiWallNodes.epsilon, 1e-9) ** 2);
|
||
}
|
||
const roomGeom = body || [];
|
||
const roomComponents: WallGeometryComponent[] = [
|
||
...(structurallyValidWallGeometry(roomGeom)
|
||
? [{ id: 'room-primary', geom: roomGeom }]
|
||
: []),
|
||
...isolatedCore.map((component, index) => ({
|
||
id: `room-isolated-${index}`, geom: component.geom,
|
||
})),
|
||
];
|
||
// cut opening tunnels (axis-aligned to opening angle)
|
||
corePhase = 'openings';
|
||
for (const o of openings) {
|
||
if (!(o.length > 0)) continue;
|
||
const association = resolveOpeningWallAssociation(openingIndex!, o, true);
|
||
if (!association.negative && !association.positive) continue;
|
||
const rad = (o.angle * Math.PI) / 180;
|
||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||
const nx = -uy, ny = ux;
|
||
const half = o.length / 2;
|
||
const pad = Math.max(maxDepth, pitch * coordScale) * 1.25;
|
||
const slot = [
|
||
[o.x - ux * half - nx * pad, o.y - uy * half - ny * pad],
|
||
[o.x + ux * half - nx * pad, o.y + uy * half - ny * pad],
|
||
[o.x + ux * half + nx * pad, o.y + uy * half + ny * pad],
|
||
[o.x - ux * half + nx * pad, o.y - uy * half + ny * pad],
|
||
];
|
||
if (body) body = difference(body, closedRing(slot) as any);
|
||
for (const component of isolatedCore) {
|
||
component.geom = difference(component.geom, closedRing(slot) as any);
|
||
}
|
||
}
|
||
// Independent bodies are physical but own no openings. Each merge is a
|
||
// transaction: a local boolean failure must not discard the last valid
|
||
// room/extra union. A valid offending body remains an isolated component,
|
||
// which renderers paint separately so coincident evenodd rings never punch
|
||
// a transparent hole in the primary masonry.
|
||
corePhase = 'extras';
|
||
const isolated: WallGeometryComponent[] = [];
|
||
let degradedExtraCount = 0;
|
||
const mergeExtra = operations.mergeExtra
|
||
|| ((primary: any, extra: any) => primary ? union(primary, extra) : extra);
|
||
for (let index = 0; index < extraBodies.length; index++) {
|
||
const extra = extraBodies[index];
|
||
if (extra.length < 3 || !extra.every((point) => point.length >= 2
|
||
&& Number.isFinite(point[0]) && Number.isFinite(point[1]))
|
||
|| Math.abs(polygonArea(extra)) <= 1e-9) {
|
||
degradedExtraCount++;
|
||
continue;
|
||
}
|
||
const standalone: any = [closedRing(extra)];
|
||
try {
|
||
const merged = mergeExtra(body, standalone, index);
|
||
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid extra union');
|
||
body = merged;
|
||
} catch {
|
||
degradedExtraCount++;
|
||
if (structurallyValidWallGeometry(standalone)) {
|
||
isolated.push({ id: `extra-${index}`, geom: standalone });
|
||
}
|
||
}
|
||
}
|
||
isolated.push(...isolatedCore);
|
||
isolated.sort((left, right) => polyclipToPathD(left.geom).localeCompare(polyclipToPathD(right.geom)));
|
||
const primary = body || [];
|
||
const components: WallGeometryComponent[] = [
|
||
...(structurallyValidWallGeometry(primary) ? [{ id: 'primary', geom: primary }] : []),
|
||
...isolated.map((component, index) => ({ ...component, id: `isolated-${index}` })),
|
||
];
|
||
return {
|
||
status: degradedExtraCount || degradedCoreCount ? 'degraded-extra' : 'ok',
|
||
geom: primary, components, roomGeom, roomComponents, paperGeom,
|
||
depthUnits: maxDepth,
|
||
openingPadUnits: Math.max(maxDepth, pitch * coordScale) * 1.25,
|
||
openingIndex, multiWallNodes,
|
||
degradedExtraCount: degradedExtraCount + degradedCoreCount,
|
||
};
|
||
} catch {
|
||
operations.onCoreFailure?.(corePhase);
|
||
return {
|
||
status: 'failed-core', geom: [], components: [], roomGeom: [], paperGeom: [],
|
||
roomComponents: [],
|
||
depthUnits: maxDepth, openingIndex: null, multiWallNodes, degradedExtraCount: 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
export function wallBodiesUnionPath(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
/** Independent physical bodies are unioned only after room openings are cut,
|
||
* so a door/window/gate can never punch a coincident partition or column. */
|
||
extraBodies: number[][][] = [],
|
||
operations: WallGeometryOperations = {},
|
||
): {
|
||
status: 'ok' | 'degraded-extra';
|
||
d: string;
|
||
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
|
||
components: readonly WallGeometryComponent[];
|
||
roomGeom: any;
|
||
roomComponents: readonly WallGeometryComponent[];
|
||
openingIndex: OpeningWallIndex | null;
|
||
multiWallNodes: MultiWallNodeMap | null;
|
||
paperD: string;
|
||
depthUnits: number;
|
||
openingPadUnits?: number;
|
||
fillRule: 'evenodd' | 'nonzero';
|
||
} | null {
|
||
if (!walls?.length && !extraBodies.length) return null;
|
||
const united = wallBodiesGeometry(
|
||
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
|
||
operations,
|
||
);
|
||
return wallBodiesGeometryPath(united);
|
||
}
|
||
|
||
/** Project one already validated wall-geometry pass into the SVG payload. */
|
||
export function wallBodiesGeometryPath(
|
||
united: WallBodiesGeometryResult,
|
||
): {
|
||
status: 'ok' | 'degraded-extra';
|
||
d: string;
|
||
paths: readonly { id: string; d: string; fillRule: 'evenodd' }[];
|
||
components: readonly WallGeometryComponent[];
|
||
roomGeom: any;
|
||
roomComponents: readonly WallGeometryComponent[];
|
||
openingIndex: OpeningWallIndex | null;
|
||
multiWallNodes: MultiWallNodeMap | null;
|
||
paperD: string;
|
||
depthUnits: number;
|
||
openingPadUnits?: number;
|
||
fillRule: 'evenodd' | 'nonzero';
|
||
} | null {
|
||
if (united.status === 'failed-core' || united.status === 'not-applicable') return null;
|
||
const paths = united.components.map((component) => ({
|
||
id: component.id, d: polyclipToPathD(component.geom), fillRule: 'evenodd' as const,
|
||
})).filter((component) => !!component.d);
|
||
const d = paths[0]?.d || '';
|
||
const paperD = polyclipToPathD(united.paperGeom);
|
||
if (paths.length) {
|
||
const projected = {
|
||
status: united.status, d, paths, components: united.components,
|
||
roomGeom: united.roomGeom,
|
||
multiWallNodes: united.multiWallNodes, paperD,
|
||
depthUnits: united.depthUnits,
|
||
fillRule: 'evenodd',
|
||
} as unknown as NonNullable<ReturnType<typeof wallBodiesGeometryPath>>;
|
||
// Retained topology is an internal acceleration seam, not part of the
|
||
// enumerable SVG projection contract. Keeping it non-enumerable preserves
|
||
// structural equality and serialization for callers that compare path
|
||
// payloads while still allowing a second opening policy to reuse it.
|
||
Object.defineProperties(projected, {
|
||
roomComponents: { value: united.roomComponents || [], enumerable: false },
|
||
openingIndex: { value: united.openingIndex, enumerable: false },
|
||
openingPadUnits: { value: united.openingPadUnits, enumerable: false },
|
||
});
|
||
return projected;
|
||
}
|
||
// successful empty result: do not resurrect raw rings
|
||
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
|
||
// that creates an exterior tooth at a corner Split, so resurrecting it after
|
||
// a boolean failure would make malformed input violate the facade invariant.
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Recut an already-built room masonry pass for another opening policy.
|
||
*
|
||
* Plan drawing and light transport share the expensive room topology but not
|
||
* their cuts: every visual opening cuts the plan, while only an interior
|
||
* door/gate transmits light. Rebuilding all room rings, junctions and the
|
||
* exterior envelope for that second policy dominated first paint. This helper
|
||
* starts from the immutable uncut components retained by wallBodiesGeometry,
|
||
* applies the alternate slots, and then merges the policy-specific independent
|
||
* bodies. Any unexpected core failure returns null so callers can fall back to
|
||
* a completely independent canonical pass.
|
||
*/
|
||
export function recutWallBodiesGeometry(
|
||
base: Pick<WallBodiesGeometryResult,
|
||
'status' | 'roomGeom' | 'roomComponents' | 'openingIndex' | 'depthUnits'
|
||
| 'openingPadUnits'>,
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||
extraBodies: number[][][] = [],
|
||
operations: WallGeometryOperations = {},
|
||
): { status: 'ok' | 'degraded-extra'; geom: any; components: readonly WallGeometryComponent[] } | null {
|
||
if (base.status === 'failed-core' || base.status === 'not-applicable') return null;
|
||
const retained = base.roomComponents?.length
|
||
? base.roomComponents
|
||
: (structurallyValidWallGeometry(base.roomGeom)
|
||
? [{ id: 'room-primary', geom: base.roomGeom }]
|
||
: []);
|
||
let body: any = retained[0]?.geom || null;
|
||
const isolatedCore: WallGeometryComponent[] = retained.slice(1)
|
||
.map((component) => ({ ...component }));
|
||
try {
|
||
for (const opening of openings) {
|
||
if (!(opening.length > 0) || !base.openingIndex) continue;
|
||
const association = resolveOpeningWallAssociation(base.openingIndex, opening, true);
|
||
if (!association.negative && !association.positive) continue;
|
||
const rad = (opening.angle * Math.PI) / 180;
|
||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||
const nx = -uy, ny = ux;
|
||
const half = opening.length / 2;
|
||
const pad = base.openingPadUnits ?? Math.max(base.depthUnits, 1) * 1.25;
|
||
const slot = closedRing([
|
||
[opening.x - ux * half - nx * pad, opening.y - uy * half - ny * pad],
|
||
[opening.x + ux * half - nx * pad, opening.y + uy * half - ny * pad],
|
||
[opening.x + ux * half + nx * pad, opening.y + uy * half + ny * pad],
|
||
[opening.x - ux * half + nx * pad, opening.y - uy * half + ny * pad],
|
||
]) as any;
|
||
if (body) body = difference(body, slot);
|
||
for (const component of isolatedCore) component.geom = difference(component.geom, slot);
|
||
}
|
||
const isolated: WallGeometryComponent[] = [...isolatedCore];
|
||
let degradedExtraCount = isolatedCore.length;
|
||
const mergeExtra = operations.mergeExtra
|
||
|| ((primary: any, extra: any) => primary ? union(primary, extra) : extra);
|
||
for (let index = 0; index < extraBodies.length; index++) {
|
||
const extra = extraBodies[index];
|
||
if (extra.length < 3 || !extra.every((point) => point.length >= 2
|
||
&& Number.isFinite(point[0]) && Number.isFinite(point[1]))
|
||
|| Math.abs(polygonArea(extra)) <= 1e-9) {
|
||
degradedExtraCount++;
|
||
continue;
|
||
}
|
||
const standalone: any = [closedRing(extra)];
|
||
try {
|
||
const merged = mergeExtra(body, standalone, index);
|
||
if (!structurallyValidWallGeometry(merged)) throw new Error('invalid extra union');
|
||
body = merged;
|
||
} catch {
|
||
degradedExtraCount++;
|
||
if (structurallyValidWallGeometry(standalone)) {
|
||
isolated.push({ id: `policy-extra-${index}`, geom: standalone });
|
||
}
|
||
}
|
||
}
|
||
isolated.sort((left, right) => polyclipToPathD(left.geom).localeCompare(polyclipToPathD(right.geom)));
|
||
const primary = body || [];
|
||
return {
|
||
status: degradedExtraCount ? 'degraded-extra' : 'ok',
|
||
geom: primary,
|
||
components: [
|
||
...(structurallyValidWallGeometry(primary) ? [{ id: 'primary', geom: primary }] : []),
|
||
...isolated.map((component, index) => ({ ...component, id: `isolated-${index}` })),
|
||
],
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Per-edge wall quads for styling hooks and thick-cut suppression — one body
|
||
* per unique wall key. Shared and outer walls both grow ±½ from the
|
||
* centreline (docs/WALL-THICKNESS.md §2). Production hatch uses
|
||
* wallBodiesUnionPath; these quads remain for hooks / stroke cuts.
|
||
*/
|
||
export interface WallEdgeBody {
|
||
key: string;
|
||
kind: WallKind;
|
||
cm: number;
|
||
/** Quad corners (4 points), CCW. */
|
||
quad: number[][];
|
||
a: number[];
|
||
b: number[];
|
||
depthUnits: number;
|
||
}
|
||
|
||
export function wallEdgeBodies(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): WallEdgeBody[] {
|
||
if (!walls?.length) return [];
|
||
const seen = new Set<string>();
|
||
const out: WallEdgeBody[] = [];
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
if (!pr) continue;
|
||
const poly = pr.poly;
|
||
for (let i = 0; i < poly.length; i++) {
|
||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||
const kind = pr.kinds[i];
|
||
if (!kind) continue;
|
||
const cm = pr.cms[i];
|
||
if (!(cm > 0)) continue;
|
||
const key = keyOf(a, b, pitch, coordScale);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const depth = wallCmToUnits(cm, cellCm, gridPitch);
|
||
const [inx, iny] = inwardNormal(poly, i);
|
||
const ox = -inx, oy = -iny;
|
||
const h = depth / 2;
|
||
const quad: number[][] = [
|
||
[a[0] + ox * h, a[1] + oy * h],
|
||
[b[0] + ox * h, b[1] + oy * h],
|
||
[b[0] + inx * h, b[1] + iny * h],
|
||
[a[0] + inx * h, a[1] + iny * h],
|
||
];
|
||
out.push({ key, kind, cm, quad, a: [a[0], a[1]], b: [b[0], b[1]], depthUnits: depth });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** SVG path for an edge body, with optional opening slots cut (evenodd holes). */
|
||
export function wallEdgePathD(
|
||
body: WallEdgeBody,
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }> = [],
|
||
): string {
|
||
let d = polyToPath(body.quad);
|
||
const [dx, dy] = wallDir(body.a, body.b);
|
||
const ux = dx, uy = dy;
|
||
// normal across the wall (from a toward inward of first room estimate = perp)
|
||
const nx = -uy, ny = ux;
|
||
for (const o of openings) {
|
||
if (!wallAngleMatches(body.a, body.b, o.angle)) continue;
|
||
// only openings whose centre lies on (or very near) this span's centreline
|
||
const dist = distToSeg(o.x, o.y, body.a[0], body.a[1], body.b[0], body.b[1]);
|
||
if (dist > Math.max(body.depthUnits * 0.55, 1e-3)) continue;
|
||
const half = o.length / 2;
|
||
// slot covers full depth of this quad
|
||
const pad = body.depthUnits; // generous across
|
||
// project opening onto wall direction
|
||
const cx = o.x, cy = o.y;
|
||
const slot = [
|
||
[cx - ux * half - nx * pad, cy - uy * half - ny * pad],
|
||
[cx + ux * half - nx * pad, cy + uy * half - ny * pad],
|
||
[cx + ux * half + nx * pad, cy + uy * half + ny * pad],
|
||
[cx - ux * half + nx * pad, cy - uy * half + ny * pad],
|
||
];
|
||
d += ` ${polyToPath(reversePoly(slot))}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* Outward paper growth offsets per edge (plan units): half-thickness under
|
||
* every thick wall (outer and shared) so the scene background never shows
|
||
* through the outer half-out.
|
||
*/
|
||
export function paperOutwardOffsets(
|
||
rooms: any[],
|
||
roomId: string,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): number[] {
|
||
const pr = roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
return pr ? pr.offsets : [];
|
||
}
|
||
|
||
/**
|
||
* Expand a polygon outward by per-edge offsets (mirror of inset with flipped
|
||
* normals). Used for paper under shared thick walls.
|
||
*/
|
||
export function outsetContour(
|
||
poly: number[][],
|
||
offsets: number[],
|
||
multiWallNodes?: MultiWallNodeMap | null,
|
||
): number[][] | null {
|
||
const n = poly?.length || 0;
|
||
if (n < 3 || offsets.length !== n) return null;
|
||
if (offsets.every((o) => !(o > 0))) return poly.map((p) => [p[0], p[1]]);
|
||
// outset = inset of the reversed winding with same offsets, then reverse back
|
||
const rev = reversePoly(poly);
|
||
const revOff = offsets.slice().reverse();
|
||
// shift so revOff[i] applies to edge rev[i]→rev[i+1] which was poly edge
|
||
// after reverse: edge i of rev was edge (n-1-i) of original... careful.
|
||
// Simpler: negate inward normals by using inset on poly with negative?
|
||
// Build by flipping offset direction manually:
|
||
const out: number[][] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const iPrev = (i - 1 + n) % n;
|
||
const oA = Math.max(0, offsets[iPrev]);
|
||
const oB = Math.max(0, offsets[i]);
|
||
const [nAx, nAy] = inwardNormal(poly, iPrev);
|
||
const [nBx, nBy] = inwardNormal(poly, i);
|
||
// outward = -inward
|
||
const a0 = poly[iPrev], a1 = poly[i];
|
||
const b0 = poly[i], b1 = poly[(i + 1) % n];
|
||
const dA = [a1[0] - a0[0], a1[1] - a0[1]];
|
||
const dB = [b1[0] - b0[0], b1[1] - b0[1]];
|
||
const LA = Math.hypot(dA[0], dA[1]) || 1;
|
||
const LB = Math.hypot(dB[0], dB[1]) || 1;
|
||
const uA = [dA[0] / LA, dA[1] / LA];
|
||
const uB = [dB[0] / LB, dB[1] / LB];
|
||
const pA = [a0[0] - nAx * oA, a0[1] - nAy * oA];
|
||
const pB = [b0[0] - nBx * oB, b0[1] - nBy * oB];
|
||
if (!(oA > 0) && !(oB > 0)) {
|
||
out.push([poly[i][0], poly[i][1]]);
|
||
continue;
|
||
}
|
||
// Mirror the inset contract above: one physical edge plus one zero-depth
|
||
// edge is a local cap with both endpoints present in traversal order.
|
||
if ((oA > 0) !== (oB > 0)) {
|
||
const v = poly[i];
|
||
const pa = oA > 0 ? [v[0] - nAx * oA, v[1] - nAy * oA] : [v[0], v[1]];
|
||
const pb = oB > 0 ? [v[0] - nBx * oB, v[1] - nBy * oB] : [v[0], v[1]];
|
||
out.push(pa);
|
||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||
continue;
|
||
}
|
||
if (collinearJoint(uA, uB)) {
|
||
const v = poly[i];
|
||
const pa = [v[0] - nAx * oA, v[1] - nAy * oA];
|
||
const pb = [v[0] - nBx * oB, v[1] - nBy * oB];
|
||
out.push(pa);
|
||
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
|
||
continue;
|
||
}
|
||
const hit = lineIntersect(pA, uA, pB, uB);
|
||
const maxO = Math.max(oA, oB, 1e-9);
|
||
// #329 (owner correction 2026-08-27): the tip of a degenerate corner is
|
||
// a NORMAL SHARP APEX — the masonry converges to a point at the plan's
|
||
// own vertex. Neither the flat chamfer (which produced the "trident"
|
||
// with the folded inset) nor the raw mitre needle (which sticks metres
|
||
// past the walls that make it) is acceptable there.
|
||
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
|
||
?? MITRE_LIMIT * maxO;
|
||
if (hit) {
|
||
const dist = Math.hypot(hit[0] - poly[i][0], hit[1] - poly[i][1]);
|
||
if (Number.isFinite(dist) && dist <= joinLimit) {
|
||
out.push(hit);
|
||
continue;
|
||
}
|
||
if (isDegenerateApexCorner(poly, offsets, i)) {
|
||
out.push([poly[i][0], poly[i][1]]);
|
||
continue;
|
||
}
|
||
}
|
||
if (oA > 0) out.push([poly[i][0] - nAx * oA, poly[i][1] - nAy * oA]);
|
||
if (oB > 0) out.push([poly[i][0] - nBx * oB, poly[i][1] - nBy * oB]);
|
||
}
|
||
void rev; void revOff;
|
||
return out.length >= 3 ? out : null;
|
||
}
|
||
|
||
/**
|
||
* Paper shapes grown under thick shared walls. Falls back to exact room
|
||
* contours when there is no thickness (byte-compatible with paperRoomShapes).
|
||
*/
|
||
export function paperRoomShapesWithWalls(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): Array<
|
||
| { path: string }
|
||
| { poly: string }
|
||
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
|
||
> {
|
||
if (!walls?.length) return paperRoomShapes(rooms);
|
||
try {
|
||
const exterior = exteriorEnvelopeGeometry(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
if (exterior) {
|
||
const rawPaper = exterior.shell?.length
|
||
? union(exterior.centre, exterior.shell)
|
||
: exterior.centre;
|
||
const paper = rawPaper;
|
||
const path = polyclipToPathD(paper);
|
||
if (path) return [{ path }];
|
||
}
|
||
} catch {
|
||
// Safe fallback below: exact room centrelines never reproduce the known
|
||
// exterior Split spike, even when boolean offsetting rejected bad input.
|
||
}
|
||
return paperRoomShapes(rooms);
|
||
}
|
||
|
||
interface OpeningWallEdge {
|
||
roomId: string;
|
||
a: number[];
|
||
b: number[];
|
||
inward: [number, number];
|
||
cm: number;
|
||
half: number;
|
||
area: number;
|
||
key: string;
|
||
}
|
||
|
||
/**
|
||
* Immutable wall index shared by opening symbols, wall cuts and tunnel fills.
|
||
* Building atomic room profiles is the expensive O(rooms²) part; callers that
|
||
* resolve several openings build this once and reuse it for every opening.
|
||
*/
|
||
export interface OpeningWallIndex {
|
||
edges: OpeningWallEdge[];
|
||
adjacencyEps: number;
|
||
}
|
||
|
||
export function openingWallIndex(
|
||
rooms: any[],
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): OpeningWallIndex {
|
||
const edges: OpeningWallEdge[] = [];
|
||
for (const room of rooms || []) {
|
||
if (!room?.id) continue;
|
||
const pr = roomWallProfile(
|
||
rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
if (!pr) continue;
|
||
const area = Math.abs(polygonArea(pr.poly));
|
||
for (let i = 0; i < pr.poly.length; i++) {
|
||
// A virtual interval has no opening, inner face or physical tunnel.
|
||
if (!pr.kinds[i]) continue;
|
||
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
|
||
edges.push({
|
||
roomId: room.id,
|
||
a, b,
|
||
inward: inwardNormal(pr.poly, i),
|
||
cm: pr.cms[i],
|
||
half: pr.offsets[i],
|
||
area,
|
||
key: keyOf(a, b, pitch, coordScale),
|
||
});
|
||
}
|
||
}
|
||
return { edges, adjacencyEps: openEps(pitch, coordScale) };
|
||
}
|
||
|
||
export interface OpeningWallPiece {
|
||
x0: number;
|
||
x1: number;
|
||
half: number;
|
||
cm: number;
|
||
key: string;
|
||
/** Canonical unit direction of the physical wall, independent of room winding. */
|
||
axis: [number, number];
|
||
}
|
||
|
||
export interface OpeningWallSide {
|
||
roomId: string;
|
||
side: -1 | 1;
|
||
/** First matching room edge in config order. Symbols historically use that
|
||
* order to choose their default face on an inherently ambiguous shared wall;
|
||
* tunnel ownership still uses compareOpeningSides below. */
|
||
order: number;
|
||
pieces: OpeningWallPiece[];
|
||
faceDistance: number;
|
||
area: number;
|
||
coverage: number;
|
||
full: boolean;
|
||
}
|
||
|
||
export interface OpeningWallAssociation {
|
||
negative: OpeningWallSide | null;
|
||
positive: OpeningWallSide | null;
|
||
}
|
||
|
||
function tunnelCoverage(pieces: OpeningWallPiece[], lo: number, hi: number, eps: number): {
|
||
coverage: number; full: boolean;
|
||
} {
|
||
const spans = pieces
|
||
.map((p) => [Math.max(lo, p.x0), Math.min(hi, p.x1)] as [number, number])
|
||
.filter((p) => p[1] - p[0] > eps)
|
||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
if (!spans.length) return { coverage: 0, full: false };
|
||
let start = spans[0][0], end = spans[0][1], coverage = 0;
|
||
let full = start <= lo + eps;
|
||
for (let i = 1; i < spans.length; i++) {
|
||
const [a, b] = spans[i];
|
||
if (a <= end + eps) {
|
||
end = Math.max(end, b);
|
||
continue;
|
||
}
|
||
coverage += end - start;
|
||
full = false;
|
||
start = a; end = b;
|
||
}
|
||
coverage += end - start;
|
||
full = full && end >= hi - eps;
|
||
return { coverage, full };
|
||
}
|
||
|
||
function compareOpeningSides(a: OpeningWallSide, b: OpeningWallSide): number {
|
||
return Number(b.full) - Number(a.full)
|
||
|| a.faceDistance - b.faceDistance
|
||
|| a.area - b.area
|
||
|| a.roomId.localeCompare(b.roomId);
|
||
}
|
||
|
||
/**
|
||
* Resolve adjacent room sides for one opening against a prebuilt wall index.
|
||
* A candidate must be genuinely collinear/adjacent (4% of one grid pitch), not
|
||
* merely the closest parallel wall within a whole cell. This keeps detached
|
||
* rooms and double-wall air gaps from becoming a phantom second room.
|
||
*/
|
||
export function resolveOpeningWallAssociation(
|
||
index: OpeningWallIndex,
|
||
opening: { x: number; y: number; angle: number; length: number },
|
||
physicalOnly = false,
|
||
): OpeningWallAssociation {
|
||
const x = Number(opening?.x), y = Number(opening?.y);
|
||
const angle = Number(opening?.angle), length = Number(opening?.length);
|
||
if (![x, y, angle, length].every(Number.isFinite) || !(length > 0)) {
|
||
return { negative: null, positive: null };
|
||
}
|
||
const rad = angle * Math.PI / 180;
|
||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||
const nx = -uy, ny = ux;
|
||
const openingHalf = length / 2;
|
||
const eps = Math.max(1e-9, index.adjacencyEps);
|
||
const candidates = new Map<string, OpeningWallSide>();
|
||
let candidateOrder = 0;
|
||
|
||
for (const edge of index.edges) {
|
||
if (physicalOnly && !(edge.half > 0)) continue;
|
||
if (!wallAngleMatches(edge.a, edge.b, angle)) continue;
|
||
const [edgeUx, edgeUy] = wallDir(edge.a, edge.b);
|
||
// Adjacency is perpendicular distance to the wall line. Long legacy
|
||
// openings may have their centre just beyond an endpoint while still
|
||
// overlapping the real span; the projection clip below decides that part.
|
||
const lineDistance = Math.abs((x - edge.a[0]) * edgeUy - (y - edge.a[1]) * edgeUx);
|
||
if (lineDistance > eps) continue;
|
||
const ta = (edge.a[0] - x) * ux + (edge.a[1] - y) * uy;
|
||
const tb = (edge.b[0] - x) * ux + (edge.b[1] - y) * uy;
|
||
const x0 = Math.max(-openingHalf, Math.min(ta, tb));
|
||
const x1 = Math.min(openingHalf, Math.max(ta, tb));
|
||
if (x1 - x0 <= eps) continue;
|
||
const side = (edge.inward[0] * nx + edge.inward[1] * ny >= 0 ? 1 : -1) as -1 | 1;
|
||
// Signed centreline position matters: an edge just across the axis has an
|
||
// inner face closer by that offset, not farther by abs(offset) + half.
|
||
const mx = (edge.a[0] + edge.b[0]) / 2;
|
||
const my = (edge.a[1] + edge.b[1]) / 2;
|
||
const centreY = (mx - x) * nx + (my - y) * ny;
|
||
const faceDistance = Math.abs(centreY + side * edge.half);
|
||
const key = `${side}|${edge.roomId}`;
|
||
const piece: OpeningWallPiece = {
|
||
x0, x1, half: edge.half, cm: edge.cm, key: edge.key, axis: [edgeUx, edgeUy],
|
||
};
|
||
const previous = candidates.get(key);
|
||
if (previous) {
|
||
previous.pieces.push(piece);
|
||
previous.faceDistance = Math.min(previous.faceDistance, faceDistance);
|
||
} else {
|
||
candidates.set(key, {
|
||
roomId: edge.roomId, side, order: candidateOrder++, pieces: [piece], faceDistance,
|
||
area: edge.area, coverage: 0, full: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const candidate of candidates.values()) {
|
||
const coverage = tunnelCoverage(candidate.pieces, -openingHalf, openingHalf, eps);
|
||
candidate.coverage = coverage.coverage;
|
||
candidate.full = coverage.full;
|
||
}
|
||
const pick = (side: -1 | 1): OpeningWallSide | null => {
|
||
const list = [...candidates.values()].filter((candidate) => (
|
||
candidate.side === side && candidate.coverage > eps
|
||
));
|
||
list.sort(compareOpeningSides);
|
||
return list[0] || null;
|
||
};
|
||
return { negative: pick(-1), positive: pick(1) };
|
||
}
|
||
|
||
function centrePiece(side: OpeningWallSide): OpeningWallPiece {
|
||
return [...side.pieces].sort((a, b) => {
|
||
const da = a.x0 <= 0 && a.x1 >= 0 ? 0 : Math.min(Math.abs(a.x0), Math.abs(a.x1));
|
||
const db = b.x0 <= 0 && b.x1 >= 0 ? 0 : Math.min(Math.abs(b.x0), Math.abs(b.x1));
|
||
return da - db || (b.x1 - b.x0) - (a.x1 - a.x0) || a.key.localeCompare(b.key);
|
||
})[0];
|
||
}
|
||
|
||
/**
|
||
* Physical half-depth and direction for an opening. Visible symbol placement
|
||
* is resolved separately and is always centred; flip_v changes only the
|
||
* opening direction (#250).
|
||
*/
|
||
export function openingInnerFaceOffset(
|
||
rooms: any[],
|
||
opening: { x: number; y: number; angle: number; length: number; flip_v?: boolean },
|
||
walls: WallEntry[] | null | undefined,
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
openCuts: number[][] = [],
|
||
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
|
||
const index = openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||
return openingInnerFaceOffsetFromIndex(index, opening);
|
||
}
|
||
|
||
/** Cheap per-opening face resolution against a cached atomic wall index. */
|
||
export function openingInnerFaceOffsetFromIndex(
|
||
index: OpeningWallIndex,
|
||
opening: { x: number; y: number; angle: number; length: number; flip_v?: boolean },
|
||
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
|
||
const association = resolveOpeningWallAssociation(index, opening);
|
||
const available = [association.negative, association.positive]
|
||
.filter((side): side is OpeningWallSide => !!side);
|
||
if (!available.length) return { ox: 0, oy: 0, cm: 0, side: -1 };
|
||
// An exterior wall keeps its real room-side direction so gates still open
|
||
// outward. A shared wall has no exterior, so use the opening-local negative
|
||
// side instead of the first room in model order.
|
||
const naturalSide = association.negative && association.positive
|
||
? -1
|
||
: available[0].side;
|
||
const selectedSide = (opening.flip_v ? -naturalSide : naturalSide) as -1 | 1;
|
||
const selected = (selectedSide === -1 ? association.negative : association.positive)
|
||
|| available[0];
|
||
const piece = centrePiece(selected);
|
||
if (!(piece.half > 0) || !(piece.cm > 0)) return { ox: 0, oy: 0, cm: 0, side: selectedSide };
|
||
const rad = opening.angle * Math.PI / 180;
|
||
const nx = -Math.sin(rad), ny = Math.cos(rad);
|
||
return {
|
||
ox: nx * selectedSide * piece.half,
|
||
oy: ny * selectedSide * piece.half,
|
||
cm: piece.cm,
|
||
side: selectedSide,
|
||
};
|
||
}
|
||
|
||
/** One half of a room-coloured opening tunnel, in opening-local coordinates. */
|
||
export interface OpeningTunnelFace {
|
||
side: -1 | 1;
|
||
roomId: string;
|
||
/** One SVG path containing one contour per disconnected physical span. */
|
||
d: string;
|
||
}
|
||
|
||
/** Pure geometry consumed by the full-card opening-tunnel renderer. */
|
||
export interface OpeningTunnelGeometry {
|
||
faces: OpeningTunnelFace[];
|
||
/** Local Y bounds. The wall centreline is always y=0. */
|
||
minY: number;
|
||
maxY: number;
|
||
wallKey: string;
|
||
}
|
||
|
||
/** @internal Exported so the non-overlapping union profile has a direct mutation guard. */
|
||
export function tunnelFacePath(side: -1 | 1, pieces: OpeningWallPiece[]): string {
|
||
const eps = 1e-9;
|
||
const valid = pieces.filter((piece) => (
|
||
Number.isFinite(piece.x0) && Number.isFinite(piece.x1)
|
||
&& Number.isFinite(piece.half) && piece.x1 > piece.x0 && piece.half > 0
|
||
));
|
||
if (!valid.length) return '';
|
||
|
||
// Turn overlapping atomic wall intervals into a non-overlapping depth
|
||
// profile. A profile slab uses the deepest physical body covering that X;
|
||
// this is the exact union of all candidate rectangles and never extends an
|
||
// opening past either jamb.
|
||
const rawBreaks = valid.flatMap((piece) => [piece.x0, piece.x1]).sort((a, b) => a - b);
|
||
const breaks: number[] = [];
|
||
for (const value of rawBreaks) {
|
||
const tail = breaks[breaks.length - 1];
|
||
if (tail === undefined || value > tail + eps) breaks.push(value);
|
||
}
|
||
const profile: Array<{ x0: number; x1: number; half: number }> = [];
|
||
for (let i = 0; i + 1 < breaks.length; i++) {
|
||
const x0 = breaks[i], x1 = breaks[i + 1];
|
||
if (!(x1 > x0 + eps)) continue;
|
||
const mid = (x0 + x1) / 2;
|
||
const half = valid.reduce((depth, piece) => (
|
||
mid >= piece.x0 - eps && mid <= piece.x1 + eps ? Math.max(depth, piece.half) : depth
|
||
), 0);
|
||
if (!(half > 0)) continue;
|
||
const tail = profile[profile.length - 1];
|
||
if (tail && x0 <= tail.x1 + eps && Math.abs(half - tail.half) <= eps) {
|
||
tail.x1 = x1;
|
||
} else {
|
||
profile.push({ x0, x1, half });
|
||
}
|
||
}
|
||
|
||
// Build one simple outline for every connected span. Thickness changes are
|
||
// vertices on its outer envelope, not shared edges between translucent SVG
|
||
// rectangles, so neither antialiasing seams nor double-alpha bands exist.
|
||
const components: Array<Array<{ x0: number; x1: number; half: number }>> = [];
|
||
for (const slab of profile) {
|
||
const component = components[components.length - 1];
|
||
const tail = component?.[component.length - 1];
|
||
if (tail && slab.x0 <= tail.x1 + eps) {
|
||
slab.x0 = tail.x1;
|
||
component.push(slab);
|
||
} else {
|
||
components.push([slab]);
|
||
}
|
||
}
|
||
|
||
return components.map((component) => {
|
||
const first = component[0], last = component[component.length - 1];
|
||
// Both half-faces are subpaths of one nonzero-filled path. Give them a
|
||
// real device-pixel overlap at ordinary wall depths: a 0.1 px overlap was
|
||
// still rasterised as a faint centre seam by Chromium. Because winding is
|
||
// identical this remains one alpha application, not a double-fill band.
|
||
const seam = Math.min(Math.min(...component.map((slab) => slab.half)) * 0.25, 0.75);
|
||
const axisY = -side * seam;
|
||
const commands: string[] = [];
|
||
if (side === 1) {
|
||
commands.push(`M ${first.x0} ${axisY} L ${last.x1} ${axisY}`);
|
||
for (let i = component.length - 1; i >= 0; i--) {
|
||
const slab = component[i];
|
||
commands.push(`L ${slab.x1} ${slab.half} L ${slab.x0} ${slab.half}`);
|
||
}
|
||
} else {
|
||
// Keep the same winding direction as the positive face. The two faces
|
||
// overlap only around y=0; matching winding makes that overlap solid
|
||
// under the nonzero fill rule instead of cancelling into a hairline.
|
||
commands.push(`M ${last.x1} ${axisY} L ${first.x0} ${axisY}`);
|
||
for (const slab of component)
|
||
commands.push(`L ${slab.x0} ${-slab.half} L ${slab.x1} ${-slab.half}`);
|
||
}
|
||
commands.push('Z');
|
||
return commands.join(' ');
|
||
}).join(' ');
|
||
}
|
||
|
||
type TunnelOccupancy = Map<string, Array<[number, number]>>;
|
||
|
||
function reserveTunnelPieces(
|
||
opening: { x: number; y: number; angle: number },
|
||
side: -1 | 1,
|
||
pieces: OpeningWallPiece[],
|
||
occupied?: TunnelOccupancy,
|
||
): OpeningWallPiece[] {
|
||
if (!occupied) return pieces;
|
||
const openingRad = opening.angle * Math.PI / 180;
|
||
const openingUx = Math.cos(openingRad), openingUy = Math.sin(openingRad);
|
||
const out: OpeningWallPiece[] = [];
|
||
const eps = 1e-9;
|
||
|
||
for (const piece of pieces) {
|
||
const [ux, uy] = piece.axis;
|
||
const direction = openingUx * ux + openingUy * uy;
|
||
if (Math.abs(direction) <= eps) continue;
|
||
const centre = opening.x * ux + opening.y * uy;
|
||
const g0 = centre + direction * piece.x0;
|
||
const g1 = centre + direction * piece.x1;
|
||
const lo = Math.min(g0, g1), hi = Math.max(g0, g1);
|
||
const occupancyKey = `${piece.key}|${side}`;
|
||
const previous = occupied.get(occupancyKey) || [];
|
||
let fragments: Array<[number, number]> = [[lo, hi]];
|
||
for (const [usedLo, usedHi] of previous) {
|
||
const next: Array<[number, number]> = [];
|
||
for (const [a, b] of fragments) {
|
||
if (usedHi <= a + eps || usedLo >= b - eps) next.push([a, b]);
|
||
else {
|
||
if (usedLo > a + eps) next.push([a, Math.min(b, usedLo)]);
|
||
if (usedHi < b - eps) next.push([Math.max(a, usedHi), b]);
|
||
}
|
||
}
|
||
fragments = next;
|
||
if (!fragments.length) break;
|
||
}
|
||
for (const [a, b] of fragments) {
|
||
const lx0 = (a - centre) / direction;
|
||
const lx1 = (b - centre) / direction;
|
||
out.push({ ...piece, x0: Math.min(lx0, lx1), x1: Math.max(lx0, lx1) });
|
||
}
|
||
const merged = [...previous, [lo, hi] as [number, number]]
|
||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
const compact: Array<[number, number]> = [];
|
||
for (const span of merged) {
|
||
const tail = compact[compact.length - 1];
|
||
if (tail && span[0] <= tail[1] + eps) tail[1] = Math.max(tail[1], span[1]);
|
||
else compact.push([span[0], span[1]]);
|
||
}
|
||
occupied.set(occupancyKey, compact);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function openingTunnelGeometryFromIndex(
|
||
index: OpeningWallIndex,
|
||
opening: { x: number; y: number; angle: number; length: number },
|
||
occupied?: TunnelOccupancy,
|
||
): OpeningTunnelGeometry | null {
|
||
const association = resolveOpeningWallAssociation(index, opening, true);
|
||
const negative = association.negative, positive = association.positive;
|
||
if (!negative && !positive) return null;
|
||
|
||
let chosen: Array<{ candidate: OpeningWallSide; side: -1 | 1 }>;
|
||
if (negative && positive) {
|
||
chosen = [{ candidate: negative, side: -1 }, { candidate: positive, side: 1 }];
|
||
} else {
|
||
const only = (negative || positive)!;
|
||
chosen = [{ candidate: only, side: -1 }, { candidate: only, side: 1 }];
|
||
}
|
||
const renderedPieces = chosen.map(({ candidate, side }) => ({
|
||
candidate,
|
||
side,
|
||
pieces: reserveTunnelPieces(opening, side, candidate.pieces, occupied),
|
||
}));
|
||
const faces = renderedPieces.map(({ candidate, side, pieces }) => ({
|
||
side, roomId: candidate.roomId, d: tunnelFacePath(side, pieces),
|
||
}));
|
||
const allPieces = renderedPieces.flatMap(({ pieces }) => pieces);
|
||
if (!allPieces.length) return null;
|
||
const maxHalf = Math.max(...allPieces.map((piece) => piece.half));
|
||
const wallKey = [...new Set(allPieces.map((piece) => piece.key))].sort().join('|');
|
||
return { faces, minY: -maxHalf, maxY: maxHalf, wallKey };
|
||
}
|
||
|
||
/**
|
||
* Resolve the physical tunnel and its adjacent rooms without reading card or
|
||
* HA state. The opening-local X axis follows `angle`; local y=0 is the wall
|
||
* centreline. Atomic room-wall profiles provide exact mixed-thickness clips,
|
||
* so a legacy opening near a breakpoint cannot paint beyond the real body.
|
||
*
|
||
* A single adjacent room (outer wall) owns both halves. Two adjacent rooms own
|
||
* one half each. Draft walls, virtual spans and zero-thickness intervals never
|
||
* produce a face because they have no eligible room-wall interval.
|
||
*/
|
||
export function openingTunnelGeometry(
|
||
rooms: any[],
|
||
opening: { x: number; y: number; angle: number; length: number },
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): OpeningTunnelGeometry | null {
|
||
if (![pitch, cellCm, gridPitch, coordScale].every(Number.isFinite)
|
||
|| !(pitch > 0) || !(cellCm > 0) || !(gridPitch > 0) || !(coordScale > 0)
|
||
|| !walls?.length) return null;
|
||
const index = openingWallIndex(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
return openingTunnelGeometryFromIndex(index, opening);
|
||
}
|
||
|
||
/** Resolve every opening while paying the atomic room-profile cost once. */
|
||
export function openingTunnelGeometries(
|
||
rooms: any[],
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }>,
|
||
walls: WallEntry[] | null | undefined,
|
||
openCuts: number[][],
|
||
pitch: number,
|
||
cellCm: number,
|
||
gridPitch: number,
|
||
coordScale = 1,
|
||
): Array<OpeningTunnelGeometry | null> {
|
||
if (![pitch, cellCm, gridPitch, coordScale].every(Number.isFinite)
|
||
|| !(pitch > 0) || !(cellCm > 0) || !(gridPitch > 0) || !(coordScale > 0)
|
||
|| !walls?.length) return openings.map(() => null);
|
||
const index = openingWallIndex(
|
||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||
);
|
||
return openingTunnelGeometriesFromIndex(index, openings);
|
||
}
|
||
|
||
/** Cheap batch resolution against a cached atomic wall index. */
|
||
export function openingTunnelGeometriesFromIndex(
|
||
index: OpeningWallIndex,
|
||
openings: Array<{ x: number; y: number; angle: number; length: number }>,
|
||
): Array<OpeningTunnelGeometry | null> {
|
||
const occupied: TunnelOccupancy = new Map();
|
||
return openings.map((opening) => openingTunnelGeometryFromIndex(index, opening, occupied));
|
||
}
|