import type { WallInterval } from './wall-thickness'; export type OpeningPlacementType = 'window' | 'door' | 'passage' | 'gate'; export interface OpeningPlacementPreset { type: OpeningPlacementType; lengthCm: number; flipH: boolean; flipV: boolean; revision: number; } export interface OpeningPlacementTarget { segmentKey: string; a: [number, number]; b: [number, number]; physicalHalfWidth: number; sourceOrder: number; /** Explicit independent-wall owner; room-wall targets leave it absent. */ partitionHost?: { kind: 'partition'; id: string }; /** More than one coincident independent wall cannot be selected safely. */ ambiguousPartitionHost?: boolean; } export interface OpeningPlacementMeasureGeometry { labels: readonly [ { distance: number; midpoint: [number, number] }, { distance: number; midpoint: [number, number] }, ]; guide: { x: number; y: number; angle: number } | null; } export interface OpeningPlacementCore { presetRevision: number; geometryRevision: number; pointer: [number, number]; type: OpeningPlacementType; lengthCm: number; flipH: boolean; flipV: boolean; x: number; y: number; angle: number; renderedLength: number; target: OpeningPlacementTarget; host?: { kind: 'partition'; id: string; t: number }; measure: OpeningPlacementMeasureGeometry; } export interface OpeningPlacementResolution { candidate: OpeningPlacementCore | null; /** Closest independent wall rejected specifically by its jamb reserve. */ jambBlockedTarget: OpeningPlacementTarget | null; } export interface PassagePlacementPreviewGeometry { rect: { x: number; y: number; width: number; height: number }; boundaries: readonly [ { x1: number; y1: number; x2: number; y2: number }, { x1: number; y1: number; x2: number; y2: number }, ]; } export interface ResolveOpeningPlacementInput { pointer: readonly [number, number]; preset: OpeningPlacementPreset; geometryRevision: number; renderedLength: number; intervals: readonly WallInterval[]; baseTolerance: number; bodyPointerPadding: number; gridStep: number; } const DEFAULTS: Record = { window: 120, door: 90, passage: 90, gate: 300, }; /** One authority for toolbar presets and dialog type changes. */ export function openingDefaultLengthCm(type: OpeningPlacementType): number { return DEFAULTS[type]; } export function openingPlacementPreset( type: OpeningPlacementType, revision: number, ): OpeningPlacementPreset { return { type, lengthCm: openingDefaultLengthCm(type), flipH: false, flipV: false, revision }; } /** Local SVG geometry for the passage-only placement overlay. The candidate * remains the authority for both the future wall cut and the preview. */ export function passagePlacementPreviewGeometry( candidate: Pick, gridPitch: number, ): PassagePlacementPreviewGeometry { const halfLength = Math.max(0, candidate.renderedLength) / 2; const halfDepth = Math.max(0, candidate.target.physicalHalfWidth); const boundaryHalfLength = halfDepth + Math.max(0, gridPitch) * 0.18; const boundary = (x: number) => ({ x1: x, y1: -boundaryHalfLength, x2: x, y2: boundaryHalfLength, }); return { rect: { x: -halfLength, y: halfDepth ? -halfDepth : 0, width: halfLength * 2, height: halfDepth * 2, }, boundaries: [boundary(-halfLength), boundary(halfLength)], }; } function pointOrder(a: readonly number[], b: readonly number[]): number { return a[0] - b[0] || a[1] - b[1]; } function canonicalEnds(a: readonly number[], b: readonly number[]): { a: [number, number]; b: [number, number]; } { const aa: [number, number] = [a[0], a[1]]; const bb: [number, number] = [b[0], b[1]]; return pointOrder(aa, bb) <= 0 ? { a: aa, b: bb } : { a: bb, b: aa }; } /** WallEntry.key intentionally omits segment length so it can survive some * geometry rewrites. Placement identity has a narrower job: only the two * room-owned copies of the exact same atomic span may collapse. Include both * canonical endpoints so concentric/overlapping spans cannot alias. */ function atomicSegmentKey(a: readonly number[], b: readonly number[]): string { const clean = (value: number): string => { const rounded = Math.abs(value) <= 5e-10 ? 0 : Math.round(value * 1e6) / 1e6; return rounded.toFixed(6); }; return `${clean(a[0])},${clean(a[1])}>${clean(b[0])},${clean(b[1])}`; } /** * Collapse the two room-owned copies of a shared wall into one transient target. * The key is valid only for this derived geometry epoch; it is never persisted * into OpeningCfg, whose compatibility contract remains absolute x/y/angle. */ export function openingPlacementTargets( intervals: readonly (WallInterval & { partitionHost?: { kind: 'partition'; id: string }; })[], ): OpeningPlacementTarget[] { const targets = new Map(); intervals.forEach((interval, sourceOrder) => { if (!interval.kind || interval.open) return; const ends = canonicalEnds(interval.a, interval.b); if (Math.hypot(ends.b[0] - ends.a[0], ends.b[1] - ends.a[1]) <= 1e-9) return; const segmentKey = atomicSegmentKey(ends.a, ends.b); const previous = targets.get(segmentKey); if (previous) { previous.physicalHalfWidth = Math.max(previous.physicalHalfWidth, interval.half || 0); previous.sourceOrder = Math.min(previous.sourceOrder, sourceOrder); if (interval.partitionHost) { if (previous.partitionHost && previous.partitionHost.id !== interval.partitionHost.id) previous.ambiguousPartitionHost = true; else previous.partitionHost = interval.partitionHost; } return; } targets.set(segmentKey, { segmentKey, a: ends.a, b: ends.b, physicalHalfWidth: Math.max(0, interval.half || 0), sourceOrder, ...(interval.partitionHost ? { partitionHost: interval.partitionHost } : {}), }); }); return [...targets.values()]; } /** A thick target's generous body envelope may extend beyond its endpoint. * If that endpoint continues as a collinear virtual interval, the virtual * centreline owns the longitudinal region and must block placement there. A * crossing virtual wall does not block the physical target underneath it. */ function pointerInsideCollinearOpenSpan( pointer: readonly [number, number], target: OpeningPlacementTarget, intervals: readonly WallInterval[], envelope: number, lineEpsilon: number, ): boolean { const tx = target.b[0] - target.a[0], ty = target.b[1] - target.a[1]; const targetLength = Math.hypot(tx, ty); if (!(targetLength > 1e-9)) return false; const tux = tx / targetLength, tuy = ty / targetLength; for (const interval of intervals) { if (!interval.open && interval.kind) continue; const ends = canonicalEnds(interval.a, interval.b); const dx = ends.b[0] - ends.a[0], dy = ends.b[1] - ends.a[1]; const length = Math.hypot(dx, dy); if (!(length > 1e-9)) continue; const ux = dx / length, uy = dy / length; if (Math.abs(tux * uy - tuy * ux) > 1e-6) continue; const lineOffset = Math.abs( (ends.a[0] - target.a[0]) * tuy - (ends.a[1] - target.a[1]) * tux, ); if (lineOffset > lineEpsilon) continue; const along = (pointer[0] - ends.a[0]) * ux + (pointer[1] - ends.a[1]) * uy; // Only the mathematical endpoint remains available to the adjacent // physical segment. Reusing the looser line-collinearity epsilon here // would leave a small but real physical hit strip inside the virtual span. const endpointEpsilon = 1e-9; if (along <= endpointEpsilon || along >= length - endpointEpsilon) continue; const perpendicular = Math.abs( (pointer[0] - ends.a[0]) * uy - (pointer[1] - ends.a[1]) * ux, ); if (perpendicular <= envelope + 1e-9) return true; } return false; } function projection( point: readonly [number, number], target: OpeningPlacementTarget, ): { along: number; length: number; x: number; y: number; distance: number; perpendicular: number } { const dx = target.b[0] - target.a[0]; const dy = target.b[1] - target.a[1]; const length = Math.hypot(dx, dy); const ux = dx / length, uy = dy / length; const rawAlong = (point[0] - target.a[0]) * ux + (point[1] - target.a[1]) * uy; const along = Math.max(0, Math.min(length, rawAlong)); const x = target.a[0] + along * ux; const y = target.a[1] + along * uy; return { along, length, x, y, distance: Math.hypot(point[0] - x, point[1] - y), perpendicular: Math.abs((point[0] - target.a[0]) * uy - (point[1] - target.a[1]) * ux), }; } function targetCompare( a: { target: OpeningPlacementTarget; distance: number; perpendicular: number }, b: { target: OpeningPlacementTarget; distance: number; perpendicular: number }, ): number { const eps = 1e-9; if (Math.abs(a.distance - b.distance) > eps) return a.distance - b.distance; if (Math.abs(a.perpendicular - b.perpendicular) > eps) return a.perpendicular - b.perpendicular; const key = a.target.segmentKey < b.target.segmentKey ? -1 : a.target.segmentKey > b.target.segmentKey ? 1 : 0; return key || a.target.sourceOrder - b.target.sourceOrder; } /** Pure hover/click resolver. It selects one bounded physical wall interval, * projects to its canonical axis and applies the established along-wall grid * plus centre magnet. No config, history or renderer cache is mutated. */ export function resolveOpeningPlacementResult( input: ResolveOpeningPlacementInput, ): OpeningPlacementResolution { const targets = openingPlacementTargets(input.intervals); const pointerEligible = targets.map((target) => { const p = projection(input.pointer, target); const envelope = Math.max( input.baseTolerance, target.physicalHalfWidth + input.bodyPointerPadding, ); return { target, ...p, envelope }; }).filter((item) => item.distance <= item.envelope + 1e-9) .filter((item) => !pointerInsideCollinearOpenSpan( input.pointer, item.target, input.intervals, item.envelope, Math.max(1e-9, Math.min(input.baseTolerance, input.gridStep * 0.04)), )); const jambBlocked = pointerEligible .filter((item) => !!item.target.partitionHost && input.renderedLength + 2 * item.target.physicalHalfWidth > item.length + 1e-9) .sort(targetCompare); const eligible = pointerEligible // Room-wall compatibility keeps its historical wide-gate behaviour. An // explicit partition host also reserves half its physical depth per end. .filter((item) => !item.target.partitionHost || input.renderedLength + 2 * item.target.physicalHalfWidth <= item.length + 1e-9) .sort(targetCompare); let picked = eligible[0]; if (!picked) return { candidate: null, jambBlockedTarget: jambBlocked[0]?.target || null }; // A room wall and an independently persisted wall may cover the same axis // without sharing identical endpoints. In that composite case the stable // partition id is the only useful persisted owner. A genuinely crossing // partition tie is not a composite and must not be resolved by lexical key. const tied = eligible.filter((item) => Math.abs(item.distance - picked.distance) <= 1e-9 && Math.abs(item.perpendicular - picked.perpendicular) <= 1e-9); const hosted = tied.filter((item) => item.target.partitionHost); if (hosted.length) { const hostIds = new Set(hosted.map((item) => item.target.partitionHost!.id)); if (hostIds.size !== 1 || hosted.some((item) => item.target.ambiguousPartitionHost)) return { candidate: null, jambBlockedTarget: null }; const hostPick = hosted[0]; const hx = hostPick.target.b[0] - hostPick.target.a[0]; const hy = hostPick.target.b[1] - hostPick.target.a[1]; const hLength = Math.hypot(hx, hy); const hux = hx / hLength, huy = hy / hLength; const composite = tied.every((item) => { const tx = item.target.b[0] - item.target.a[0]; const ty = item.target.b[1] - item.target.a[1]; const tLength = Math.hypot(tx, ty); const tux = tx / tLength, tuy = ty / tLength; const parallel = Math.abs(hux * tuy - huy * tux) <= 1e-6; const offset = Math.abs( (item.target.a[0] - hostPick.target.a[0]) * huy - (item.target.a[1] - hostPick.target.a[1]) * hux, ); return parallel && offset <= 1e-9; }); if (!composite) return { candidate: null, jambBlockedTarget: null }; picked = hostPick; } if (picked.target.ambiguousPartitionHost) return { candidate: null, jambBlockedTarget: null }; const { target, length } = picked; const dx = target.b[0] - target.a[0], dy = target.b[1] - target.a[1]; const ux = dx / length, uy = dy / length; const jambMargin = target.partitionHost ? target.physicalHalfWidth : 0; const half = Math.min( Math.max(0, input.renderedLength) / 2 + jambMargin, length / 2, ); let along = picked.along; const grid = Math.max(input.gridStep, 1e-9); const wallCenter = length / 2; const centered = Math.abs(along - wallCenter) <= grid / 2; along = centered ? wallCenter : Math.round(along / grid) * grid; along = Math.max(half, Math.min(length - half, along)); const x = target.a[0] + ux * along; const y = target.a[1] + uy * along; const openingHalf = Math.max(0, input.renderedLength) / 2; const leftEdge = along - openingHalf; const rightEdge = along + openingHalf; const sideA = Math.max(0, leftEdge); const sideB = Math.max(0, length - rightEdge); const midpointA: [number, number] = [ target.a[0] + ux * (leftEdge - sideA / 2), target.a[1] + uy * (leftEdge - sideA / 2), ]; const midpointB: [number, number] = [ target.a[0] + ux * (rightEdge + sideB / 2), target.a[1] + uy * (rightEdge + sideB / 2), ]; let angle = Math.atan2(dy, dx) * 180 / Math.PI; if (angle >= 90) angle -= 180; else if (angle < -90) angle += 180; const isCentered = Math.abs(along - wallCenter) <= 1e-9; return { candidate: { presetRevision: input.preset.revision, geometryRevision: input.geometryRevision, pointer: [input.pointer[0], input.pointer[1]], type: input.preset.type, lengthCm: input.preset.lengthCm, flipH: input.preset.flipH, flipV: input.preset.flipV, x, y, angle, renderedLength: input.renderedLength, target, ...(target.partitionHost ? { host: { ...target.partitionHost, t: length > 0 ? along / length : 0 }, } : {}), measure: { labels: [ { distance: sideA, midpoint: midpointA }, { distance: sideB, midpoint: midpointB }, ], guide: isCentered ? { x, y, angle } : null, }, }, jambBlockedTarget: null }; } export function resolveOpeningPlacement( input: ResolveOpeningPlacementInput, ): OpeningPlacementCore | null { return resolveOpeningPlacementResult(input).candidate; } export function sameOpeningPlacementInput( candidate: Pick, pointer: readonly [number, number], presetRevision: number, geometryRevision: number, epsilon = 1e-6, ): boolean { return candidate.presetRevision === presetRevision && candidate.geometryRevision === geometryRevision && Math.hypot(candidate.pointer[0] - pointer[0], candidate.pointer[1] - pointer[1]) <= epsilon; }