mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
782 lines
29 KiB
TypeScript
782 lines
29 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
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<T>(
|
|
candidates: BoundaryCandidate<T>[], ambiguity: number, eps: number,
|
|
): BoundaryCandidate<T> | '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<number[]>[] = [];
|
|
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<SharedSeg>[] = [];
|
|
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<string, { lo: number; hi: number }[]>();
|
|
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 };
|