/** * Partial open (virtual) wall spans — docs/superpowers/specs/2026-08-05-open-spans-delete-design.md * * Stored on the space as `open_spans: [{ a, b }]` in normalised 0..1 coords. * `rooms[].open_to` remains the light-zone connectivity index derived from spans. */ import { roomPoly, sharedBoundary, distToSegment, roomEdges } from './logic'; import { wallKey, wallDir, wallAngleMatches, thicknessCmAt, setWallThickness, DRAW_WALL_DEFAULT_CM, type WallEntry, } from './wall-thickness'; /** One virtual stretch in config space (normalised 0..1). */ export interface OpenSpanEntry { a: number[]; b: number[]; } export const OPEN_SPAN_MIN_UNITS = 1e-3; function qn(v: number, pitch: number): number { if (!(pitch > 0) || !Number.isFinite(v)) return v; return Math.round(v / pitch) * pitch; } /** Render-space segment → normalised entry (pitch = GRID_STEP_N). */ export function spanToEntry(a: number[], b: number[], coordScale: number): OpenSpanEntry { const s = coordScale > 0 ? coordScale : 1; return { a: [a[0] / s, a[1] / s], b: [b[0] / s, b[1] / s], }; } export function entryToSeg(e: OpenSpanEntry, coordScale: number): number[] { const s = coordScale > 0 ? coordScale : 1; return [e.a[0] * s, e.a[1] * s, e.b[0] * s, e.b[1] * s]; } function finitePoint(p: any): boolean { return Array.isArray(p) && p.length >= 2 && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1])); } /** * Fail-soft read of `space.open_spans` (AUD-159B6-03). The field is persisted * data: an old client, a hand-edited YAML or a broken import can put anything * there, and one malformed entry used to throw inside render and blank the * card for every reader. Anything that is not two finite points a minimum * length apart is dropped, the rest keeps working. */ export function sanitizeOpenSpans(spans: unknown): OpenSpanEntry[] { if (!Array.isArray(spans)) return []; const out: OpenSpanEntry[] = []; for (const e of spans) { if (!e || typeof e !== 'object') continue; const raw = e as any; if (!finitePoint(raw.a) || !finitePoint(raw.b)) continue; const a = [Number(raw.a[0]), Number(raw.a[1])]; const b = [Number(raw.b[0]), Number(raw.b[1])]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < OPEN_SPAN_MIN_UNITS) continue; out.push({ a, b }); } return out; } export function spanKey(a: number[], b: number[], pitch: number, coordScale = 1): string { if (coordScale === 1) return wallKey(a, b, pitch); return wallKey([a[0] / coordScale, a[1] / coordScale], [b[0] / coordScale, b[1] / coordScale], pitch); } function sameSpan(x: OpenSpanEntry, y: OpenSpanEntry, pitch: number): boolean { return spanKey(x.a, x.b, pitch, 1) === spanKey(y.a, y.b, pitch, 1); } /** Project point onto segment; return clamped point + param t in [0,1]. */ export function projectOnSeg(p: number[], seg: number[]): { q: number[]; t: number; d: number } { const ax = seg[0], ay = seg[1], bx = seg[2], by = seg[3]; const dx = bx - ax, dy = by - ay; const len2 = dx * dx + dy * dy; if (len2 < 1e-18) { const q = [ax, ay]; return { q, t: 0, d: Math.hypot(p[0] - ax, p[1] - ay) }; } let t = ((p[0] - ax) * dx + (p[1] - ay) * dy) / len2; t = Math.max(0, Math.min(1, t)); const q = [ax + t * dx, ay + t * dy]; return { q, t, d: Math.hypot(p[0] - q[0], p[1] - q[1]) }; } /** Clamp P2 to the edge that holds P1 (nearest corners = edge endpoints). */ export function clampToEdgeEnds(p: number[], edge: number[]): number[] { return projectOnSeg(p, edge).q; } /** * Snap a raw point onto a shared-wall edge: corners / existing joints first, * else grid along the wall. */ export function snapOpenPoint( raw: number[], edge: number[], joints: number[][], gridPitch: number, jointPull: number, ): number[] { const on = projectOnSeg(raw, edge).q; let best = on; let bestD = Infinity; for (const j of joints) { const d = Math.hypot(on[0] - j[0], on[1] - j[1]); if (d <= jointPull && d < bestD) { bestD = d; best = [j[0], j[1]]; } } if (bestD <= jointPull) return best; // grid along the edge from the first corner const ax = edge[0], ay = edge[1], bx = edge[2], by = edge[3]; const dx = bx - ax, dy = by - ay; const len = Math.hypot(dx, dy) || 1; const along = ((on[0] - ax) * dx + (on[1] - ay) * dy) / len; const step = gridPitch > 0 ? gridPitch : 1; const snapped = Math.round(along / step) * step; const u = Math.max(0, Math.min(len, snapped)) / len; return [ax + dx * u, ay + dy * u]; } interface SharedSeg { seg: number[]; /** Room-pair identity. Adjacent pieces from different pairs must stay split. */ pair: string; a: any; b: any; } /** Shared-boundary segments with their room-pair semantics (render units). */ function sharedSegsWithPairs(rooms: any[], eps: number): SharedSeg[] { const out: SharedSeg[] = []; const list = (rooms || []).filter((r) => r?.id); 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)) { out.push({ seg: sg, pair: `${list[i].id}:${list[j].id}`, a: list[i], b: list[j] }); } } } return out; } /** All shared-boundary segments between rooms (render units). */ export function allSharedSegs(rooms: any[], eps: number): number[][] { return sharedSegsWithPairs(rooms, eps).map((x) => x.seg); } /** Find shared edge under a point; returns rooms + the atomic shared segment. */ export function hitSharedWall( raw: number[], rooms: any[], pull: number, eps: number, ): { a: any; b: any; edge: number[] } | null { const list = (rooms || []).filter((r) => r?.id); let best: { a: any; b: any; edge: number[]; d: number } | null = null; 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)) { const d = distToSegment(raw, sg); if (d <= pull && (!best || d < best.d)) best = { a: list[i], b: list[j], edge: sg, d }; } } } return best ? { a: best.a, b: best.b, edge: best.edge } : null; } /** Outer room edge under the cursor (not shared). */ export function hitOuterWall( raw: number[], rooms: any[], pull: number, eps: number, ): { room: any; edge: number[] } | null { const shared = allSharedSegs(rooms, eps); let best: { room: any; edge: number[]; d: number } | null = null; for (const room of rooms || []) { if (!room?.id) continue; const poly = roomPoly(room); if (!poly) continue; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; const edge = [a[0], a[1], b[0], b[1]]; const d = distToSegment(raw, edge); if (d > pull) continue; // skip if this edge coincides with a shared stretch let isShared = false; for (const sg of shared) { if (distToSegment([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], sg) < eps * 2) { isShared = true; break; } } if (isShared) continue; if (!best || d < best.d) best = { room, edge, d }; } } return best ? { room: best.room, edge: best.edge } : null; } /** Expand legacy open_to (no spans) into full sharedBoundary entries. */ export function expandLegacyOpenSpans( rooms: any[], spans: OpenSpanEntry[] | null | undefined, eps: number, ): OpenSpanEntry[] { const clean = sanitizeOpenSpans(spans); if (clean.length) return clean; const out: OpenSpanEntry[] = []; const list = (rooms || []).filter((r) => r?.id); const linked = (x: any, y: any) => (x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id); for (let i = 0; i < list.length; i++) { for (let j = i + 1; j < list.length; j++) { if (!linked(list[i], list[j])) continue; const pa = roomPoly(list[i]), pb = roomPoly(list[j]); if (!pa || !pb) continue; // roomPoly is already in render units when rooms come from the model; // callers that pass config-space polys must pre-scale. We accept both: // if coords look normalised (≤2), treat as config and leave as-is. for (const sg of sharedBoundary(pa, pb, eps)) { const maxC = Math.max(...sg.map(Math.abs)); if (maxC <= 2) { out.push({ a: [sg[0], sg[1]], b: [sg[2], sg[3]] }); } else { // render → will be converted by caller; store raw and let caller scale out.push({ a: [sg[0], sg[1]], b: [sg[2], sg[3]] }); } } } } return out; } /** * Resolve open cuts in RENDER units. `spans` are normalised; `rooms` from * spaceModel (render polys). Legacy: empty spans + open_to → full shared segs. */ export function resolveOpenCuts( rooms: any[], spans: OpenSpanEntry[] | null | undefined, coordScale: number, eps: number, allowLegacy = true, ): number[][] { const list = (rooms || []).filter((r) => r?.id); const clean = sanitizeOpenSpans(spans); if (clean.length) { return clipOpenSpansToShared(clean, rooms, coordScale, eps) .map((e) => entryToSeg(e, coordScale)); } // Legacy `open_to`-only configuration. NEVER read in the middle of a geometry // transaction (AUD-159B6-02): once explicit spans have been removed the index // is stale by construction and would resurrect a different stretch. if (!allowLegacy) return []; const out: number[][] = []; const linked = (x: any, y: any) => (x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id); for (let i = 0; i < list.length; i++) { for (let j = i + 1; j < list.length; j++) { if (!linked(list[i], list[j])) continue; const pa = roomPoly(list[i]), pb = roomPoly(list[j]); if (!pa || !pb) continue; for (const sg of sharedBoundary(pa, pb, eps)) out.push(sg); } } return out; } /** Persistable spans from current cuts (normalised). */ export function cutsToSpanEntries(cuts: number[][], coordScale: number): OpenSpanEntry[] { return cuts.map((sg) => spanToEntry([sg[0], sg[1]], [sg[2], sg[3]], coordScale)); } /** Sync open_to from geometric spans (render cuts + rooms with render polys). */ export function syncOpenToFromCuts(roomsCfg: any[], roomsModel: any[], cuts: number[][], eps: number): void { // clear all open_to first for (const r of roomsCfg || []) { if (r.open_to) delete r.open_to; } if (!cuts.length) return; const model = (roomsModel || []).filter((r) => r?.id); const byId = new Map(model.map((r) => [r.id, r])); const cfgById = new Map((roomsCfg || []).filter((r) => r?.id).map((r) => [r.id, r])); const link = (ia: string, ib: string) => { const a = cfgById.get(ia), b = cfgById.get(ib); if (!a || !b) return; if (!(a.open_to || []).includes(ib)) a.open_to = [...(a.open_to || []), ib]; if (!(b.open_to || []).includes(ia)) b.open_to = [...(b.open_to || []), ia]; }; for (let i = 0; i < model.length; i++) { for (let j = i + 1; j < model.length; j++) { const pa = roomPoly(model[i]), pb = roomPoly(model[j]); if (!pa || !pb) continue; const shared = sharedBoundary(pa, pb, eps); if (!shared.length) continue; for (const cut of cuts) { const mid = [(cut[0] + cut[2]) / 2, (cut[1] + cut[3]) / 2]; if (shared.some((sg) => distToSegment(mid, sg) < eps * 4)) { link(model[i].id!, model[j].id!); break; } } } } for (const r of roomsCfg || []) { if (r.open_to && !r.open_to.length) delete r.open_to; } void byId; } export function hitOpenSpan( raw: number[], cuts: number[][], pull: number, ): number[] | null { let best: { sg: number[]; d: number } | null = null; for (const sg of cuts) { const d = distToSegment(raw, sg); if (d <= pull && (!best || d < best.d)) best = { sg, d }; } return best ? best.sg : null; } /** One semantic result for the combined Boundary tool. */ export type BoundaryTarget = | { kind: 'open'; seg: number[]; distance: number } | { kind: 'shared'; a: any; b: any; edge: number[]; distance: number } | { kind: 'outer'; room: any; edge: number[]; distance: number } | { kind: 'ambiguous'; group: 'open' | 'shared' | 'outer' } | { kind: 'none' }; export interface BoundaryResolveOptions { /** Minimum transverse hit width, already converted from CSS px to render units. */ openPull: number; /** How far past an open span endpoint a click may extend longitudinally. */ openEndCap: number; /** Per-solid-segment hit width; lets thick walls use at least half their body. */ solidPull: (seg: number[]) => number; /** Difference between two candidate distances that makes a junction ambiguous. */ ambiguity: number; eps: number; } interface BoundaryCandidate { value: T; seg: number[]; semantic: string; distance: number; } /** * Hit a finite segment by separate transverse and longitudinal tolerances. * `distToSegment` alone grows a circular target around the ends; for virtual * spans that made a click well beyond a short dash restore the wrong stretch. */ function finiteSegmentDistance( raw: number[], seg: number[], transverse: number, endCap = 0, ): number | null { const dx = seg[2] - seg[0], dy = seg[3] - seg[1]; const len = Math.hypot(dx, dy); if (!(len > 1e-9)) return null; const ux = dx / len, uy = dy / len; const along = (raw[0] - seg[0]) * ux + (raw[1] - seg[1]) * uy; if (along < -endCap || along > len + endCap) return null; const perpendicular = Math.abs((raw[0] - seg[0]) * uy - (raw[1] - seg[1]) * ux); if (perpendicular > transverse) return null; return distToSegment(raw, seg); } function collinear(a: number[], b: number[], eps: number): boolean { const adx = a[2] - a[0], ady = a[3] - a[1]; const bdx = b[2] - b[0], bdy = b[3] - b[1]; const al = Math.hypot(adx, ady), bl = Math.hypot(bdx, bdy); if (!(al > 1e-9 && bl > 1e-9)) return false; if (Math.abs((adx / al) * (bdy / bl) - (ady / al) * (bdx / bl)) > 1e-6) return false; return distToSegment([b[0], b[1]], a) <= eps * 4 || distToSegment([a[0], a[1]], b) <= eps * 4; } function chooseCandidate( candidates: BoundaryCandidate[], ambiguity: number, eps: number, ): BoundaryCandidate | 'ambiguous' | null { if (!candidates.length) return null; candidates.sort((a, b) => a.distance - b.distance); const first = candidates[0]; for (let i = 1; i < candidates.length; i++) { const next = candidates[i]; if (next.distance - first.distance > ambiguity) break; // Atomic pieces of the same semantic wall may meet at a harmless collinear // joint. A real corner/T-junction or another room pair is ambiguous. if (next.semantic !== first.semantic || !collinear(first.seg, next.seg, eps)) return 'ambiguous'; } return first; } /** * Resolve exactly what one Boundary click means. Category priority is part of * the contract: an open span wins over the solid room edge beneath it, then a * shared solid boundary wins over an outer wall. Ambiguity is evaluated only * inside the winning category, so the priority itself never creates a false * junction warning. */ export function resolveBoundaryTarget( raw: number[], rooms: any[], cuts: number[][], options: BoundaryResolveOptions, ): BoundaryTarget { const { openPull, openEndCap, solidPull, ambiguity, eps } = options; const shared = sharedSegsWithPairs(rooms, eps); const openCandidates: BoundaryCandidate[] = []; for (const seg of cuts || []) { const distance = finiteSegmentDistance(raw, seg, openPull, openEndCap); if (distance == null) continue; const mid = [(seg[0] + seg[2]) / 2, (seg[1] + seg[3]) / 2]; const owner = shared.find((item) => distToSegment(mid, item.seg) <= eps * 4); openCandidates.push({ value: seg, seg, semantic: owner?.pair || `open:${seg.join(',')}`, distance }); } const open = chooseCandidate(openCandidates, ambiguity, eps); if (open === 'ambiguous') return { kind: 'ambiguous', group: 'open' }; if (open) return { kind: 'open', seg: open.value, distance: open.distance }; const sharedCandidates: BoundaryCandidate[] = []; for (const item of shared) { const distance = finiteSegmentDistance(raw, item.seg, solidPull(item.seg)); if (distance == null) continue; sharedCandidates.push({ value: item, seg: item.seg, semantic: item.pair, distance }); } const common = chooseCandidate(sharedCandidates, ambiguity, eps); if (common === 'ambiguous') return { kind: 'ambiguous', group: 'shared' }; if (common) return { kind: 'shared', a: common.value.a, b: common.value.b, edge: common.value.seg, distance: common.distance, }; const outerCandidates: BoundaryCandidate<{ room: any; edge: number[] }>[] = []; for (const room of rooms || []) { if (!room?.id) continue; const poly = roomPoly(room); if (!poly) continue; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; const edge = [a[0], a[1], b[0], b[1]]; const distance = finiteSegmentDistance(raw, edge, solidPull(edge)); if (distance == null) continue; const q = projectOnSeg(raw, edge).q; if (shared.some((item) => distToSegment(q, item.seg) <= eps * 4)) continue; outerCandidates.push({ value: { room, edge }, seg: edge, semantic: String(room.id), distance }); } } const outer = chooseCandidate(outerCandidates, ambiguity, eps); if (outer === 'ambiguous') return { kind: 'ambiguous', group: 'outer' }; if (outer) return { kind: 'outer', ...outer.value, distance: outer.distance }; return { kind: 'none' }; } /** Remove wall thickness entries whose midpoint lies on the open span. */ export function clearThicknessUnderSpan( walls: WallEntry[] | null | undefined, a: number[], b: number[], pitch: number, coordScale = 1, ): WallEntry[] { if (!walls?.length) return []; const tol = Math.max(pitch * 0.5, 1e-9) * (coordScale > 0 ? coordScale : 1); const [dx, dy] = wallDir( [a[0] / coordScale, a[1] / coordScale], [b[0] / coordScale, b[1] / coordScale], ); let wang = Math.atan2(dy, dx); if (wang < 0) wang += Math.PI; return walls.filter((w) => { const at = w.key.lastIndexOf('@'); if (at < 0) return true; 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)) return true; let dAng = Math.abs(aq - wang); if (dAng > Math.PI / 2) dAng = Math.PI - dAng; if (dAng >= 0.02) return true; const dist = distToSegment( [sx * coordScale, sy * coordScale], [a[0], a[1], b[0], b[1]], ); return dist > tol; }); } /** * Thickness to apply when closing a span: neighbour solid on same line, else default. */ export function thicknessOnClose( walls: WallEntry[] | null | undefined, closed: number[], solidEdges: number[][], pitch: number, coordScale = 1, fallbackCm = DRAW_WALL_DEFAULT_CM, ): number { const [dx, dy] = wallDir([closed[0], closed[1]], [closed[2], closed[3]]); let bestCm = 0; let bestD = Infinity; const mid = [(closed[0] + closed[2]) / 2, (closed[1] + closed[3]) / 2]; for (const sg of solidEdges) { const [ex, ey] = wallDir([sg[0], sg[1]], [sg[2], sg[3]]); if (Math.abs(dx * ey - dy * ex) > 0.05) continue; // collinear-ish: neighbour mid distance along line const cm = thicknessCmAt(walls, [sg[0], sg[1]], [sg[2], sg[3]], pitch, coordScale); if (!(cm > 0)) continue; const sm = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2]; const d = Math.hypot(sm[0] - mid[0], sm[1] - mid[1]); if (d < bestD) { bestD = d; bestCm = cm; } } return bestCm > 0 ? bestCm : fallbackCm; } export function applyThicknessOnClose( walls: WallEntry[] | null | undefined, closed: number[], solidEdges: number[][], pitch: number, coordScale = 1, fallbackCm = DRAW_WALL_DEFAULT_CM, ): WallEntry[] { const cm = thicknessOnClose(walls, closed, solidEdges, pitch, coordScale, fallbackCm); return setWallThickness(walls, [closed[0], closed[1]], [closed[2], closed[3]], cm, pitch, coordScale); } /** Drop openings whose centre lies on the span (angle-aware). */ export function purgeOpeningsOnSpan( openings: any[] | null | undefined, span: number[], coordScale: number, pull: number, ): any[] { if (!openings?.length) return openings ? openings.slice() : []; return openings.filter((o) => { const x = Number(o.x) * coordScale; const y = Number(o.y) * coordScale; if (distToSegment([x, y], span) > pull) return true; if (!wallAngleMatches([span[0], span[1]], [span[2], span[3]], Number(o.angle) || 0)) return true; return false; // on span → remove }); } /** True if an opening placement point sits on a virtual cut. */ export function pointOnOpenCut( x: number, y: number, angle: number, cuts: number[][], pull: number, ): boolean { for (const sg of cuts) { if (distToSegment([x, y], sg) > pull) continue; if (wallAngleMatches([sg[0], sg[1]], [sg[2], sg[3]], angle)) return true; } return false; } /** Joints for snap: edge ends + open-span ends on the same edge line. */ export function jointsOnEdge(edge: number[], cuts: number[][], eps: number): number[][] { const joints: number[][] = [ [edge[0], edge[1]], [edge[2], edge[3]], ]; const mid = [(edge[0] + edge[2]) / 2, (edge[1] + edge[3]) / 2]; for (const sg of cuts) { if (distToSegment(mid, sg) > Math.hypot(edge[2] - edge[0], edge[3] - edge[1]) && distToSegment([sg[0], sg[1]], edge) > eps) continue; // endpoints that lie on this edge for (const p of [[sg[0], sg[1]], [sg[2], sg[3]]]) { if (distToSegment(p, edge) <= eps * 2) joints.push(p); } } return joints; } /** Remove a cut matching endpoints (tolerant). */ export function removeCut(cuts: number[][], target: number[], eps: number): number[][] { const tMid = [(target[0] + target[2]) / 2, (target[1] + target[3]) / 2]; return cuts.filter((sg) => { const m = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2]; return Math.hypot(m[0] - tMid[0], m[1] - tMid[1]) > eps * 4; }); } /** Degrade span entries whose segment no longer lies on any shared boundary. */ export function degradeOpenSpans( spans: OpenSpanEntry[] | null | undefined, roomsModel: any[], coordScale: number, eps: number, ): OpenSpanEntry[] { const clean = sanitizeOpenSpans(spans); if (!clean.length) return []; const shared = allSharedSegs(roomsModel, eps); return clean.filter((e) => { const sg = entryToSeg(e, coordScale); const mid = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2]; return shared.some((sh) => distToSegment(mid, sh) < eps * 4); }); } /** * Project each open span onto the current shared-boundary geometry and clip * to every overlap. Spans that no longer overlap any shared stretch are dropped. * A Split may turn one continuous physical boundary into adjacent stretches * owned by different room pairs. Those pieces must remain separate so their * midpoints derive the correct `open_to` links (AUD-159B7-01). * Prevents "solid outline + dashed open" after resize when a span drifts off * the true shared edge. */ export function clipOpenSpansToShared( spans: OpenSpanEntry[] | null | undefined, roomsModel: any[], coordScale: number, eps: number, ): OpenSpanEntry[] { const clean = sanitizeOpenSpans(spans); if (!clean.length) return []; const shared = sharedSegsWithPairs(roomsModel, eps); if (!shared.length) return []; const pieces: Array<{ pair: string; seg: number[] }> = []; const minLen = Math.max(eps * 4, 1e-6); for (const e of clean) { const sg = entryToSeg(e, coordScale); const ax = sg[0], ay = sg[1], bx = sg[2], by = sg[3]; const adx = bx - ax, ady = by - ay; const aLen = Math.hypot(adx, ady); if (aLen < minLen) continue; const ux = adx / aLen, uy = ady / aLen; const byPair = new Map(); for (const { seg: sh, pair } of shared) { // both endpoints of `sh` must lie on the line of sg const d1 = Math.abs((sh[0] - ax) * uy - (sh[1] - ay) * ux); const d2 = Math.abs((sh[2] - ax) * uy - (sh[3] - ay) * ux); if (d1 > eps * 4 || d2 > eps * 4) continue; const t1 = (sh[0] - ax) * ux + (sh[1] - ay) * uy; const t2 = (sh[2] - ax) * ux + (sh[3] - ay) * uy; const lo = Math.max(0, Math.min(t1, t2)); const hi = Math.min(aLen, Math.max(t1, t2)); if (hi - lo < minLen) continue; const ranges = byPair.get(pair) || []; ranges.push({ lo, hi }); byPair.set(pair, ranges); } for (const [pair, ranges] of byPair) { ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi); const merged: { lo: number; hi: number }[] = []; for (const range of ranges) { const tail = merged[merged.length - 1]; if (tail && range.lo <= tail.hi + minLen) tail.hi = Math.max(tail.hi, range.hi); else merged.push({ ...range }); } for (const range of merged) { const na = [ax + ux * range.lo, ay + uy * range.lo]; const nb = [ax + ux * range.hi, ay + uy * range.hi]; if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < minLen) continue; pieces.push({ pair, seg: [na[0], na[1], nb[0], nb[1]] }); } } } // Canonicalise storage globally, not only inside each source entry. Drawing // two touching pieces on the same physical boundary must not leave two dash // phases / two selectable fragments behind. Pair identity is deliberately // part of the group: Split can leave touching pieces owned by DIFFERENT room // pairs and those must stay separate for an exact `open_to` index. interface LineGroup { pair: string; origin: number[]; ux: number; uy: number; ranges: Array<{ lo: number; hi: number }>; } const groups: LineGroup[] = []; const lineTol = Math.max(eps * 4, 1e-6); for (const { pair, seg } of pieces) { const dx = seg[2] - seg[0], dy = seg[3] - seg[1]; const len = Math.hypot(dx, dy); if (len < minLen) continue; let ux = dx / len, uy = dy / len; if (ux < -1e-12 || (Math.abs(ux) <= 1e-12 && uy < 0)) { ux = -ux; uy = -uy; } let group = groups.find((g) => ( g.pair === pair && Math.abs(g.ux * uy - g.uy * ux) <= 1e-6 && Math.abs((seg[0] - g.origin[0]) * g.uy - (seg[1] - g.origin[1]) * g.ux) <= lineTol && Math.abs((seg[2] - g.origin[0]) * g.uy - (seg[3] - g.origin[1]) * g.ux) <= lineTol )); if (!group) { group = { pair, origin: [seg[0], seg[1]], ux, uy, ranges: [] }; groups.push(group); } const t1 = (seg[0] - group.origin[0]) * group.ux + (seg[1] - group.origin[1]) * group.uy; const t2 = (seg[2] - group.origin[0]) * group.ux + (seg[3] - group.origin[1]) * group.uy; group.ranges.push({ lo: Math.min(t1, t2), hi: Math.max(t1, t2) }); } const out: OpenSpanEntry[] = []; for (const group of groups) { group.ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi); const merged: Array<{ lo: number; hi: number }> = []; for (const range of group.ranges) { const tail = merged[merged.length - 1]; if (tail && range.lo <= tail.hi + minLen) tail.hi = Math.max(tail.hi, range.hi); else merged.push({ ...range }); } for (const range of merged) { const a = [ group.origin[0] + group.ux * range.lo, group.origin[1] + group.uy * range.lo, ]; const b = [ group.origin[0] + group.ux * range.hi, group.origin[1] + group.uy * range.hi, ]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= minLen) { out.push(spanToEntry(a, b, coordScale)); } } } return out; } /** Rekey span endpoints after parallel old→new edge moves (render units). */ export function rekeyOpenSpansAfterMove( spans: OpenSpanEntry[] | null | undefined, oldSpans: [number[], number[]][], newSpans: [number[], number[]][], coordScale: number, ): OpenSpanEntry[] { const clean = sanitizeOpenSpans(spans); if (!clean.length) return []; if (oldSpans.length !== newSpans.length) return clean; const out: OpenSpanEntry[] = []; for (const e of clean) { const sg = entryToSeg(e, coordScale); const a = [sg[0], sg[1]], b = [sg[2], sg[3]]; let na = a, nb = b; for (let i = 0; i < oldSpans.length; i++) { const [oa, ob] = oldSpans[i]; const [xa, xb] = newSpans[i]; // map endpoints that sat on the old span const mapPt = (p: number[]): number[] => { const pr = projectOnSeg(p, [oa[0], oa[1], ob[0], ob[1]]); if (pr.d > 1e-3) return p; const dx = xb[0] - xa[0], dy = xb[1] - xa[1]; return [xa[0] + dx * pr.t, xa[1] + dy * pr.t]; }; if (projectOnSeg([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], [oa[0], oa[1], ob[0], ob[1]]).d < 1e-2) { na = mapPt(a); nb = mapPt(b); break; } } out.push(spanToEntry(na, nb, coordScale)); } return out; } export { qn, roomEdges, sameSpan };