mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
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<OpeningPlacementType, number> = {
|
|
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<OpeningPlacementCore, 'renderedLength' | 'target'>,
|
|
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<string, OpeningPlacementTarget>();
|
|
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<OpeningPlacementCore, 'presetRevision' | 'geometryRevision' | 'pointer'>,
|
|
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;
|
|
}
|