mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
- 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
671 lines
23 KiB
TypeScript
671 lines
23 KiB
TypeScript
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,
|
||
};
|
||
}
|