mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
251 lines
8.8 KiB
TypeScript
251 lines
8.8 KiB
TypeScript
/** Pure host resolution shared by editor, renderers and backend-facing projections. */
|
|
import { wallCmToUnits, type WallInterval } from './wall-thickness';
|
|
import type {
|
|
OpeningCfg, PartitionCfg, PartitionOpeningHost,
|
|
} from './types';
|
|
|
|
export type PartitionOpeningOrphanReason =
|
|
| 'invalid-host'
|
|
| 'missing-partition'
|
|
| 'invalid-position'
|
|
| 'invalid-length'
|
|
| 'does-not-fit'
|
|
| 'does-not-fit-jamb';
|
|
|
|
export interface ResolvedPartitionOpening {
|
|
opening: OpeningCfg;
|
|
host: PartitionOpeningHost;
|
|
partition: PartitionCfg;
|
|
center: [number, number];
|
|
angle: number;
|
|
length: number;
|
|
depth: number;
|
|
t: number;
|
|
axis: { a: [number, number]; b: [number, number]; ux: number; uy: number; length: number };
|
|
}
|
|
|
|
export interface PartitionOpeningResolution {
|
|
resolved: ResolvedPartitionOpening | null;
|
|
reason: PartitionOpeningOrphanReason | null;
|
|
}
|
|
|
|
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] =>
|
|
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
|
|
|
/** Physical jamb reserve at either endpoint of one independent wall. */
|
|
export function partitionOpeningJambMargin(
|
|
partition: PartitionCfg, cellCm = 5, gridPitch = 5,
|
|
): number {
|
|
return wallCmToUnits(partition.cm, cellCm, gridPitch) / 2;
|
|
}
|
|
|
|
/** Resolve explicit host identity. There is intentionally no nearest-wall fallback. */
|
|
export function resolvePartitionOpening(
|
|
opening: OpeningCfg,
|
|
partitions: readonly PartitionCfg[],
|
|
lengthScale = 1,
|
|
cellCm = 5,
|
|
gridPitch = 5,
|
|
jambMargin = 0,
|
|
): PartitionOpeningResolution {
|
|
const host = opening.host;
|
|
if (!host || host.kind !== 'partition' || typeof host.id !== 'string' || !host.id)
|
|
return { resolved: null, reason: 'invalid-host' };
|
|
const partition = partitions.find((item) => item.id === host.id);
|
|
if (!partition) return { resolved: null, reason: 'missing-partition' };
|
|
if (!finitePoint(partition.a) || !finitePoint(partition.b) || !Number.isFinite(host.t)
|
|
|| host.t < 0 || host.t > 1) return { resolved: null, reason: 'invalid-position' };
|
|
const dx = partition.b[0] - partition.a[0];
|
|
const dy = partition.b[1] - partition.a[1];
|
|
const axisLength = Math.hypot(dx, dy);
|
|
const length = Number(opening.length) * lengthScale;
|
|
if (!(axisLength > 1e-9) || !(length > 0) || !Number.isFinite(length))
|
|
return { resolved: null, reason: 'invalid-length' };
|
|
const along = host.t * axisLength;
|
|
if (along - length / 2 < jambMargin - 1e-9
|
|
|| along + length / 2 > axisLength - jambMargin + 1e-9)
|
|
return { resolved: null, reason: 'does-not-fit' };
|
|
const ux = dx / axisLength, uy = dy / axisLength;
|
|
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
if (angle >= 90) angle -= 180;
|
|
else if (angle < -90) angle += 180;
|
|
return {
|
|
reason: null,
|
|
resolved: {
|
|
opening,
|
|
host,
|
|
partition,
|
|
center: [partition.a[0] + dx * host.t, partition.a[1] + dy * host.t],
|
|
angle,
|
|
length,
|
|
depth: wallCmToUnits(partition.cm, cellCm, gridPitch),
|
|
t: host.t,
|
|
axis: {
|
|
a: [partition.a[0], partition.a[1]],
|
|
b: [partition.b[0], partition.b[1]],
|
|
ux, uy, length: axisLength,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Explicit zero-margin policy for render, hit-test and legacy round-trip. */
|
|
export function resolvePartitionOpeningCompat(
|
|
opening: OpeningCfg,
|
|
partitions: readonly PartitionCfg[],
|
|
lengthScale = 1,
|
|
cellCm = 5,
|
|
gridPitch = 5,
|
|
): PartitionOpeningResolution {
|
|
return resolvePartitionOpening(opening, partitions, lengthScale, cellCm, gridPitch, 0);
|
|
}
|
|
|
|
/** Strict write policy. Compat/read callers intentionally keep using the
|
|
* zero-margin resolver above so an existing near-end opening never disappears. */
|
|
export function resolvePartitionOpeningStrict(
|
|
opening: OpeningCfg,
|
|
partitions: readonly PartitionCfg[],
|
|
lengthScale = 1,
|
|
cellCm = 5,
|
|
gridPitch = 5,
|
|
): PartitionOpeningResolution {
|
|
const host = opening.host;
|
|
const partition = host?.kind === 'partition'
|
|
? partitions.find((item) => item.id === host.id)
|
|
: undefined;
|
|
if (!partition) return resolvePartitionOpeningCompat(
|
|
opening, partitions, lengthScale, cellCm, gridPitch,
|
|
);
|
|
const result = resolvePartitionOpening(
|
|
opening, partitions, lengthScale, cellCm, gridPitch,
|
|
partitionOpeningJambMargin(partition, cellCm, gridPitch),
|
|
);
|
|
return result.reason === 'does-not-fit'
|
|
? { resolved: null, reason: 'does-not-fit-jamb' }
|
|
: result;
|
|
}
|
|
|
|
/** Only direct geometry edits opt a legacy record into the strict policy. */
|
|
export function partitionOpeningNeedsStrictValidation(
|
|
previous: OpeningCfg | null | undefined,
|
|
candidate: OpeningCfg,
|
|
): boolean {
|
|
if (!previous) return true;
|
|
return previous.length !== candidate.length
|
|
|| previous.host?.kind !== candidate.host?.kind
|
|
|| previous.host?.id !== candidate.host?.id
|
|
|| previous.host?.t !== candidate.host?.t;
|
|
}
|
|
|
|
export function partitionOpeningCut(resolved: ResolvedPartitionOpening): {
|
|
hostId: string; a: [number, number]; b: [number, number]; depth: number;
|
|
} {
|
|
const { center, length, axis, host } = resolved;
|
|
const half = length / 2;
|
|
return {
|
|
hostId: host.id,
|
|
a: [center[0] - axis.ux * half, center[1] - axis.uy * half],
|
|
b: [center[0] + axis.ux * half, center[1] + axis.uy * half],
|
|
depth: resolved.depth,
|
|
};
|
|
}
|
|
|
|
export function partitionOpeningFace(
|
|
resolved: ResolvedPartitionOpening,
|
|
flipV = false,
|
|
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
|
|
const side = (flipV ? 1 : -1) as -1 | 1;
|
|
const half = resolved.depth / 2;
|
|
return {
|
|
ox: -resolved.axis.uy * side * half,
|
|
oy: resolved.axis.ux * side * half,
|
|
cm: resolved.partition.cm,
|
|
side,
|
|
};
|
|
}
|
|
|
|
/** Candidate intervals for the existing room-wall placement resolver. */
|
|
export function partitionPlacementIntervals(
|
|
partitions: readonly PartitionCfg[], cellCm: number, gridPitch: number,
|
|
): Array<WallInterval & { partitionHost: { kind: 'partition'; id: string } }> {
|
|
return partitions.flatMap((partition) => {
|
|
if (!finitePoint(partition.a) || !finitePoint(partition.b)
|
|
|| Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]) <= 1e-9)
|
|
return [];
|
|
return [{
|
|
roomId: '',
|
|
a: [partition.a[0], partition.a[1]],
|
|
b: [partition.b[0], partition.b[1]],
|
|
key: `partition:${partition.id}`,
|
|
kind: 'outer' as const,
|
|
cm: partition.cm,
|
|
open: false,
|
|
half: wallCmToUnits(partition.cm, cellCm, gridPitch) / 2,
|
|
partitionHost: { kind: 'partition' as const, id: partition.id },
|
|
}];
|
|
});
|
|
}
|
|
|
|
/** Exact collinear coverage, used only for a computed composite room-wall cut. */
|
|
export function partitionOpeningHasCompositeRoomWall(
|
|
resolved: ResolvedPartitionOpening,
|
|
intervals: readonly WallInterval[],
|
|
epsilon: number,
|
|
): boolean {
|
|
const { center, length, axis } = resolved;
|
|
const half = length / 2;
|
|
const coverage = intervals.flatMap((interval) => {
|
|
if (!interval.kind || interval.open) return [];
|
|
const dx = interval.b[0] - interval.a[0], dy = interval.b[1] - interval.a[1];
|
|
const span = Math.hypot(dx, dy);
|
|
if (!(span > epsilon)) return [];
|
|
const ux = dx / span, uy = dy / span;
|
|
if (Math.abs(axis.ux * uy - axis.uy * ux) > 1e-6) return [];
|
|
const lineDistance = Math.abs(
|
|
(center[0] - interval.a[0]) * uy - (center[1] - interval.a[1]) * ux,
|
|
);
|
|
if (lineDistance > epsilon) return [];
|
|
const along = (point: readonly number[]) =>
|
|
(point[0] - center[0]) * axis.ux + (point[1] - center[1]) * axis.uy;
|
|
const a = along(interval.a), b = along(interval.b);
|
|
const lo = Math.max(-half, Math.min(a, b));
|
|
const hi = Math.min(half, Math.max(a, b));
|
|
return hi >= lo - epsilon ? [[lo, hi] as [number, number]] : [];
|
|
});
|
|
if (!coverage.length) return false;
|
|
coverage.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
let reached = -half;
|
|
for (const [lo, hi] of coverage) {
|
|
if (lo > reached + epsilon) return false;
|
|
reached = Math.max(reached, hi);
|
|
if (reached >= half - epsilon) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function hostedOpeningIntervalsOverlap(
|
|
candidate: ResolvedPartitionOpening,
|
|
others: readonly ResolvedPartitionOpening[],
|
|
epsilon = 1e-9,
|
|
): boolean {
|
|
const half = candidate.length / (2 * candidate.axis.length);
|
|
const lo = candidate.t - half, hi = candidate.t + half;
|
|
return others.some((other) => {
|
|
if (other.host.id !== candidate.host.id || other.opening.id === candidate.opening.id) return false;
|
|
const otherHalf = other.length / (2 * other.axis.length);
|
|
return Math.max(lo, other.t - otherHalf) < Math.min(hi, other.t + otherHalf) - epsilon;
|
|
});
|
|
}
|
|
|
|
/** Update compatibility fields after a host move without changing host semantics. */
|
|
export function materializePartitionOpening(
|
|
opening: OpeningCfg, resolved: ResolvedPartitionOpening, coordScale: number,
|
|
): OpeningCfg {
|
|
return {
|
|
...opening,
|
|
x: resolved.center[0] / coordScale,
|
|
y: resolved.center[1] / coordScale,
|
|
angle: resolved.angle,
|
|
};
|
|
}
|