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 = 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; /** Orthographic camera depth of the face centre, 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 = 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 { 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; const cameraDepth = points.reduce((sum, value) => sum + (value.point[0] * Math.sin(rotation) + value.point[1] * Math.cos(rotation)) * Math.sin(tilt) + value.z * Math.cos(tilt), 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 { 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, }; }