Files
houseplan-card/src/iso-openings.ts
T
Claude 0d641ffbfd feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)
- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 15:18:00 +03:00

671 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint,
type IsoCamera, type PlanPoint, type ScenePoint,
} from './iso-projection';
import { openingSymbolOffset } from './opening-symbol-placement';
export type IsoOpeningType = 'door' | 'window' | 'gate' | 'passage';
export interface IsoOpeningFace {
ox: number;
oy: number;
/** Physical thickness metadata from the resolved host, in centimetres. */
cm?: number;
side: -1 | 1;
}
export interface IsoOpeningInput {
id: string;
sourceIndex: number;
type: IsoOpeningType;
x: number;
y: number;
angle: number;
length: number;
flipH: boolean;
flipV: boolean;
face: IsoOpeningFace;
}
export interface IsoOpeningLeafBasis {
leaf: number;
hinge: PlanPoint;
closedVector: PlanPoint;
quarterVector: PlanPoint;
turnDeg: number;
bottom: number;
top: number;
}
export interface IsoOpeningAxisBasis {
center: PlanPoint;
tangent: PlanPoint;
normal: PlanPoint;
start: PlanPoint;
end: PlanPoint;
}
export interface IsoOpeningFaceBasis {
side: -1 | 1;
cm: number;
/** Full physical wall depth in plan-render units. */
depth: number;
/** Centreline-to-selected-face vector returned by the canonical host resolver. */
offset: PlanPoint;
selectedStart: PlanPoint;
selectedEnd: PlanPoint;
oppositeStart: PlanPoint;
oppositeEnd: PlanPoint;
}
export interface IsoOpeningRevealBasis {
jamb: 0 | 1;
center: PlanPoint;
selected: PlanPoint;
opposite: PlanPoint;
}
/**
* Versioned authority for every fixed Stage 4 opening dimension. It is part of
* the structural fingerprint, so a policy change cannot reuse stale cached
* opening volumes from an older algorithm.
*/
export interface IsoOpeningGeometryPolicy {
revision: number;
leafThicknessRatio: number;
frameThicknessRatio: number;
doorTurnDeg: number;
windowTurnDeg: number;
gateTurnDeg: number;
gateTopRatio: number;
windowFrameBottomRatio: number;
windowFrameTopRatio: number;
windowSashBottomRatio: number;
windowSashTopRatio: number;
windowGlassBottomRatio: number;
windowGlassTopRatio: number;
doorTopRatio: number;
}
export const ISO_OPENING_GEOMETRY_POLICY: Readonly<IsoOpeningGeometryPolicy> = Object.freeze({
revision: 3,
leafThicknessRatio: 0.04,
frameThicknessRatio: 0.05,
doorTurnDeg: 50,
windowTurnDeg: 65,
gateTurnDeg: 10,
gateTopRatio: 0.88,
windowFrameBottomRatio: 0.38,
windowFrameTopRatio: 1,
windowSashBottomRatio: 0.40,
windowSashTopRatio: 0.98,
windowGlassBottomRatio: 0.45,
windowGlassTopRatio: 0.93,
doorTopRatio: 0.92,
});
export const ISO_OPENING_LEAF_THICKNESS_RATIO =
ISO_OPENING_GEOMETRY_POLICY.leafThicknessRatio;
export const ISO_OPENING_FRAME_THICKNESS_RATIO =
ISO_OPENING_GEOMETRY_POLICY.frameThicknessRatio;
export interface IsoWindowProfile {
frameBottom: number;
frameTop: number;
sashBottom: number;
sashTop: number;
glassBottom: number;
glassTop: number;
member: number;
}
/** Immutable jamb/axis topology stored in the structural Iso LRU. */
export interface IsoOpeningBasis {
id: string;
sourceIndex: number;
type: IsoOpeningType;
wallHeight: number;
leafThickness: number;
frameThickness: number;
windowProfile: IsoWindowProfile | null;
axis: IsoOpeningAxisBasis;
face: IsoOpeningFaceBasis;
reveals: readonly IsoOpeningRevealBasis[];
leaves: readonly IsoOpeningLeafBasis[];
}
export type IsoOpeningSurfaceKind =
| 'jamb-reveal'
| 'leaf-front'
| 'leaf-back'
| 'leaf-edge'
| 'leaf-top'
| 'window-insert'
| 'window-sash-side'
| 'window-sash-bottom'
| 'window-sash-top'
| 'window-glass'
| 'window-glass-top'
| 'window-frame-side'
| 'window-frame-top'
| 'window-sill';
export type IsoOpeningMaterial =
| 'reveal'
| 'matte-leaf'
| 'light-window'
| 'glass-side'
| 'glass-top'
| 'light-frame'
| 'light-sill';
export interface IsoOpeningSurface {
kind: IsoOpeningSurfaceKind;
material: IsoOpeningMaterial;
d: string;
/** Camera depth of the face centre along the projector, used for local occlusion. */
cameraDepth: number;
depth: number;
jamb?: 0 | 1;
edge?: 'hinge' | 'tip';
}
export interface IsoOpeningPanel {
id: string;
sourceIndex: number;
type: IsoOpeningType;
leaf: number;
d: string;
shadowD: string;
depth: number;
material: 'matte-leaf' | 'light-window';
thickness: number;
/** Stage 4 prism/frame faces. The legacy d remains the centre face for compatibility. */
surfaces: readonly IsoOpeningSurface[];
}
export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; }
const finite = (values: readonly number[]): boolean => values.every(Number.isFinite);
function frozenPoint(x: number, y: number): PlanPoint {
return Object.freeze([
Object.is(x, -0) ? 0 : x,
Object.is(y, -0) ? 0 : y,
]) as PlanPoint;
}
function add(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] + b[0], a[1] + b[1]];
}
function subtract(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] - b[0], a[1] - b[1]];
}
function scaled(a: PlanPoint, factor: number): PlanPoint {
return [a[0] * factor, a[1] * factor];
}
function transformVector(
point: PlanPoint, angleDeg: number, sx: number, sy: number,
): PlanPoint {
const x = point[0] * sx, y = point[1] * sy;
const angle = angleDeg * Math.PI / 180;
return [x * Math.cos(angle) - y * Math.sin(angle),
x * Math.sin(angle) + y * Math.cos(angle)];
}
function leafBasis(
input: IsoOpeningInput,
leaf: number,
localHinge: PlanPoint,
localVector: PlanPoint,
turnDeg: number,
bottom: number,
top: number,
): IsoOpeningLeafBasis {
const sx = input.flipH ? -1 : 1;
// Gate flip_v is represented by its resolved face.side/turnDeg. Mirroring
// the structural basis as well would cancel that direction on shared and
// partition hosts, just like a nested scaleY in the flat renderer.
const sy = input.type === 'gate' ? 1 : input.flipV ? -1 : 1;
// Flat symbols stay on the saved centreline (#250). A volumetric door/gate
// has a physical host face, however: centring its prism inside the masonry
// makes half of the leaf look embedded in the wall. Only the derived Iso
// leaf moves to that selected face; saved coordinates and the Flat symbol do
// not change. Windows keep their frame/sash centred across the reveal.
const offset = input.type === 'door' || input.type === 'gate'
? input.face
: openingSymbolOffset(input.type, input.flipV, input.angle, input.face);
const origin: PlanPoint = [input.x + offset.ox, input.y + offset.oy];
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
const closedVector = transformVector(localVector, input.angle, sx, sy);
const quarterVector = transformVector(
[-localVector[1], localVector[0]], input.angle, sx, sy,
);
return { leaf, hinge, closedVector, quarterVector, turnDeg, bottom, top };
}
/**
* Build only stable opening topology. The transform is algebraically the same
* as the floor symbol's translate/rotate/flip nesting, including the gate's
* 0..10 degree exterior-face convention.
*/
export function buildIsoOpeningBasis(
input: IsoOpeningInput,
wallHeight = ISO_WALL_HEIGHT,
policy: Readonly<IsoOpeningGeometryPolicy> = ISO_OPENING_GEOMETRY_POLICY,
): IsoOpeningBasis {
const faceCm = input.face.cm ?? 0;
if (!finite([
input.x, input.y, input.angle, input.length,
input.face.ox, input.face.oy, faceCm, wallHeight,
]) || !(input.length > 0) || !(wallHeight > 0)
|| (input.face.side !== -1 && input.face.side !== 1) || faceCm < 0) {
throw new Error('invalid isometric opening input');
}
const half = input.length / 2;
const angle = input.angle * Math.PI / 180;
const tangent = frozenPoint(Math.cos(angle), Math.sin(angle));
const normal = frozenPoint(-Math.sin(angle), Math.cos(angle));
const center = frozenPoint(input.x, input.y);
const start = frozenPoint(input.x - tangent[0] * half, input.y - tangent[1] * half);
const end = frozenPoint(input.x + tangent[0] * half, input.y + tangent[1] * half);
const offset = frozenPoint(input.face.ox, input.face.oy);
const selectedStart = frozenPoint(start[0] + offset[0], start[1] + offset[1]);
const selectedEnd = frozenPoint(end[0] + offset[0], end[1] + offset[1]);
const oppositeStart = frozenPoint(start[0] - offset[0], start[1] - offset[1]);
const oppositeEnd = frozenPoint(end[0] - offset[0], end[1] - offset[1]);
const axis = Object.freeze({ center, tangent, normal, start, end });
const face = Object.freeze({
side: input.face.side,
cm: faceCm,
depth: Math.hypot(input.face.ox, input.face.oy) * 2,
offset,
selectedStart,
selectedEnd,
oppositeStart,
oppositeEnd,
});
const reveals = input.type === 'passage' ? [] : [
Object.freeze({
jamb: 0 as const,
center: start,
selected: selectedStart,
opposite: oppositeStart,
}),
Object.freeze({
jamb: 1 as const,
center: end,
selected: selectedEnd,
opposite: oppositeEnd,
}),
];
let leaves: IsoOpeningLeafBasis[];
if (input.type === 'passage') {
leaves = [];
} else if (input.type === 'gate') {
const turn = input.face.side * policy.gateTurnDeg;
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0,
wallHeight * policy.gateTopRatio),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0,
wallHeight * policy.gateTopRatio),
];
} else if (input.type === 'window') {
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], -policy.windowTurnDeg,
wallHeight * policy.windowSashBottomRatio,
wallHeight * policy.windowSashTopRatio),
leafBasis(input, 1, [half, 0], [-half, 0], policy.windowTurnDeg,
wallHeight * policy.windowSashBottomRatio,
wallHeight * policy.windowSashTopRatio),
];
} else {
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -policy.doorTurnDeg, 0,
wallHeight * policy.doorTopRatio)];
}
const windowProfile = input.type === 'window' ? Object.freeze({
frameBottom: wallHeight * policy.windowFrameBottomRatio,
frameTop: wallHeight * policy.windowFrameTopRatio,
sashBottom: wallHeight * policy.windowSashBottomRatio,
sashTop: wallHeight * policy.windowSashTopRatio,
glassBottom: wallHeight * policy.windowGlassBottomRatio,
glassTop: wallHeight * policy.windowGlassTopRatio,
member: wallHeight * policy.frameThicknessRatio,
}) : null;
return Object.freeze({
id: input.id,
sourceIndex: input.sourceIndex,
type: input.type,
wallHeight,
leafThickness: wallHeight * policy.leafThicknessRatio,
frameThickness: wallHeight * policy.frameThicknessRatio,
windowProfile,
axis,
face,
reveals: Object.freeze(reveals),
leaves: Object.freeze(leaves.map((leaf) => Object.freeze({
...leaf,
hinge: frozenPoint(leaf.hinge[0], leaf.hinge[1]),
closedVector: frozenPoint(leaf.closedVector[0], leaf.closedVector[1]),
quarterVector: frozenPoint(leaf.quarterVector[0], leaf.quarterVector[1]),
}))),
});
}
const pointText = (point: ScenePoint): string =>
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
interface IsoSpatialPoint {
point: PlanPoint;
z: number;
}
function projectedSurface(
kind: IsoOpeningSurfaceKind,
material: IsoOpeningMaterial,
points: readonly IsoSpatialPoint[],
camera: IsoCamera,
metadata: Pick<IsoOpeningSurface, 'jamb' | 'edge'> = {},
): IsoOpeningSurface {
const projected = points.map((value) => projectPlanPoint(value.point, value.z, camera));
const rotation = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
// #713: depth along the oblique projector (s, 1) of `y' = y − z·s`.
const cameraDepth = points.reduce((sum, value) => sum
+ (value.point[0] * Math.sin(rotation) + value.point[1] * Math.cos(rotation))
* Math.sin(tilt)
+ value.z, 0) / points.length;
return Object.freeze({
kind,
material,
d: `M ${projected.map(pointText).join(' L ')} Z`,
cameraDepth,
depth: Math.max(...projected.map((point) => point[1])),
...metadata,
});
}
function verticalSurface(
kind: IsoOpeningSurfaceKind,
material: IsoOpeningMaterial,
a: PlanPoint,
b: PlanPoint,
bottom: number,
top: number,
camera: IsoCamera,
metadata: Pick<IsoOpeningSurface, 'jamb' | 'edge'> = {},
): IsoOpeningSurface {
return projectedSurface(kind, material, [
{ point: a, z: bottom },
{ point: b, z: bottom },
{ point: b, z: top },
{ point: a, z: top },
], camera, metadata);
}
function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
const angle = leaf.turnDeg * amount * Math.PI / 180;
return [
leaf.hinge[0] + leaf.closedVector[0] * Math.cos(angle)
+ leaf.quarterVector[0] * Math.sin(angle),
leaf.hinge[1] + leaf.closedVector[1] * Math.cos(angle)
+ leaf.quarterVector[1] * Math.sin(angle),
];
}
function leafPrismSurfaces(
leaf: IsoOpeningLeafBasis,
tip: PlanPoint,
thickness: number,
camera: IsoCamera,
): readonly IsoOpeningSurface[] {
const vector = subtract(tip, leaf.hinge);
const length = Math.hypot(vector[0], vector[1]);
if (!(length > 1e-9) || !(thickness > 0)) return Object.freeze([]);
const halfNormal = frozenPoint(
-vector[1] * thickness / (2 * length),
vector[0] * thickness / (2 * length),
);
const frontHinge = add(leaf.hinge, halfNormal);
const frontTip = add(tip, halfNormal);
const backHinge = subtract(leaf.hinge, halfNormal);
const backTip = subtract(tip, halfNormal);
const surfaces = [
verticalSurface(
'leaf-back', 'matte-leaf', backTip, backHinge,
leaf.bottom, leaf.top, camera,
),
verticalSurface(
'leaf-front', 'matte-leaf', frontHinge, frontTip,
leaf.bottom, leaf.top, camera,
),
verticalSurface(
'leaf-edge', 'matte-leaf', backHinge, frontHinge,
leaf.bottom, leaf.top, camera, { edge: 'hinge' },
),
verticalSurface(
'leaf-edge', 'matte-leaf', frontTip, backTip,
leaf.bottom, leaf.top, camera, { edge: 'tip' },
),
projectedSurface('leaf-top', 'matte-leaf', [
{ point: frontHinge, z: leaf.top },
{ point: backHinge, z: leaf.top },
{ point: backTip, z: leaf.top },
{ point: frontTip, z: leaf.top },
], camera),
];
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || String(a.edge || '').localeCompare(String(b.edge || ''))));
}
/**
* Project state-independent Stage 4 jamb/reveal and window frame/sill surfaces.
* Passage deliberately has no decorative volume. This function consumes only
* the immutable structural basis and is safe to cache with that basis.
*/
export function projectIsoOpeningStructure(
basis: IsoOpeningBasis,
camera: IsoCamera = ISO_CAMERA,
): readonly IsoOpeningSurface[] {
if (basis.type === 'passage') return Object.freeze([]);
const surfaces: IsoOpeningSurface[] = [];
if (basis.face.depth > 1e-9) {
for (const reveal of basis.reveals) {
surfaces.push(verticalSurface(
'jamb-reveal', 'reveal', reveal.selected, reveal.opposite,
0, basis.wallHeight, camera, { jamb: reveal.jamb },
));
}
}
if (basis.type !== 'window' || !basis.leaves.length) {
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || (a.jamb ?? 0) - (b.jamb ?? 0)));
}
const profile = basis.windowProfile;
if (!profile) throw new Error('missing isometric window profile');
const bottom = profile.frameBottom;
const top = profile.frameTop;
const member = Math.min(basis.frameThickness, Math.hypot(
basis.axis.end[0] - basis.axis.start[0],
basis.axis.end[1] - basis.axis.start[1],
) / 4, (top - bottom) / 4);
const innerStart = add(basis.axis.start, scaled(basis.axis.tangent, member));
const innerEnd = subtract(basis.axis.end, scaled(basis.axis.tangent, member));
const offsets: readonly PlanPoint[] = basis.face.depth > 1e-9
? [basis.face.offset, frozenPoint(-basis.face.offset[0], -basis.face.offset[1])]
: [basis.face.offset];
for (const offset of offsets) {
const outerStart = add(basis.axis.start, offset);
const outerEnd = add(basis.axis.end, offset);
const innerFaceStart = add(innerStart, offset);
const innerFaceEnd = add(innerEnd, offset);
surfaces.push(
verticalSurface(
'window-frame-side', 'light-frame', outerStart, innerFaceStart,
bottom, top, camera, { jamb: 0 },
),
verticalSurface(
'window-frame-side', 'light-frame', innerFaceEnd, outerEnd,
bottom, top, camera, { jamb: 1 },
),
verticalSurface(
'window-frame-top', 'light-frame', innerFaceStart, innerFaceEnd,
Math.max(bottom, top - member), top, camera,
),
);
}
if (basis.face.depth > 1e-9) {
surfaces.push(projectedSurface('window-sill', 'light-sill', [
{ point: basis.face.selectedStart, z: bottom },
{ point: basis.face.selectedEnd, z: bottom },
{ point: basis.face.oppositeEnd, z: bottom },
{ point: basis.face.oppositeStart, z: bottom },
], camera));
}
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || (a.jamb ?? 0) - (b.jamb ?? 0)));
}
/** Live sash frame and glass, all derived from the same immutable window basis. */
function windowSashSurfaces(
basis: IsoOpeningBasis,
leaf: IsoOpeningLeafBasis,
tip: PlanPoint,
camera: IsoCamera,
): readonly IsoOpeningSurface[] {
const profile = basis.windowProfile;
if (!profile) throw new Error('missing isometric window profile');
const vector = subtract(tip, leaf.hinge);
const length = Math.hypot(vector[0], vector[1]);
if (!(length > 1e-9)) return Object.freeze([]);
const unit = frozenPoint(vector[0] / length, vector[1] / length);
const member = Math.min(profile.member, length / 4,
(profile.sashTop - profile.sashBottom) / 4);
const innerHinge = add(leaf.hinge, scaled(unit, member));
const innerTip = subtract(tip, scaled(unit, member));
const paneThickness = Math.min(basis.frameThickness * 0.35, length * 0.02);
const paneHalfNormal = frozenPoint(
-unit[1] * paneThickness / 2,
unit[0] * paneThickness / 2,
);
const glassFrontHinge = add(innerHinge, paneHalfNormal);
const glassFrontTip = add(innerTip, paneHalfNormal);
const glassBackHinge = subtract(innerHinge, paneHalfNormal);
const glassBackTip = subtract(innerTip, paneHalfNormal);
const surfaces: IsoOpeningSurface[] = [
verticalSurface('window-sash-side', 'light-frame', leaf.hinge, innerHinge,
profile.sashBottom, profile.sashTop, camera, { edge: 'hinge' }),
verticalSurface('window-sash-side', 'light-frame', innerTip, tip,
profile.sashBottom, profile.sashTop, camera, { edge: 'tip' }),
verticalSurface('window-sash-bottom', 'light-frame', leaf.hinge, tip,
profile.sashBottom, profile.sashBottom + member, camera),
verticalSurface('window-sash-top', 'light-frame', leaf.hinge, tip,
profile.sashTop - member, profile.sashTop, camera),
verticalSurface('window-glass', 'glass-side', glassFrontHinge, glassFrontTip,
profile.glassBottom, profile.glassTop, camera),
verticalSurface('window-glass', 'glass-side', glassBackTip, glassBackHinge,
profile.glassBottom, profile.glassTop, camera),
projectedSurface('window-glass-top', 'glass-top', [
{ point: glassFrontHinge, z: profile.glassTop },
{ point: glassFrontTip, z: profile.glassTop },
{ point: glassBackTip, z: profile.glassTop },
{ point: glassBackHinge, z: profile.glassTop },
], camera),
];
return Object.freeze(surfaces.sort((a, b) => a.depth - b.depth
|| a.kind.localeCompare(b.kind) || String(a.edge || '').localeCompare(String(b.edge || ''))));
}
/** Apply live state after the structural cache: O(leaves), no topology work. */
export function projectIsoOpening(
basis: IsoOpeningBasis,
amount: number,
camera: IsoCamera = ISO_CAMERA,
): IsoOpeningPanel[] {
const liveAmount = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0));
return basis.leaves.map((leaf) => {
const tip = liveTip(leaf, liveAmount);
const floorHinge = projectPlanPoint(leaf.hinge, leaf.bottom, camera);
const floorTip = projectPlanPoint(tip, leaf.bottom, camera);
const topTip = projectPlanPoint(tip, leaf.top, camera);
const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera);
const material = basis.type === 'window' ? 'light-window' : 'matte-leaf';
const surfaces = basis.type === 'window'
? windowSashSurfaces(basis, leaf, tip, camera)
: leafPrismSurfaces(leaf, tip, basis.leafThickness, camera);
return Object.freeze({
id: basis.id,
sourceIndex: basis.sourceIndex,
type: basis.type,
leaf: leaf.leaf,
d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`,
shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`,
depth: Math.max(floorHinge[1], floorTip[1]),
material,
thickness: basis.type === 'window' ? 0 : basis.leafThickness,
surfaces,
});
});
}
/** State-independent plan envelope for fit/home; blur is intentionally absent. */
export function isoOpeningBounds(
bases: readonly IsoOpeningBasis[],
): IsoOpeningBounds | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const include = (point: PlanPoint, radius = 0): void => {
minX = Math.min(minX, point[0] - radius);
minY = Math.min(minY, point[1] - radius);
maxX = Math.max(maxX, point[0] + radius);
maxY = Math.max(maxY, point[1] + radius);
};
for (const basis of bases) {
if (basis.type === 'passage') continue;
include(basis.axis.start);
include(basis.axis.end);
include(basis.face.selectedStart);
include(basis.face.selectedEnd);
include(basis.face.oppositeStart);
include(basis.face.oppositeEnd);
for (const leaf of basis.leaves) {
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1])
+ basis.leafThickness / 2;
include(leaf.hinge, radius);
}
}
return finite([minX, minY, maxX, maxY])
? { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
: null;
}
export interface IsoDecorationInput {
showBorders: boolean;
hideOpenings: boolean;
filtersSupported: boolean;
forcedColors: boolean;
}
export interface IsoDecorationLayers {
structural: boolean;
panels: boolean;
shadows: boolean;
materialNuance: boolean;
floorSymbols: boolean;
}
/** Display/capability policy is pure so decoration can never trigger Flat fallback. */
export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLayers {
const structural = !!input.showBorders;
return {
structural,
panels: structural && !input.hideOpenings,
shadows: structural && input.filtersSupported && !input.forcedColors,
materialNuance: structural && input.filtersSupported && !input.forcedColors,
floorSymbols: !input.hideOpenings && !structural,
};
}