Files
houseplan-card/src/wall-face-graph.ts
T
2026-08-22 16:49:29 +03:00

603 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*
* Strictly positive is the validity boundary. The previous `wallChainSegments`
* accepted a recorded zero, which cannot be drawn through the UI (1..100 cm,
* `docs/WALL-THICKNESS.md`) but can sit in an old draft.
*/
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 —
// дефект вызывающего, поэтому он приводится к минимальной допустимой
// толщине (1 см, docs/WALL-THICKNESS.md), а не к выдуманному значению.
const active = valid(activeCm);
const fallback = valid(defaultCm) ?? 1;
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 positive 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<string, readonly number[]>, 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<number>();
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<string, {
a: [number, number]; b: [number, number]; sourceKeys: Set<string>;
}>();
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<string, [number, number]>();
const adjacency = new Map<string, Set<string>>();
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<string, string[]>();
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<string>();
const facesByKey = new Map<string, WallGraphFace>();
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<string>();
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;
}