mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
/**
|
|
* Pure production-geometry preparation and the explicit Optimize preflight.
|
|
*
|
|
* This module deliberately owns no Lit, DOM or Home Assistant state. The card
|
|
* uses the same preparation helpers for its renderer, so the safety check
|
|
* cannot quietly drift into a second, simplified geometry algorithm.
|
|
*/
|
|
import { distToSegment, roomPoly, sharedBoundary } from './logic';
|
|
import { resolveOpenCuts, type OpenSpanEntry } from './open-spans';
|
|
import {
|
|
partitionOpeningCut,
|
|
partitionOpeningHasCompositeRoomWall,
|
|
resolvePartitionOpeningCompat,
|
|
type ResolvedPartitionOpening,
|
|
} from './partition-openings';
|
|
import { physicalBodyParts, type PartitionOpeningCut } from './physical-geometry';
|
|
import {
|
|
floorFootprintGeometry,
|
|
wallBodiesGeometry,
|
|
wallIntervals,
|
|
type WallEntry,
|
|
} from './wall-thickness';
|
|
import {
|
|
GRID_PITCH,
|
|
GRID_STEP_N,
|
|
NORM_W,
|
|
spaceModels,
|
|
} from './space-geometry';
|
|
import type { OpeningCfg, ServerConfig, SpaceModel } from './types';
|
|
import { contentFingerprint } from './visual-continuity';
|
|
|
|
export interface GeometryOpeningProjection extends OpeningCfg {
|
|
rx: number;
|
|
ry: number;
|
|
rlen: number;
|
|
partitionHost?: ResolvedPartitionOpening;
|
|
}
|
|
|
|
export interface RoomOpeningGeometryInput {
|
|
x: number;
|
|
y: number;
|
|
angle: number;
|
|
length: number;
|
|
}
|
|
|
|
export interface SpacePhysicalGeometryInputs {
|
|
space: SpaceModel;
|
|
walls: WallEntry[];
|
|
openCuts: number[][];
|
|
openings: GeometryOpeningProjection[];
|
|
roomOpenings: RoomOpeningGeometryInput[];
|
|
partitionCuts: PartitionOpeningCut[];
|
|
physicalBodies: number[][][];
|
|
wallKeyPitch: number;
|
|
cellCm: number;
|
|
gridPitch: number;
|
|
coordScale: number;
|
|
}
|
|
|
|
/**
|
|
* #295: a PRIVACY-SAFE marker of the caught exception. The result deliberately
|
|
* carries the error CLASS, never the message: messages can leak entity ids,
|
|
* file paths or user data, and the existing preflight contract (unit «null,
|
|
* exceptions and floor failure are bounded…») forbids them in the payload.
|
|
*/
|
|
export function preflightErrorDetail(error: unknown): string {
|
|
if (error instanceof Error) return error.name || 'Error';
|
|
return typeof error;
|
|
}
|
|
|
|
export type OptimizeGeometryFailureReason =
|
|
| 'prepare-exception'
|
|
| 'wall-null'
|
|
| 'wall-degraded-extra'
|
|
| 'wall-failed-core'
|
|
| 'wall-exception'
|
|
| 'floor-null'
|
|
| 'floor-exception';
|
|
|
|
export type OptimizeSpaceGeometryStatus = 'ok' | 'failed' | 'not-applicable';
|
|
|
|
export interface OptimizeSpaceGeometryCheck {
|
|
spaceId: string;
|
|
displayName: string;
|
|
status: OptimizeSpaceGeometryStatus;
|
|
reason?: OptimizeGeometryFailureReason;
|
|
/**
|
|
* #295: the caught exception's CLASS (never the message — privacy contract)
|
|
* for the *-exception reasons. Non-exception reasons are self-describing
|
|
* and leave it unset. Diagnostic payloads carry it; user strings do not.
|
|
*/
|
|
detail?: string;
|
|
}
|
|
|
|
export interface OptimizeGeometryPreflightResult {
|
|
fingerprint: string;
|
|
spaces: OptimizeSpaceGeometryCheck[];
|
|
failures: Array<OptimizeSpaceGeometryCheck & {
|
|
status: 'failed'; reason: OptimizeGeometryFailureReason;
|
|
}>;
|
|
ok: boolean;
|
|
}
|
|
|
|
export interface CheckOptimizeGeometryOptions {
|
|
fallbackSpaceName?: (oneBasedIndex: number) => string;
|
|
prepareSpace?: typeof prepareSpacePhysicalGeometryInputs;
|
|
wallPass?: typeof wallBodiesGeometry;
|
|
floorPass?: typeof floorFootprintGeometry;
|
|
fingerprint?: typeof contentFingerprint;
|
|
/** Internal reuse seam: receives the exact successful wall pass without
|
|
* adding private plan geometry to the serializable preflight result. */
|
|
captureWallGeometry?: (
|
|
input: SpacePhysicalGeometryInputs,
|
|
geometry: ReturnType<typeof wallBodiesGeometry>,
|
|
) => void;
|
|
}
|
|
|
|
export interface SpacePhysicalGeometryResult extends OptimizeSpaceGeometryCheck {
|
|
fingerprint: string;
|
|
ok: boolean;
|
|
}
|
|
|
|
/** Fingerprint only fields capable of changing canonical physical geometry. */
|
|
export function spacePhysicalGeometryFingerprint(spaceConfig: any): string {
|
|
return contentFingerprint({
|
|
id: spaceConfig?.id ?? '',
|
|
cell_cm: spaceConfig?.cell_cm,
|
|
rooms: spaceConfig?.rooms || [],
|
|
walls: spaceConfig?.walls || [],
|
|
wall_segments: spaceConfig?.wall_segments || [],
|
|
open_spans: spaceConfig?.open_spans || [],
|
|
openings: spaceConfig?.openings || [],
|
|
partitions: spaceConfig?.partitions || [],
|
|
wall_columns: spaceConfig?.wall_columns || [],
|
|
});
|
|
}
|
|
|
|
/** Strict one-space barrier backed by the exact same pass as Optimize. */
|
|
export function checkSpacePhysicalGeometry(
|
|
config: ServerConfig | any,
|
|
spaceId: string,
|
|
options: CheckOptimizeGeometryOptions = {},
|
|
): SpacePhysicalGeometryResult {
|
|
const allSpaces = Array.isArray(config?.spaces) ? config.spaces : [];
|
|
const raw = allSpaces.find((space: any) => String(space?.id || '') === String(spaceId || ''));
|
|
const fingerprint = spacePhysicalGeometryFingerprint(raw);
|
|
if (!raw) {
|
|
return {
|
|
spaceId: String(spaceId || ''), displayName: '', status: 'failed',
|
|
reason: 'prepare-exception', fingerprint, ok: false,
|
|
};
|
|
}
|
|
const result = checkOptimizeGeometry(
|
|
{ ...config, spaces: [raw] },
|
|
{ ...options, fingerprint: () => fingerprint },
|
|
);
|
|
const space = result.spaces[0] || {
|
|
spaceId: String(spaceId || ''), displayName: '', status: 'failed' as const,
|
|
reason: 'prepare-exception' as const,
|
|
};
|
|
return { ...space, fingerprint, ok: space.status !== 'failed' };
|
|
}
|
|
|
|
/** Resolve explicit spans and the legacy open_to fallback exactly as the card. */
|
|
export function geometryOpenCuts(
|
|
spaceConfig: any,
|
|
space: Pick<SpaceModel, 'rooms'>,
|
|
gridPitch = GRID_PITCH,
|
|
coordScale = NORM_W,
|
|
): number[][] {
|
|
return resolveOpenCuts(
|
|
space.rooms,
|
|
spaceConfig?.open_spans as OpenSpanEntry[] | undefined,
|
|
coordScale,
|
|
gridPitch * 0.02,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Group open cuts by the two rooms that own their shared boundary. Wall
|
|
* masonry has historically consumed this filtered/ordered projection rather
|
|
* than the raw span array; both renderer and preflight share it here.
|
|
*/
|
|
export function geometryOpenPairs(
|
|
roomsInput: readonly any[],
|
|
cuts: readonly number[][],
|
|
gridPitch = GRID_PITCH,
|
|
): Array<{ a: any; b: any; segs: number[][] }> {
|
|
if (!cuts.length) return [];
|
|
const rooms = roomsInput.filter((room) => room?.id);
|
|
const eps = gridPitch * 0.02;
|
|
const result: Array<{ a: any; b: any; segs: number[][] }> = [];
|
|
for (let left = 0; left < rooms.length; left++) {
|
|
for (let right = left + 1; right < rooms.length; right++) {
|
|
const a = rooms[left], b = rooms[right];
|
|
const polygonA = roomPoly(a), polygonB = roomPoly(b);
|
|
if (!polygonA || !polygonB) continue;
|
|
const shared = sharedBoundary(polygonA, polygonB, eps);
|
|
if (!shared.length) continue;
|
|
const segs = cuts.filter((cut) => {
|
|
const midpoint = [(cut[0] + cut[2]) / 2, (cut[1] + cut[3]) / 2];
|
|
return shared.some((segment) => distToSegment(midpoint, segment) < eps * 4);
|
|
}).map((cut) => [...cut]);
|
|
if (segs.length) result.push({ a, b, segs });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Project stored openings into render units; invalid hosted records stay inert. */
|
|
export function geometryOpenings(
|
|
spaceConfig: any,
|
|
space: Pick<SpaceModel, 'partitions'>,
|
|
cellCm: number,
|
|
gridPitch = GRID_PITCH,
|
|
coordScale = NORM_W,
|
|
): GeometryOpeningProjection[] {
|
|
const raw = Array.isArray(spaceConfig?.openings) ? spaceConfig.openings : [];
|
|
return raw.flatMap((opening: OpeningCfg) => {
|
|
const fallback: GeometryOpeningProjection = {
|
|
...opening,
|
|
rx: Number(opening.x) * coordScale,
|
|
ry: Number(opening.y) * coordScale,
|
|
rlen: Number(opening.length) * coordScale,
|
|
};
|
|
// A contour-wall host is stable identity metadata. Its materialised
|
|
// x/y/angle remain the room-opening projection until the graph renderer;
|
|
// only partition hosts need spatial resolution here.
|
|
if (!opening.host || opening.host.kind === 'wall') return [fallback];
|
|
const resolution = resolvePartitionOpeningCompat(
|
|
opening, space.partitions, coordScale, cellCm, gridPitch,
|
|
);
|
|
if (!resolution.resolved) return [];
|
|
return [{
|
|
...opening,
|
|
rx: resolution.resolved.center[0],
|
|
ry: resolution.resolved.center[1],
|
|
rlen: resolution.resolved.length,
|
|
angle: resolution.resolved.angle,
|
|
partitionHost: resolution.resolved,
|
|
}];
|
|
});
|
|
}
|
|
|
|
export function geometryPartitionOpeningCuts(
|
|
openings: readonly GeometryOpeningProjection[],
|
|
accept: (opening: OpeningCfg) => boolean = () => true,
|
|
): PartitionOpeningCut[] {
|
|
return openings.flatMap((opening) => (
|
|
opening.host && opening.partitionHost && accept(opening)
|
|
? [partitionOpeningCut(opening.partitionHost)]
|
|
: []
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Room masonry is cut by a hosted opening only for the exact composite wall
|
|
* rule used by the production card. Ordinary openings always remain inputs.
|
|
*/
|
|
export function geometryRoomOpeningInputs(
|
|
openings: readonly GeometryOpeningProjection[],
|
|
space: Pick<SpaceModel, 'rooms'>,
|
|
walls: readonly WallEntry[],
|
|
openCuts: number[][],
|
|
wallKeyPitch: number,
|
|
cellCm: number,
|
|
gridPitch = GRID_PITCH,
|
|
coordScale = NORM_W,
|
|
): RoomOpeningGeometryInput[] {
|
|
const intervals = wallIntervals(
|
|
space.rooms, [...walls], openCuts,
|
|
wallKeyPitch, cellCm, gridPitch, coordScale,
|
|
);
|
|
return openings.flatMap((opening) => {
|
|
const input = {
|
|
x: opening.rx,
|
|
y: opening.ry,
|
|
angle: Number(opening.angle) || 0,
|
|
length: opening.rlen,
|
|
};
|
|
if (!opening.host || opening.host.kind === 'wall') return [input];
|
|
if (!opening.partitionHost) return [];
|
|
return partitionOpeningHasCompositeRoomWall(
|
|
opening.partitionHost, intervals, gridPitch * 0.0002,
|
|
) ? [input] : [];
|
|
});
|
|
}
|
|
|
|
/** Prepare every argument consumed by the production boolean passes. */
|
|
export function prepareSpacePhysicalGeometryInputs(
|
|
spaceConfig: any,
|
|
space: SpaceModel,
|
|
): SpacePhysicalGeometryInputs {
|
|
const wallKeyPitch = GRID_STEP_N;
|
|
const gridPitch = GRID_PITCH;
|
|
const coordScale = NORM_W;
|
|
const cellCm = Number.isFinite(Number(space.cellCm)) && Number(space.cellCm) > 0
|
|
? Number(space.cellCm) : 5;
|
|
const walls = Array.isArray(spaceConfig?.walls)
|
|
? spaceConfig.walls as WallEntry[] : [];
|
|
const cuts = geometryOpenCuts(spaceConfig, space, gridPitch, coordScale);
|
|
const openCuts = geometryOpenPairs(space.rooms, cuts, gridPitch)
|
|
.flatMap((pair) => pair.segs);
|
|
const openings = geometryOpenings(
|
|
spaceConfig, space, cellCm, gridPitch, coordScale,
|
|
);
|
|
const partitionCuts = geometryPartitionOpeningCuts(openings);
|
|
const physicalBodies = physicalBodyParts(
|
|
space, cellCm, gridPitch, gridPitch * 0.0002, partitionCuts,
|
|
).all;
|
|
const roomOpenings = geometryRoomOpeningInputs(
|
|
openings, space, walls, openCuts,
|
|
wallKeyPitch, cellCm, gridPitch, coordScale,
|
|
);
|
|
return {
|
|
space, walls, openCuts, openings, roomOpenings, partitionCuts,
|
|
physicalBodies, wallKeyPitch, cellCm, gridPitch, coordScale,
|
|
};
|
|
}
|
|
|
|
function safeDisplayName(
|
|
spaceConfig: any,
|
|
index: number,
|
|
fallback: (oneBasedIndex: number) => string,
|
|
): { spaceId: string; displayName: string } {
|
|
const title = typeof spaceConfig?.title === 'string' ? spaceConfig.title.trim() : '';
|
|
const id = spaceConfig?.id == null ? '' : String(spaceConfig.id).trim();
|
|
return {
|
|
spaceId: id,
|
|
displayName: title || id || fallback(index + 1),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fail-closed renderability check for the exact config returned by Optimize.
|
|
* Geometry values never escape this call; the dialog retains only bounded
|
|
* statuses, names and the candidate fingerprint.
|
|
*/
|
|
export function checkOptimizeGeometry(
|
|
config: ServerConfig | any,
|
|
options: CheckOptimizeGeometryOptions = {},
|
|
): OptimizeGeometryPreflightResult {
|
|
const fingerprint = (options.fingerprint || contentFingerprint)(config);
|
|
const rawSpaces = Array.isArray(config?.spaces) ? config.spaces : [];
|
|
const fallback = options.fallbackSpaceName || ((index: number) => `Space ${index}`);
|
|
const prepare = options.prepareSpace || prepareSpacePhysicalGeometryInputs;
|
|
const buildWalls = options.wallPass || wallBodiesGeometry;
|
|
const buildFloor = options.floorPass || floorFootprintGeometry;
|
|
const spaces: OptimizeSpaceGeometryCheck[] = [];
|
|
|
|
for (let index = 0; index < rawSpaces.length; index++) {
|
|
const raw = rawSpaces[index];
|
|
const identity = safeDisplayName(raw, index, fallback);
|
|
let input: SpacePhysicalGeometryInputs;
|
|
try {
|
|
const model = spaceModels({ ...config, spaces: [raw] } as ServerConfig)[0];
|
|
if (!model) throw new Error('missing space model');
|
|
input = prepare(raw, model);
|
|
} catch (error) {
|
|
spaces.push({
|
|
...identity, status: 'failed', reason: 'prepare-exception',
|
|
detail: preflightErrorDetail(error),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const hasWallPass = input.walls.length > 0 || input.physicalBodies.length > 0;
|
|
if (!input.space.rooms.length && !hasWallPass) {
|
|
spaces.push({ ...identity, status: 'not-applicable' });
|
|
continue;
|
|
}
|
|
|
|
let united: ReturnType<typeof wallBodiesGeometry> | null = null;
|
|
if (hasWallPass) {
|
|
try {
|
|
united = buildWalls(
|
|
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
|
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
|
input.physicalBodies,
|
|
);
|
|
} catch (error) {
|
|
spaces.push({
|
|
...identity, status: 'failed', reason: 'wall-exception',
|
|
detail: preflightErrorDetail(error),
|
|
});
|
|
continue;
|
|
}
|
|
if (united == null) {
|
|
spaces.push({ ...identity, status: 'failed', reason: 'wall-null' });
|
|
continue;
|
|
}
|
|
if (united.status === 'degraded-extra') {
|
|
spaces.push({ ...identity, status: 'failed', reason: 'wall-degraded-extra' });
|
|
continue;
|
|
}
|
|
if (united.status === 'failed-core') {
|
|
spaces.push({ ...identity, status: 'failed', reason: 'wall-failed-core' });
|
|
continue;
|
|
}
|
|
options.captureWallGeometry?.(input, united);
|
|
}
|
|
|
|
if (input.space.rooms.length && united?.paperGeom == null) {
|
|
let floor: ReturnType<typeof floorFootprintGeometry>;
|
|
try {
|
|
floor = buildFloor(
|
|
input.space.rooms, input.walls, input.openCuts,
|
|
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
|
);
|
|
} catch (error) {
|
|
spaces.push({
|
|
...identity, status: 'failed', reason: 'floor-exception',
|
|
detail: preflightErrorDetail(error),
|
|
});
|
|
continue;
|
|
}
|
|
if (floor == null) {
|
|
spaces.push({ ...identity, status: 'failed', reason: 'floor-null' });
|
|
continue;
|
|
}
|
|
}
|
|
spaces.push({ ...identity, status: 'ok' });
|
|
}
|
|
|
|
const failures = spaces.filter((space): space is typeof space & {
|
|
status: 'failed'; reason: OptimizeGeometryFailureReason;
|
|
} => space.status === 'failed');
|
|
return { fingerprint, spaces, failures, ok: failures.length === 0 };
|
|
}
|