mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
/**
|
|
* 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<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;
|
|
}
|