/** * Pure planar wall graph used by the Plan editor. * * Source axes are atomized at endpoint, T, X and collinear-overlap vertices. * Every undirected atom retains all source keys. Faces are then obtained by a * clockwise turn from the reverse half-edge; positive signed walks are the * bounded faces, while the opposite walk is the unbounded exterior. * * The implementation is intentionally independent of Lit/config mutation. * A deterministic X sweep with an interval treap removes disjoint bounding * boxes before exact intersection work. Its cost is O((E + K) log E), where K * is the bounded-box candidate set (and includes the real intersections I). * The editor invokes it only for accepted clicks, never for pointermove/hover. */ export interface WallGraphSourceSegment { a: readonly number[]; b: readonly number[]; /** Stable provenance key. More than one source may own the same atom. */ key: string; } export interface WallGraphAtom { a: [number, number]; b: [number, number]; key: string; sourceKeys: string[]; } export interface WallGraphFace { /** Open ring: the first point is not repeated at the end. */ ring: [number, number][]; key: string; area: number; atomKeys: string[]; sourceKeys: string[]; } export interface WallFaceGraph { atoms: WallGraphAtom[]; faces: WallGraphFace[]; } export interface WallChainSegment { a: [number, number]; b: [number, number]; cm: number; } const DEFAULT_EPSILON = 0.001; /** Compatibility projection for a session token written by the old toolbar. */ export function normalizeUnifiedWallTool(value: unknown): unknown { return value === 'partition' ? 'draw' : value; } /** Immutable open-chain projection used by explicit finish and full rejection. */ /** * Thickness of every segment in a chain — the single answer to that question. * * Issue #234: five call sites decided it independently and disagreed in three * different ways. The preview filled a gap with the toolbar field, the two * partition writers with a hard-coded 15 cm, the room writer with the first * edge's value. So a chain drawn at 30 cm was shown at 30 and stored at 15, and * the owner discovered it much later by hovering a wall. Two formulas for one * meaning always drift; there is exactly one here now. * * A missing record inside a committed chain inherits the previous segment, * then the toolbar field, then the default (owner's decision 2026-08-21). * The final missing record is different: it is the live rubber-band, so the * toolbar field must win before the previous segment. Otherwise changing the * field between clicks previews the old thickness and commits the new one. * * Zero is an explicit bodyless wall (#306). Negative and non-finite values are * still invalid and therefore fall through to the documented inheritance. */ export function chainSegmentCms( segmentCount: number, recorded: readonly (number | null | undefined)[] | null | undefined, activeCm: number | null | undefined, defaultCm: number, ): number[] { const count = Number.isFinite(segmentCount) && segmentCount > 0 ? Math.floor(segmentCount) : 0; const valid = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; // `defaultCm` — ответственность вызывающего: он передаёт // DRAW_WALL_DEFAULT_CM. Константа сюда не импортируется намеренно — этот // модуль не зависит ни от чего, и второе место, где живёт число 15, было бы // ровно тем дублированием, которое задача и убирает. Невалидный default — // дефект вызывающего; безопасный fallback теперь валидная стена без тела. const active = valid(activeCm); const fallback = valid(defaultCm) ?? 0; const out: number[] = []; let previous: number | null = null; for (let i = 0; i < count; i++) { const own = valid(recorded?.[i]); const inherited = previous ?? active ?? fallback; const liveTail = active ?? previous ?? fallback; const cm = own ?? (i === count - 1 ? liveTail : inherited); out.push(cm); previous = cm; } return out; } /** * Drawable segments of a chain. Thickness arrives already resolved (#234): this * function no longer owns a fallback of its own, because owning one is how the * disagreement started. */ export function wallChainSegments( path: readonly (readonly number[])[], cms: readonly number[], ): WallChainSegment[] { const result: WallChainSegment[] = []; for (let i = 0; i + 1 < path.length; i++) { const a = path[i]; const b = path[i + 1]; if (!finitePoint(a) || !finitePoint(b) || Math.hypot(b[0] - a[0], b[1] - a[1]) <= Number.EPSILON) continue; // The resolver guarantees a non-negative number per index; a caller that skips // it is a defect, so the value is used as given rather than re-defaulted. result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] }); } return result; } function finitePoint(point: readonly number[] | null | undefined): point is readonly [number, number] { return !!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]); } function cross(ax: number, ay: number, bx: number, by: number): number { return ax * by - ay * bx; } function signedArea(ring: readonly (readonly number[])[]): number { let sum = 0; for (let i = 0; i < ring.length; i++) { const a = ring[i]; const b = ring[(i + 1) % ring.length]; sum += a[0] * b[1] - b[0] * a[1]; } return sum / 2; } function canonicalVertex(point: readonly number[], epsilon: number): [number, number] { const step = Math.max(epsilon, Number.EPSILON); const x = Math.round(point[0] / step) * step; const y = Math.round(point[1] / step) * step; return [Object.is(x, -0) ? 0 : x, Object.is(y, -0) ? 0 : y]; } function vertexKey(point: readonly number[], epsilon: number): string { const canonical = canonicalVertex(point, epsilon); return `${Math.round(canonical[0] / epsilon)},${Math.round(canonical[1] / epsilon)}`; } function edgeKey(aKey: string, bKey: string): string { return aKey.localeCompare(bKey) <= 0 ? `${aKey}|${bKey}` : `${bKey}|${aKey}`; } function canonicalCycle(keys: readonly string[]): string { if (!keys.length) return ''; const candidates: string[] = []; for (const sequence of [keys, [...keys].reverse()] as const) { for (let i = 0; i < sequence.length; i++) { candidates.push([...sequence.slice(i), ...sequence.slice(0, i)].join(';')); } } candidates.sort((a, b) => a.localeCompare(b)); return candidates[0]; } function identityCycle( keys: readonly string[], points: ReadonlyMap, epsilon: number, ): string[] { const result = [...keys]; for (let changed = true; changed && result.length >= 3;) { changed = false; for (let i = 0; i < result.length; i++) { const a = points.get(result[(i - 1 + result.length) % result.length])!; const b = points.get(result[i])!; const c = points.get(result[(i + 1) % result.length])!; const abx = b[0] - a[0]; const aby = b[1] - a[1]; const bcx = c[0] - b[0]; const bcy = c[1] - b[1]; if (Math.abs(cross(abx, aby, bcx, bcy)) <= epsilon * Math.max(Math.hypot(abx, aby), Math.hypot(bcx, bcy), 1) && abx * bcx + aby * bcy >= 0) { result.splice(i, 1); changed = true; break; } } } return result; } function uniqueSorted(values: number[], epsilon: number): number[] { const sorted = values .map((value) => Math.max(0, Math.min(1, value))) .sort((a, b) => a - b); const result: number[] = []; for (const value of sorted) { if (!result.length || Math.abs(value - result[result.length - 1]) > epsilon) result.push(value); } return result; } function pointAt(source: WallGraphSourceSegment, t: number): [number, number] { return [ source.a[0] + (source.b[0] - source.a[0]) * t, source.a[1] + (source.b[1] - source.a[1]) * t, ]; } function projectedParameter(point: readonly number[], source: WallGraphSourceSegment): number { const dx = source.b[0] - source.a[0]; const dy = source.b[1] - source.a[1]; const length2 = dx * dx + dy * dy; return length2 > 0 ? ((point[0] - source.a[0]) * dx + (point[1] - source.a[1]) * dy) / length2 : 0; } function pointOnSource( point: readonly number[], source: WallGraphSourceSegment, epsilon: number, ): number | null { const t = projectedParameter(point, source); if (t < -epsilon || t > 1 + epsilon) return null; const projected = pointAt(source, t); return Math.hypot(projected[0] - point[0], projected[1] - point[1]) <= epsilon ? Math.max(0, Math.min(1, t)) : null; } function addPairCuts( left: WallGraphSourceSegment, right: WallGraphSourceSegment, leftCuts: number[], rightCuts: number[], epsilon: number, ): void { const rx = left.b[0] - left.a[0]; const ry = left.b[1] - left.a[1]; const sx = right.b[0] - right.a[0]; const sy = right.b[1] - right.a[1]; const qpx = right.a[0] - left.a[0]; const qpy = right.a[1] - left.a[1]; const denominator = cross(rx, ry, sx, sy); const scale = Math.max(Math.hypot(rx, ry), Math.hypot(sx, sy), 1); if (Math.abs(denominator) > epsilon * scale) { const t = cross(qpx, qpy, sx, sy) / denominator; const u = cross(qpx, qpy, rx, ry) / denominator; if (t >= -epsilon && t <= 1 + epsilon && u >= -epsilon && u <= 1 + epsilon) { leftCuts.push(Math.max(0, Math.min(1, t))); rightCuts.push(Math.max(0, Math.min(1, u))); } return; } // Parallel but non-collinear axes never meet. For collinear overlaps, every // endpoint which lies on the other source becomes a cut on both owners. if (Math.abs(cross(qpx, qpy, rx, ry)) > epsilon * scale) return; for (const point of [right.a, right.b]) { const t = pointOnSource(point, left, epsilon); if (t != null) leftCuts.push(t); } for (const point of [left.a, left.b]) { const u = pointOnSource(point, right, epsilon); if (u != null) rightCuts.push(u); } } interface SegmentBounds { index: number; minX: number; maxX: number; minY: number; maxY: number; } interface IntervalNode { item: SegmentBounds; priority: number; subtreeMaxY: number; left: IntervalNode | null; right: IntervalNode | null; } function intervalPriority(index: number): number { let value = (index + 1) | 0; value ^= value << 13; value ^= value >>> 17; value ^= value << 5; return value >>> 0; } function intervalCompare(left: SegmentBounds, right: SegmentBounds): number { return left.minY - right.minY || left.index - right.index; } function refreshInterval(node: IntervalNode): IntervalNode { node.subtreeMaxY = Math.max( node.item.maxY, node.left?.subtreeMaxY ?? -Infinity, node.right?.subtreeMaxY ?? -Infinity, ); return node; } function rotateIntervalLeft(node: IntervalNode): IntervalNode { const root = node.right!; node.right = root.left; root.left = refreshInterval(node); return refreshInterval(root); } function rotateIntervalRight(node: IntervalNode): IntervalNode { const root = node.left!; node.left = root.right; root.right = refreshInterval(node); return refreshInterval(root); } function insertInterval(root: IntervalNode | null, item: SegmentBounds): IntervalNode { if (!root) return { item, priority: intervalPriority(item.index), subtreeMaxY: item.maxY, left: null, right: null, }; if (intervalCompare(item, root.item) < 0) { root.left = insertInterval(root.left, item); if (root.left.priority < root.priority) root = rotateIntervalRight(root); } else { root.right = insertInterval(root.right, item); if (root.right.priority < root.priority) root = rotateIntervalLeft(root); } return refreshInterval(root); } function removeInterval(root: IntervalNode | null, item: SegmentBounds): IntervalNode | null { if (!root) return null; const order = intervalCompare(item, root.item); if (order < 0) root.left = removeInterval(root.left, item); else if (order > 0) root.right = removeInterval(root.right, item); else if (!root.left) return root.right; else if (!root.right) return root.left; else if (root.left.priority < root.right.priority) { root = rotateIntervalRight(root); root.right = removeInterval(root.right, item); } else { root = rotateIntervalLeft(root); root.left = removeInterval(root.left, item); } return refreshInterval(root); } function queryIntervals( root: IntervalNode | null, minY: number, maxY: number, output: SegmentBounds[], ): void { if (!root || root.subtreeMaxY < minY) return; if (root.left?.subtreeMaxY != null && root.left.subtreeMaxY >= minY) queryIntervals(root.left, minY, maxY, output); if (root.item.minY <= maxY && root.item.maxY >= minY) output.push(root.item); if (root.item.minY <= maxY) queryIntervals(root.right, minY, maxY, output); } function discoverPairCuts( sources: readonly WallGraphSourceSegment[], cuts: number[][], epsilon: number, ): void { const bounds = sources.map((source, index): SegmentBounds => ({ index, minX: Math.min(source.a[0], source.b[0]) - epsilon, maxX: Math.max(source.a[0], source.b[0]) + epsilon, minY: Math.min(source.a[1], source.b[1]) - epsilon, maxY: Math.max(source.a[1], source.b[1]) + epsilon, })); const starts = [...bounds].sort((left, right) => left.minX - right.minX || left.minY - right.minY || left.index - right.index); const ends = [...bounds].sort((left, right) => left.maxX - right.maxX || left.index - right.index); const active = new Set(); let intervalRoot: IntervalNode | null = null; let endIndex = 0; for (const item of starts) { while (endIndex < ends.length && ends[endIndex].maxX < item.minX) { const expired = ends[endIndex++]; if (!active.delete(expired.index)) continue; intervalRoot = removeInterval(intervalRoot, expired); } const candidates: SegmentBounds[] = []; queryIntervals(intervalRoot, item.minY, item.maxY, candidates); candidates.sort((left, right) => left.index - right.index); for (const candidate of candidates) { addPairCuts( sources[candidate.index], sources[item.index], cuts[candidate.index], cuts[item.index], epsilon, ); } active.add(item.index); intervalRoot = insertInterval(intervalRoot, item); } } /** Atomize valid source axes without changing their persisted representation. */ export function atomizeWallSegments( input: readonly WallGraphSourceSegment[], epsilon = DEFAULT_EPSILON, ): WallGraphAtom[] { const safeEpsilon = Number.isFinite(epsilon) && epsilon > 0 ? epsilon : DEFAULT_EPSILON; const sources = input.filter((source) => finitePoint(source.a) && finitePoint(source.b) && typeof source.key === 'string' && source.key.length > 0 && Math.hypot(source.b[0] - source.a[0], source.b[1] - source.a[1]) > safeEpsilon); const cuts = sources.map(() => [0, 1]); discoverPairCuts(sources, cuts, safeEpsilon); const atoms = new Map; }>(); for (let i = 0; i < sources.length; i++) { const source = sources[i]; const length = Math.hypot( source.b[0] - source.a[0], source.b[1] - source.a[1], ); const parameters = uniqueSorted(cuts[i], safeEpsilon / Math.max(length, 1)); for (let j = 0; j + 1 < parameters.length; j++) { const a = canonicalVertex(pointAt(source, parameters[j]), safeEpsilon); const b = canonicalVertex(pointAt(source, parameters[j + 1]), safeEpsilon); if (Math.hypot(b[0] - a[0], b[1] - a[1]) <= safeEpsilon) continue; const aKey = vertexKey(a, safeEpsilon); const bKey = vertexKey(b, safeEpsilon); const key = edgeKey(aKey, bKey); const existing = atoms.get(key); if (existing) existing.sourceKeys.add(source.key); else atoms.set(key, { a: aKey.localeCompare(bKey) <= 0 ? a : b, b: aKey.localeCompare(bKey) <= 0 ? b : a, sourceKeys: new Set([source.key]), }); } } return [...atoms.entries()] .map(([key, atom]) => ({ key, a: atom.a, b: atom.b, sourceKeys: [...atom.sourceKeys].sort((a, b) => a.localeCompare(b)), })) .sort((a, b) => a.key.localeCompare(b.key)); } /** Build deterministic simple bounded faces from already-atomized axes. */ export function buildWallFaceGraph( input: readonly WallGraphSourceSegment[], epsilon = DEFAULT_EPSILON, ): WallFaceGraph { const safeEpsilon = Number.isFinite(epsilon) && epsilon > 0 ? epsilon : DEFAULT_EPSILON; const atoms = atomizeWallSegments(input, safeEpsilon); const points = new Map(); const adjacency = new Map>(); const atomsByKey = new Map(atoms.map((atom) => [atom.key, atom])); for (const atom of atoms) { const aKey = vertexKey(atom.a, safeEpsilon); const bKey = vertexKey(atom.b, safeEpsilon); points.set(aKey, atom.a); points.set(bKey, atom.b); if (!adjacency.has(aKey)) adjacency.set(aKey, new Set()); if (!adjacency.has(bKey)) adjacency.set(bKey, new Set()); adjacency.get(aKey)!.add(bKey); adjacency.get(bKey)!.add(aKey); } const sortedAdjacency = new Map(); for (const [key, neighbours] of adjacency) { const origin = points.get(key)!; sortedAdjacency.set(key, [...neighbours].sort((left, right) => { const a = points.get(left)!; const b = points.get(right)!; return Math.atan2(a[1] - origin[1], a[0] - origin[0]) - Math.atan2(b[1] - origin[1], b[0] - origin[0]) || left.localeCompare(right); })); } const directedVisited = new Set(); const facesByKey = new Map(); const directedKey = (a: string, b: string): string => `${a}>${b}`; for (const atom of atoms) { const endpoints = [vertexKey(atom.a, safeEpsilon), vertexKey(atom.b, safeEpsilon)] as const; for (const [startA, startB] of [endpoints, [endpoints[1], endpoints[0]]] as const) { if (directedVisited.has(directedKey(startA, startB))) continue; const vertexKeys: string[] = []; const atomKeys: string[] = []; let a = startA; let b = startB; let closed = false; for (let guard = 0; guard <= atoms.length * 2 + 2; guard++) { const halfKey = directedKey(a, b); if (directedVisited.has(halfKey)) { closed = a === startA && b === startB; break; } directedVisited.add(halfKey); vertexKeys.push(a); atomKeys.push(edgeKey(a, b)); const outgoing = sortedAdjacency.get(b) || []; const reverseIndex = outgoing.indexOf(a); if (reverseIndex < 0 || !outgoing.length) break; const next = outgoing[(reverseIndex - 1 + outgoing.length) % outgoing.length]; a = b; b = next; if (a === startA && b === startB) { closed = true; break; } } if (!closed || new Set(vertexKeys).size < 3 || new Set(vertexKeys).size !== vertexKeys.length) continue; const ring = vertexKeys.map((key) => points.get(key)!) as [number, number][]; const area = signedArea(ring); if (!(area > safeEpsilon * safeEpsilon)) continue; // Derived T/X vertices are topology, not polygon identity. A harmless // subdivision of a straight wall must not make an old face look new. const key = canonicalCycle(identityCycle(vertexKeys, points, safeEpsilon)); const sources = new Set(); for (const key of atomKeys) { for (const sourceKey of atomsByKey.get(key)?.sourceKeys || []) sources.add(sourceKey); } const face: WallGraphFace = { ring, key, area, atomKeys: [...atomKeys], sourceKeys: [...sources].sort((left, right) => left.localeCompare(right)), }; if (!facesByKey.has(key)) facesByKey.set(key, face); } } return { atoms, faces: [...facesByKey.values()].sort((left, right) => left.area - right.area || left.key.localeCompare(right.key)), }; } /** Faces introduced by one accepted source segment, ordered area-first. */ export function findNewWallFaces( before: readonly WallGraphSourceSegment[], after: readonly WallGraphSourceSegment[], addedSourceKey: string, epsilon = DEFAULT_EPSILON, ): WallGraphFace[] { return findNewWallFacesInGraphs( buildWallFaceGraph(before, epsilon), buildWallFaceGraph(after, epsilon), addedSourceKey, ); } /** Delta projection for callers that retain a bounded structural graph cache. */ export function findNewWallFacesInGraphs( before: WallFaceGraph, after: WallFaceGraph, addedSourceKey: string, ): WallGraphFace[] { const beforeKeys = new Set(before.faces.map((face) => face.key)); return after.faces.filter((face) => !beforeKeys.has(face.key) && face.sourceKeys.includes(addedSourceKey)); } function pointOnFaceEdge( point: readonly number[], a: readonly number[], b: readonly number[], epsilon: number, ): boolean { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const length2 = dx * dx + dy * dy; if (!(length2 > 0)) return Math.hypot(point[0] - a[0], point[1] - a[1]) <= epsilon; const t = ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2; if (t < 0 || t > 1) return false; return Math.hypot(a[0] + dx * t - point[0], a[1] + dy * t - point[1]) <= epsilon; } /** Smallest exact bounded face containing a click; boundary hits belong to drawing. */ export function findWallFaceAtPoint( graph: WallFaceGraph, point: readonly number[], epsilon = DEFAULT_EPSILON, ): WallGraphFace | null { if (!finitePoint(point)) return null; const eligible = graph.faces.filter((face) => { if (face.ring.some((a, index) => pointOnFaceEdge( point, a, face.ring[(index + 1) % face.ring.length], epsilon, ))) return false; let inside = false; for (let i = 0, j = face.ring.length - 1; i < face.ring.length; j = i++) { const a = face.ring[i]; const b = face.ring[j]; if ((a[1] > point[1]) !== (b[1] > point[1]) && point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; } } return inside; }); return [...eligible].sort((left, right) => left.area - right.area || left.key.localeCompare(right.key))[0] || null; }