Files
editor/packages/viewer/src/systems/window/window-system.tsx
T
Wassim SAMADandClaude Opus 4.8 8aa179e9cb feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs
Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):

- Per-part painting: door = panel/frame/glass/hardware, window = frame/glass,
  each independently paintable. The recessed door/window body sits behind the
  wall, so the proud invisible cutout wins the scene raycast over the wall and
  the shared resolveSlotByReRaycast() re-raycasts the kind's own subtree to pick
  the exact part under the cursor (panel↔frame↔glass↔hardware). Hover tracks the
  cursor via a  re-eval (idempotent, no flicker).
- Door frame is its own slot (separate frameMaterial); hardware = new flat
  'metal-chrome'.
- Library defaults (generic): panel/frame -> library:preset-softwhite, glass ->
  library:preset-glass (flipped preset-glass to FrontSide — DoubleSide poisons
  the WebGPU MRT pass; it's the only glass we use).
- Catalog: add flat (non-PBR) 'metal-chrome' + 'metal-brass'; drop metal
  metalness 1 -> 0.6 so metals are lit by existing lights (no env needed).
- World-scale UVs (1 unit = 1m) on door/window box meshes via shared box-uv.ts,
  so finishes tile at real-world scale instead of stretching.
- PaintResolveArgs gains an optional  for subtree re-raycasting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:01:37 -04:00

3651 lines
92 KiB
TypeScript

import {
type AnyNodeId,
getEffectiveNode,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { applyWorldScaleBoxUVs } from '../../lib/box-uv'
import {
type ColorPreset,
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
type RenderShading,
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
let baseMaterial = getBaseMaterial()
let glassMaterial: THREE.Material = defaultGlassMaterial
let currentWindowSlot: string | undefined
// Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each window's slot materials.
let currentShading: RenderShading = 'rendered'
let currentTextures = true
let currentColorPreset: ColorPreset = 'clay'
let currentSceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined
export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash'
export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash'
export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash'
export const SLIDING_WINDOW_ACTIVE_PANEL_NAME = 'sliding-window-active-panel'
export const SINGLE_HUNG_ACTIVE_SASH_NAME = 'single-hung-active-sash'
export const DOUBLE_HUNG_TOP_SASH_NAME = 'double-hung-top-sash'
export const DOUBLE_HUNG_BOTTOM_SASH_NAME = 'double-hung-bottom-sash'
export const LOUVERED_WINDOW_SLATS_NAME = 'louvered-window-slats'
export const AWNING_WINDOW_SASH_NAME = 'awning-window-sash'
export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash'
const MAX_WINDOW_REBUILDS_PER_FRAME = 16
const WINDOW_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WINDOW_REBUILDS_PER_FRAME
const WINDOW_PROGRESSIVE_TIME_BUDGET_MS = 8
export const WindowSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const sceneMaterials = useScene((state) => state.materials)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so override-only updates re-run this component. Mirrors
// WallSystem + DoorSystem.
useLiveNodeOverrides((s) => s.overrides)
baseMaterial = textures
? getBaseMaterial(shading)
: createSurfaceRoleMaterial('joinery', colorPreset)
glassMaterial = textures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', colorPreset)
useEffect(() => {
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
if (materialRevisionRef.current === materialRevision) return
materialRevisionRef.current = materialRevision
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type === 'window') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}
})
// Editing a scene material a window slot references must rebuild that window
// (window meshes are built by this system, not <GeometrySystem>, so its
// scene-material re-dirty doesn't cover them).
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'window') continue
if (!nodeReferencesSceneMaterial(node)) continue
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}, [sceneMaterials])
useFrame(() => {
if (dirtyNodes.size === 0) return
baseMaterial = textures
? getBaseMaterial(shading)
: createSurfaceRoleMaterial('joinery', colorPreset)
glassMaterial = textures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', colorPreset)
currentShading = shading
currentTextures = textures
currentColorPreset = colorPreset
currentSceneMaterials = sceneMaterials
const nodes = useScene.getState().nodes
const dirtyWindowIds: AnyNodeId[] = []
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'window') return
dirtyWindowIds.push(id as AnyNodeId)
})
const useProgressiveWindowRebuilds = dirtyWindowIds.length > WINDOW_PROGRESSIVE_DIRTY_THRESHOLD
const frameStartedAt = performance.now()
let rebuiltWindowsThisFrame = 0
for (const id of dirtyWindowIds) {
if (useProgressiveWindowRebuilds) {
if (rebuiltWindowsThisFrame >= MAX_WINDOW_REBUILDS_PER_FRAME) {
break
}
if (
rebuiltWindowsThisFrame > 0 &&
performance.now() - frameStartedAt >= WINDOW_PROGRESSIVE_TIME_BUDGET_MS
) {
break
}
}
const node = nodes[id]
if (!node || node.type !== 'window') continue
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (!mesh) continue // Keep dirty until mesh mounts
// Merge any live override (width / height / position) so the mesh
// rebuild reflects the in-flight drag without zustand churn.
const effectiveNode = getEffectiveNode(node as WindowNode)
updateWindowMesh(effectiveNode, mesh)
clearDirty(id as AnyNodeId)
rebuiltWindowsThisFrame += 1
// Rebuild the parent wall so its cutout reflects the updated window geometry
// Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated.
// The editor tools will request a final wall rebuild on commit.
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
if (!isTransient && effectiveNode.parentId) {
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
}
}
}, 3)
return null
}
function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
mesh.userData.slotId = currentWindowSlot
return mesh
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
for (const ref of Object.values(slots)) {
if (typeof ref === 'string' && ref.startsWith('scene:')) return true
}
return false
}
// Window frame/glass default to catalog finishes (generic approach). `preset-glass`
// is now FrontSide (it was the only glass we use), so it's safe for the WebGPU
// MRT scene pass.
const FRAME_DEFAULT_REF = 'library:preset-softwhite'
const GLASS_DEFAULT_REF = 'library:preset-glass'
function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material {
if (slotId === 'glass') {
if (!currentTextures) return createSurfaceRoleMaterial('glazing', currentColorPreset)
return (
resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ??
defaultGlassMaterial
)
}
if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset)
return (
resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ??
getBaseMaterial(currentShading)
)
}
// Resolve a window's slot to a material: the `node.slots` override (colored mode
// only) → the role/base default. Textures-off ignores overrides — the monochrome
// escape hatch.
function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material {
const fallback = windowSlotDefault(slotId)
if (!currentTextures) return fallback
const ref = node.slots?.[slotId]
if (!ref) return fallback
return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback
}
function addBox(
parent: THREE.Object3D,
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) {
const geometry = new THREE.BoxGeometry(w, h, d)
applyWorldScaleBoxUVs(geometry, w, h, d)
const m = new THREE.Mesh(geometry, material)
m.position.set(x, y, z)
tagWindowSlot(m)
parent.add(m)
}
function addShape(
parent: THREE.Object3D,
material: THREE.Material,
shape: THREE.Shape,
depth: number,
z = 0,
) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: false,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2 + z)
const mesh = new THREE.Mesh(geometry, material)
tagWindowSlot(mesh)
parent.add(mesh)
}
function disposeObjectGeometry(object: THREE.Object3D) {
object.traverse((child) => {
if (child instanceof THREE.Mesh) child.geometry.dispose()
})
}
function createRectShape(left: number, right: number, bottom: number, top: number) {
const shape = new THREE.Shape()
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top)
shape.lineTo(left, top)
shape.closePath()
return shape
}
type CornerRadii = {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
const next = { ...radii }
const scale = Math.min(
1,
width / Math.max(next.topLeft + next.topRight, 1e-6),
width / Math.max(next.bottomLeft + next.bottomRight, 1e-6),
height / Math.max(next.topLeft + next.bottomLeft, 1e-6),
height / Math.max(next.topRight + next.bottomRight, 1e-6),
)
if (scale < 1) {
next.topLeft *= scale
next.topRight *= scale
next.bottomRight *= scale
next.bottomLeft *= scale
}
return next
}
function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii {
if (node.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
return normalizeCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
bottomRight: Math.max(bottomRight, 0),
bottomLeft: Math.max(bottomLeft, 0),
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height / 2)
const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
}
function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) {
return normalizeCornerRadii(
{
topLeft: Math.max(radii.topLeft - inset, 0),
topRight: Math.max(radii.topRight - inset, 0),
bottomRight: Math.max(radii.bottomRight - inset, 0),
bottomLeft: Math.max(radii.bottomLeft - inset, 0),
},
width,
height,
)
}
function createRoundedShape(
left: number,
right: number,
bottom: number,
top: number,
radii: CornerRadii,
) {
const shape = new THREE.Shape()
const { topLeft, topRight, bottomRight, bottomLeft } = radii
shape.moveTo(left + bottomLeft, bottom)
shape.lineTo(right - bottomRight, bottom)
if (bottomRight > 1e-6) {
shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false)
} else {
shape.lineTo(right, bottom)
}
shape.lineTo(right, top - topRight)
if (topRight > 1e-6) {
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
} else {
shape.lineTo(right, top)
}
shape.lineTo(left + topLeft, top)
if (topLeft > 1e-6) {
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
} else {
shape.lineTo(left, top)
}
shape.lineTo(left, bottom + bottomLeft)
if (bottomLeft > 1e-6) {
shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false)
} else {
shape.lineTo(left, bottom)
}
shape.closePath()
return shape
}
function createRoundedFrameShape(
width: number,
height: number,
frameThickness: number,
outerRadii: CornerRadii,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii)
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerRadii = insetCornerRadii(
outerRadii,
inset,
innerRight - innerLeft,
innerTop - innerBottom,
)
const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii)
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
outer.holes.push(hole)
return outer
}
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
}
function createArchShape(
left: number,
right: number,
bottom: number,
top: number,
archHeight: number,
) {
const centerX = (left + right) / 2
const halfWidth = (right - left) / 2
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
const springY = top - clampedArchHeight
const shape = new THREE.Shape()
const segments = 32
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function createArchedFrameShape(
width: number,
height: number,
archHeight: number,
frameThickness: number,
) {
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight)
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
if (inset <= 0.001) return outer
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerArchHeight = getClampedArchHeight(
innerRight - innerLeft,
innerTop - innerBottom,
archHeight - inset,
)
const hole = new THREE.Path(
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight)
.getPoints(32)
.reverse(),
)
outer.holes.push(hole)
return outer
}
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
if (halfWidth <= 1e-6) return springY
const t = Math.min(Math.abs(x) / halfWidth, 1)
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
}
function getArchedOpeningHalfWidthAtY(
y: number,
halfWidth: number,
springY: number,
archHeight: number,
) {
if (y <= springY || archHeight <= 1e-6) return halfWidth
const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1)
return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0))
}
function getRoundedBoundaryYAtX(
x: number,
left: number,
right: number,
top: number,
radii: CornerRadii,
) {
if (radii.topLeft > 1e-6 && x < left + radii.topLeft) {
const centerX = left + radii.topLeft
const centerY = top - radii.topLeft
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0))
}
if (radii.topRight > 1e-6 && x > right - radii.topRight) {
const centerX = right - radii.topRight
const centerY = top - radii.topRight
const dx = x - centerX
return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0))
}
return top
}
function getRoundedBottomBoundaryYAtX(
x: number,
left: number,
right: number,
bottom: number,
radii: CornerRadii,
) {
if (radii.bottomLeft > 1e-6 && x < left + radii.bottomLeft) {
const centerX = left + radii.bottomLeft
const centerY = bottom + radii.bottomLeft
const dx = x - centerX
return centerY - Math.sqrt(Math.max(radii.bottomLeft * radii.bottomLeft - dx * dx, 0))
}
if (radii.bottomRight > 1e-6 && x > right - radii.bottomRight) {
const centerX = right - radii.bottomRight
const centerY = bottom + radii.bottomRight
const dx = x - centerX
return centerY - Math.sqrt(Math.max(radii.bottomRight * radii.bottomRight - dx * dx, 0))
}
return bottom
}
function getRoundedHorizontalBoundsAtY(
y: number,
left: number,
right: number,
top: number,
radii: CornerRadii,
) {
let minX = left
let maxX = right
if (radii.topLeft > 1e-6 && y > top - radii.topLeft) {
const centerX = left + radii.topLeft
const centerY = top - radii.topLeft
const dy = y - centerY
minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0))
}
if (radii.topRight > 1e-6 && y > top - radii.topRight) {
const centerX = right - radii.topRight
const centerY = top - radii.topRight
const dy = y - centerY
maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0))
}
return { minX, maxX }
}
function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const {
width,
height,
frameDepth,
frameThickness,
columnRatios,
rowRatios,
columnDividerThickness,
rowDividerThickness,
sill,
sillDepth,
sillThickness,
} = node
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const outerRadii = getWindowRoundedRadii(node, width, height)
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerW = innerRight - innerLeft
const innerH = innerTop - innerBottom
const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH)
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(width, height, inset, outerRadii),
frameDepth,
)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
addShape(
mesh,
glassMaterial,
createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
glassDepth,
)
const numCols = columnRatios.length
const numRows = rowRatios.length
const usableW = innerW - (numCols - 1) * columnDividerThickness
const usableH = innerH - (numRows - 1) * rowDividerThickness
const colSum = columnRatios.reduce((a, b) => a + b, 0)
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
let x = innerLeft
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]!
const x1 = x
const x2 = x + columnDividerThickness
const dividerTop = Math.min(
getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii),
getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii),
)
if (dividerTop > innerBottom + 0.01) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, innerBottom, dividerTop),
frameDepth + 0.001,
)
}
x += columnDividerThickness
}
let y = innerTop
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]!
const yTop = y
const yBottom = y - rowDividerThickness
const { minX, maxX } = getRoundedHorizontalBoundsAtY(
yTop,
innerLeft,
innerRight,
innerTop,
innerRadii,
)
if (maxX - minX > 0.01 && yTop > innerBottom) {
addShape(
mesh,
baseMaterial,
createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop),
frameDepth + 0.001,
)
}
y -= rowDividerThickness
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const {
width,
height,
frameDepth,
frameThickness,
columnRatios,
rowRatios,
columnDividerThickness,
rowDividerThickness,
sill,
sillDepth,
sillThickness,
} = node
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const archHeight = getClampedArchHeight(width, height, node.archHeight)
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerW = innerRight - innerLeft
const innerH = innerTop - innerBottom
const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset)
const innerSpringY = innerTop - innerArchHeight
currentWindowSlot = 'frame'
addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
addShape(
mesh,
glassMaterial,
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight),
glassDepth,
)
const numCols = columnRatios.length
const numRows = rowRatios.length
const usableW = innerW - (numCols - 1) * columnDividerThickness
const usableH = innerH - (numRows - 1) * rowDividerThickness
const colSum = columnRatios.reduce((a, b) => a + b, 0)
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
const innerHalfWidth = innerW / 2
let x = innerLeft
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]!
const x1 = x
const x2 = x + columnDividerThickness
const dividerTop = Math.min(
getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight),
getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight),
)
if (dividerTop > innerBottom + 0.01) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, innerBottom, dividerTop),
frameDepth + 0.001,
)
}
x += columnDividerThickness
}
let y = innerTop
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]!
const yTop = y
const yBottom = y - rowDividerThickness
const halfAtTop = getArchedOpeningHalfWidthAtY(
yTop,
innerHalfWidth,
innerSpringY,
innerArchHeight,
)
const x1 = -halfAtTop
const x2 = halfAtTop
if (x2 - x1 > 0.01 && yTop > innerBottom) {
addShape(
mesh,
baseMaterial,
createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop),
frameDepth + 0.001,
)
}
y -= rowDividerThickness
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function getWindowRenderOpenAmount(node: WindowNode) {
const runtimeValue = useInteractive.getState().windows[node.id]?.operationState
return Math.min(Math.max(runtimeValue ?? node.operationState ?? 0, 0), 1)
}
function getAwningDirection(node: WindowNode) {
return node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
}
function isRectangleOnlyWindowType(node: WindowNode) {
return (
node.windowType === 'sliding' ||
node.windowType === 'single-hung' ||
node.windowType === 'double-hung' ||
node.windowType === 'bay' ||
node.windowType === 'bow'
)
}
function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const railThickness = Math.max(frameThickness * 0.55, 0.025)
const trackThickness = Math.max(frameThickness * 0.35, 0.018)
const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerW * 0.12)
const openAmount = getWindowRenderOpenAmount(node)
const travel = Math.max(innerW / 2 - panelOverlap, 0) * openAmount
const panelWidth = (innerW + panelOverlap) / 2
const leftPanelBaseX = -innerW / 4 - panelOverlap / 4
const leftPanelX = leftPanelBaseX + travel
const rightPanelX = innerW / 4 + panelOverlap / 4
const leftZ = frameDepth * 0.16
const rightZ = -frameDepth * 0.12
const panelH = Math.max(innerH - trackThickness * 2, 0.01)
const activePanel = new THREE.Group()
activePanel.name = SLIDING_WINDOW_ACTIVE_PANEL_NAME
activePanel.position.set(leftPanelX, 0, leftZ)
mesh.add(activePanel)
// Twin tracks signal the sliding operation without adding editor-only state.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
innerW,
trackThickness,
frameDepth,
0,
innerH / 2 - trackThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
innerW,
trackThickness,
frameDepth,
0,
-innerH / 2 + trackThickness / 2,
0,
)
currentWindowSlot = 'glass'
addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0)
addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ)
// The right sash stays fixed. The left sash is the active panel that slides across it.
currentWindowSlot = 'frame'
addBox(
activePanel,
baseMaterial,
railThickness,
panelH,
frameDepth * 0.72,
-panelWidth / 2 + railThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
railThickness,
panelH,
frameDepth * 0.72,
rightPanelX + panelWidth / 2 - railThickness / 2,
0,
rightZ,
)
addBox(
activePanel,
baseMaterial,
railThickness,
panelH,
frameDepth * 0.78,
panelWidth / 2 - railThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
railThickness,
panelH,
frameDepth * 0.78,
rightPanelX - panelWidth / 2 + railThickness / 2,
0,
rightZ,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addRectCasementSash(
parent: THREE.Object3D,
name: string,
panelW: number,
panelH: number,
frameThickness: number,
frameDepth: number,
pivotX: number,
sashCenterX: number,
rotationY: number,
) {
const sash = new THREE.Group()
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(panelH - 2 * sashFrameThickness, 0.01)
sash.name = name
sash.position.set(pivotX, 0, frameDepth * 0.06)
sash.rotation.y = rotationY
parent.add(sash)
currentWindowSlot = 'frame'
addBox(
sash,
baseMaterial,
panelW,
sashFrameThickness,
sashDepth,
sashCenterX,
panelH / 2 - sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
panelW,
sashFrameThickness,
sashDepth,
sashCenterX,
-panelH / 2 + sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
panelH,
sashDepth,
sashCenterX - panelW / 2 + sashFrameThickness / 2,
0,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
panelH,
sashDepth,
sashCenterX + panelW / 2 - sashFrameThickness / 2,
0,
0,
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
}
function addFrenchCasementHingeMarkers(
mesh: THREE.Mesh,
innerW: number,
innerH: number,
frameThickness: number,
frameDepth: number,
) {
const markerW = Math.max(frameThickness * 0.38, 0.018)
const markerH = innerH * 0.24
currentWindowSlot = 'frame'
for (const pivotX of [-innerW / 2, innerW / 2]) {
addBox(
mesh,
baseMaterial,
markerW,
markerH,
frameDepth * 1.1,
pivotX,
innerH * 0.25,
frameDepth * 0.08,
)
addBox(
mesh,
baseMaterial,
markerW,
markerH,
frameDepth * 1.1,
pivotX,
-innerH * 0.25,
frameDepth * 0.08,
)
}
}
function createFrenchArchLeafShape(
leafW: number,
leafH: number,
fullW: number,
springY: number,
archHeight: number,
side: 'left' | 'right',
inset = 0,
) {
const left = -leafW / 2 + inset
const right = leafW / 2 - inset
const bottom = -leafH / 2 + inset
const halfFullW = fullW / 2
const xOffset = side === 'left' ? -leafW / 2 : leafW / 2
const shape = new THREE.Shape()
const segments = 32
const topAt = (localX: number) =>
Math.max(
bottom + 0.01,
getArchBoundaryY(localX + xOffset, halfFullW, springY, archHeight) - inset,
)
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, topAt(right))
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, topAt(x))
}
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
function createFrenchArchLeafFrameShape(
leafW: number,
leafH: number,
fullW: number,
springY: number,
archHeight: number,
frameThickness: number,
side: 'left' | 'right',
) {
const outer = createFrenchArchLeafShape(leafW, leafH, fullW, springY, archHeight, side)
const inset = Math.min(frameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (inset <= 0.001) return outer
const hole = new THREE.Path(
createFrenchArchLeafShape(leafW, leafH, fullW, springY, archHeight, side, inset)
.getPoints(32)
.reverse(),
)
outer.holes.push(hole)
return outer
}
function createFrenchRoundedLeafShape(
leafW: number,
leafH: number,
fullW: number,
fullRadii: CornerRadii,
side: 'left' | 'right',
inset = 0,
) {
const left = -leafW / 2 + inset
const right = leafW / 2 - inset
const bottom = -leafH / 2 + inset
const top = leafH / 2 - inset
const fullLeft = -fullW / 2 + inset
const fullRight = fullW / 2 - inset
const globalOffset = side === 'left' ? -leafW / 2 : leafW / 2
const radii =
inset > 0 ? insetCornerRadii(fullRadii, inset, fullRight - fullLeft, top - bottom) : fullRadii
const shape = new THREE.Shape()
const segments = 32
const topAt = (localX: number) =>
getRoundedBoundaryYAtX(localX + globalOffset, fullLeft, fullRight, top, radii)
const bottomAt = (localX: number) =>
getRoundedBottomBoundaryYAtX(localX + globalOffset, fullLeft, fullRight, bottom, radii)
shape.moveTo(right, bottomAt(right))
shape.lineTo(right, topAt(right))
for (let index = 1; index <= segments; index += 1) {
const x = right + (left - right) * (index / segments)
shape.lineTo(x, topAt(x))
}
shape.lineTo(left, bottomAt(left))
for (let index = 1; index <= segments; index += 1) {
const x = left + (right - left) * (index / segments)
shape.lineTo(x, bottomAt(x))
}
shape.closePath()
return shape
}
function createFrenchRoundedLeafFrameShape(
leafW: number,
leafH: number,
fullW: number,
fullRadii: CornerRadii,
frameThickness: number,
side: 'left' | 'right',
) {
const outer = createFrenchRoundedLeafShape(leafW, leafH, fullW, fullRadii, side)
const inset = Math.min(frameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (inset <= 0.001) return outer
const hole = new THREE.Path(
createFrenchRoundedLeafShape(leafW, leafH, fullW, fullRadii, side, inset)
.getPoints(32)
.reverse(),
)
outer.holes.push(hole)
return outer
}
function addShapedFrenchCasementSash(
parent: THREE.Object3D,
node: WindowNode,
name: string,
side: 'left' | 'right',
fullW: number,
leafW: number,
leafH: number,
frameThickness: number,
frameDepth: number,
pivotX: number,
sashCenterX: number,
rotationY: number,
) {
const sash = new THREE.Group()
const sashVisual = new THREE.Group()
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
sash.name = name
sash.position.set(pivotX, 0, frameDepth * 0.06)
sash.rotation.y = rotationY
sashVisual.position.x = sashCenterX
sash.add(sashVisual)
parent.add(sash)
if (node.openingShape === 'arch') {
const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight)
const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness)
const sashSpringY = node.height / 2 - outerArchHeight
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createFrenchArchLeafFrameShape(
leafW,
leafH,
fullW,
sashSpringY,
sashArchHeight,
sashFrameThickness,
side,
),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (glassInset > 0.001) {
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createFrenchArchLeafShape(
leafW,
leafH,
fullW,
sashSpringY,
sashArchHeight,
side,
glassInset,
),
glassDepth,
sashDepth * 0.08,
)
}
return
}
const frameRadii = insetCornerRadii(
getWindowRoundedRadii(node, node.width, node.height),
frameThickness,
fullW,
leafH,
)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createFrenchRoundedLeafFrameShape(leafW, leafH, fullW, frameRadii, sashFrameThickness, side),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (glassInset > 0.001) {
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createFrenchRoundedLeafShape(leafW, leafH, fullW, frameRadii, side, glassInset),
glassDepth,
sashDepth * 0.08,
)
}
}
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const openAngle = getWindowRenderOpenAmount(node) * (Math.PI / 2)
const leafW = innerW / 2
addRectCasementSash(
mesh,
FRENCH_CASEMENT_LEFT_SASH_NAME,
leafW,
innerH,
frameThickness,
frameDepth,
-innerW / 2,
leafW / 2,
-openAngle,
)
addRectCasementSash(
mesh,
FRENCH_CASEMENT_RIGHT_SASH_NAME,
leafW,
innerH,
frameThickness,
frameDepth,
innerW / 2,
-leafW / 2,
openAngle,
)
addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createArchedFrameShape(
width,
height,
getClampedArchHeight(width, height, node.archHeight),
frameThickness,
),
frameDepth,
)
} else {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(
width,
height,
frameThickness,
getWindowRoundedRadii(node, width, height),
),
frameDepth,
)
}
if ((node.casementStyle ?? 'single') === 'french') {
if (innerW > 0.01 && innerH > 0.01) {
const openAngle = getWindowRenderOpenAmount(node) * (Math.PI / 2)
const leafW = innerW / 2
addShapedFrenchCasementSash(
mesh,
node,
FRENCH_CASEMENT_LEFT_SASH_NAME,
'left',
innerW,
leafW,
innerH,
frameThickness,
frameDepth,
-innerW / 2,
leafW / 2,
-openAngle,
)
addShapedFrenchCasementSash(
mesh,
node,
FRENCH_CASEMENT_RIGHT_SASH_NAME,
'right',
innerW,
leafW,
innerH,
frameThickness,
frameDepth,
innerW / 2,
-leafW / 2,
openAngle,
)
addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
return
}
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 2)
const hingeSide = node.hingesSide ?? 'left'
const hingeSign = hingeSide === 'left' ? -1 : 1
const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2
const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const sash = new THREE.Group()
const sashVisual = new THREE.Group()
sash.name = CASEMENT_WINDOW_SASH_NAME
sash.position.set(pivotX, 0, frameDepth * 0.06)
sash.rotation.y = hingeSign * openAngle
sashVisual.position.x = sashCenterX
sash.add(sashVisual)
mesh.add(sash)
if (node.openingShape === 'arch') {
const sashArchHeight = getClampedArchHeight(
innerW,
innerH,
(node.archHeight ?? innerW / 2) - frameThickness,
)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createArchShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset),
),
glassDepth,
sashDepth * 0.08,
)
}
} else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createRoundedShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
insetCornerRadii(outerRadii, glassInset, glassW, glassH),
),
glassDepth,
sashDepth * 0.08,
)
}
}
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
Math.max(frameThickness * 0.38, 0.018),
innerH * 0.28,
frameDepth * 1.1,
pivotX,
innerH * 0.24,
frameDepth * 0.08,
)
addBox(
mesh,
baseMaterial,
Math.max(frameThickness * 0.38, 0.018),
innerH * 0.28,
frameDepth * 1.1,
pivotX,
-innerH * 0.24,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
addShapedCasementWindowVisuals(node, mesh)
return
}
if ((node.casementStyle ?? 'single') === 'french') {
addFrenchCasementWindowVisuals(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 2)
const hingeSide = node.hingesSide ?? 'left'
const hingeSign = hingeSide === 'left' ? -1 : 1
const sash = new THREE.Group()
const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2
const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01)
sash.name = CASEMENT_WINDOW_SASH_NAME
sash.position.set(pivotX, 0, frameDepth * 0.06)
sash.rotation.y = hingeSign * openAngle
mesh.add(sash)
currentWindowSlot = 'frame'
addBox(
sash,
baseMaterial,
innerW,
sashFrameThickness,
sashDepth,
sashCenterX,
innerH / 2 - sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
innerW,
sashFrameThickness,
sashDepth,
sashCenterX,
-innerH / 2 + sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
sashCenterX - innerW / 2 + sashFrameThickness / 2,
0,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
sashCenterX + innerW / 2 - sashFrameThickness / 2,
0,
0,
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
// Small hinge markers make the pivot side legible when the sash is closed.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
Math.max(frameThickness * 0.38, 0.018),
innerH * 0.28,
frameDepth * 1.1,
pivotX,
innerH * 0.24,
frameDepth * 0.08,
)
addBox(
mesh,
baseMaterial,
Math.max(frameThickness * 0.38, 0.018),
innerH * 0.28,
frameDepth * 1.1,
pivotX,
-innerH * 0.24,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
addShapedAwningWindowVisuals(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 3)
const isDownward = getAwningDirection(node) === 'down'
const sash = new THREE.Group()
const pivotY = isDownward ? -innerH / 2 : innerH / 2
const sashCenterY = isDownward ? innerH / 2 : -innerH / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01)
sash.name = AWNING_WINDOW_SASH_NAME
sash.position.set(0, pivotY, frameDepth * 0.06)
sash.rotation.x = -openAngle
mesh.add(sash)
currentWindowSlot = 'frame'
addBox(
sash,
baseMaterial,
innerW,
sashFrameThickness,
sashDepth,
0,
sashCenterY + innerH / 2 - sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
innerW,
sashFrameThickness,
sashDepth,
0,
sashCenterY - innerH / 2 + sashFrameThickness / 2,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
-innerW / 2 + sashFrameThickness / 2,
sashCenterY,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
innerW / 2 - sashFrameThickness / 2,
sashCenterY,
0,
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08)
// Compact hinge rail, visible even when the sash is closed.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
innerW * 0.42,
Math.max(frameThickness * 0.38, 0.018),
frameDepth * 1.1,
0,
pivotY,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createArchedFrameShape(
width,
height,
getClampedArchHeight(width, height, node.archHeight),
frameThickness,
),
frameDepth,
)
} else {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(
width,
height,
frameThickness,
getWindowRoundedRadii(node, width, height),
),
frameDepth,
)
}
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 3)
const isDownward = getAwningDirection(node) === 'down'
const pivotY = isDownward ? -innerH / 2 : innerH / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const sash = new THREE.Group()
const sashVisual = new THREE.Group()
sash.name = AWNING_WINDOW_SASH_NAME
sash.position.set(0, pivotY, frameDepth * 0.06)
sash.rotation.x = -openAngle
sashVisual.position.y = isDownward ? innerH / 2 : -innerH / 2
sash.add(sashVisual)
mesh.add(sash)
if (node.openingShape === 'arch') {
const sashArchHeight = getClampedArchHeight(
innerW,
innerH,
(node.archHeight ?? innerW / 2) - frameThickness,
)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createArchShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset),
),
glassDepth,
sashDepth * 0.08,
)
}
} else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createRoundedShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
insetCornerRadii(outerRadii, glassInset, glassW, glassH),
),
glassDepth,
sashDepth * 0.08,
)
}
}
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
innerW * 0.42,
Math.max(frameThickness * 0.38, 0.018),
frameDepth * 1.1,
0,
pivotY,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
addShapedHopperWindowVisuals(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 3)
const sash = new THREE.Group()
const pivotY = -innerH / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01)
sash.name = HOPPER_WINDOW_SASH_NAME
sash.position.set(0, pivotY, frameDepth * 0.06)
sash.rotation.x = -openAngle
mesh.add(sash)
currentWindowSlot = 'frame'
addBox(
sash,
baseMaterial,
innerW,
sashFrameThickness,
sashDepth,
0,
innerH - sashFrameThickness / 2,
0,
)
addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashFrameThickness / 2, 0)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
-innerW / 2 + sashFrameThickness / 2,
innerH / 2,
0,
)
addBox(
sash,
baseMaterial,
sashFrameThickness,
innerH,
sashDepth,
innerW / 2 - sashFrameThickness / 2,
innerH / 2,
0,
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08)
// Compact bottom hinge rail, visible even when the sash is closed.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
innerW * 0.42,
Math.max(frameThickness * 0.38, 0.018),
frameDepth * 1.1,
0,
pivotY,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createArchedFrameShape(
width,
height,
getClampedArchHeight(width, height, node.archHeight),
frameThickness,
),
frameDepth,
)
} else {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(
width,
height,
frameThickness,
getWindowRoundedRadii(node, width, height),
),
frameDepth,
)
}
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const openAngle = openAmount * (Math.PI / 3)
const pivotY = -innerH / 2
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const sashDepth = frameDepth * 0.72
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const sash = new THREE.Group()
const sashVisual = new THREE.Group()
sash.name = HOPPER_WINDOW_SASH_NAME
sash.position.set(0, pivotY, frameDepth * 0.06)
sash.rotation.x = -openAngle
sashVisual.position.y = innerH / 2
sash.add(sashVisual)
mesh.add(sash)
if (node.openingShape === 'arch') {
const sashArchHeight = getClampedArchHeight(
innerW,
innerH,
(node.archHeight ?? innerW / 2) - frameThickness,
)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createArchShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset),
),
glassDepth,
sashDepth * 0.08,
)
}
} else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape(
sashVisual,
baseMaterial,
createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii),
sashDepth,
)
const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005)
if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape(
sashVisual,
glassMaterial,
createRoundedShape(
-glassW / 2,
glassW / 2,
-glassH / 2,
glassH / 2,
insetCornerRadii(outerRadii, glassInset, glassW, glassH),
),
glassDepth,
sashDepth * 0.08,
)
}
}
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
innerW * 0.42,
Math.max(frameThickness * 0.38, 0.018),
frameDepth * 1.1,
0,
pivotY,
frameDepth * 0.08,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addHungSash(
parent: THREE.Object3D,
panelW: number,
panelHeight: number,
sashFrameThickness: number,
frameDepth: number,
glassDepth: number,
glassW: number,
glassH: number,
) {
currentWindowSlot = 'frame'
addBox(
parent,
baseMaterial,
panelW,
sashFrameThickness,
frameDepth * 0.72,
0,
panelHeight / 2 - sashFrameThickness / 2,
0,
)
addBox(
parent,
baseMaterial,
panelW,
sashFrameThickness,
frameDepth * 0.72,
0,
-panelHeight / 2 + sashFrameThickness / 2,
0,
)
addBox(
parent,
baseMaterial,
sashFrameThickness,
panelHeight,
frameDepth * 0.72,
-panelW / 2 + sashFrameThickness / 2,
0,
0,
)
addBox(
parent,
baseMaterial,
sashFrameThickness,
panelHeight,
frameDepth * 0.72,
panelW / 2 - sashFrameThickness / 2,
0,
0,
)
currentWindowSlot = 'glass'
addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0)
}
function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const railThickness = Math.max(frameThickness * 0.55, 0.025)
const trackThickness = Math.max(frameThickness * 0.35, 0.018)
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerH * 0.12)
const openAmount = getWindowRenderOpenAmount(node)
const travel = Math.max(innerH / 2 - panelOverlap, 0) * openAmount
const panelHeight = (innerH + panelOverlap) / 2
const topPanelY = innerH / 4 + panelOverlap / 4
const bottomPanelY = -innerH / 4 - panelOverlap / 4 + travel
const topZ = -frameDepth * 0.12
const bottomZ = frameDepth * 0.16
const panelW = Math.max(innerW - trackThickness * 2, 0.01)
const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(panelHeight - 2 * sashFrameThickness, 0.01)
const activeSash = new THREE.Group()
activeSash.name = SINGLE_HUNG_ACTIVE_SASH_NAME
activeSash.position.set(0, bottomPanelY, bottomZ)
mesh.add(activeSash)
// Side tracks show the lower sash is the moving element.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
trackThickness,
innerH,
frameDepth,
-innerW / 2 + trackThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
trackThickness,
innerH,
frameDepth,
innerW / 2 - trackThickness / 2,
0,
0,
)
const topSash = new THREE.Group()
topSash.position.set(0, topPanelY, topZ)
mesh.add(topSash)
addHungSash(
topSash,
panelW,
panelHeight,
sashFrameThickness,
frameDepth,
glassDepth,
glassW,
glassH,
)
addHungSash(
activeSash,
panelW,
panelHeight,
sashFrameThickness,
frameDepth,
glassDepth,
glassW,
glassH,
)
// Meeting rails: top sash fixed, bottom sash moves upward over it.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
panelW,
railThickness,
frameDepth * 0.78,
0,
topPanelY - panelHeight / 2 + railThickness / 2,
topZ,
)
addBox(
activeSash,
baseMaterial,
panelW,
railThickness,
frameDepth * 0.78,
0,
panelHeight / 2 - railThickness / 2,
0,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// Fixed outer frame.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const railThickness = Math.max(frameThickness * 0.55, 0.025)
const trackThickness = Math.max(frameThickness * 0.35, 0.018)
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerH * 0.12)
const openAmount = getWindowRenderOpenAmount(node)
const travel = Math.max(innerH / 2 - panelOverlap, 0) * openAmount
const panelHeight = (innerH + panelOverlap) / 2
const topPanelY = innerH / 4 + panelOverlap / 4 - travel
const bottomPanelY = -innerH / 4 - panelOverlap / 4 + travel
const topZ = -frameDepth * 0.12
const bottomZ = frameDepth * 0.16
const panelW = Math.max(innerW - trackThickness * 2, 0.01)
const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(panelHeight - 2 * sashFrameThickness, 0.01)
const topSash = new THREE.Group()
const bottomSash = new THREE.Group()
topSash.name = DOUBLE_HUNG_TOP_SASH_NAME
topSash.position.set(0, topPanelY, topZ)
mesh.add(topSash)
bottomSash.name = DOUBLE_HUNG_BOTTOM_SASH_NAME
bottomSash.position.set(0, bottomPanelY, bottomZ)
mesh.add(bottomSash)
// Side tracks show both sashes move vertically.
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
trackThickness,
innerH,
frameDepth,
-innerW / 2 + trackThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
trackThickness,
innerH,
frameDepth,
innerW / 2 - trackThickness / 2,
0,
0,
)
addHungSash(
topSash,
panelW,
panelHeight,
sashFrameThickness,
frameDepth,
glassDepth,
glassW,
glassH,
)
addHungSash(
bottomSash,
panelW,
panelHeight,
sashFrameThickness,
frameDepth,
glassDepth,
glassW,
glassH,
)
// Opposing meeting rails: top sash descends while bottom sash rises.
currentWindowSlot = 'frame'
addBox(
topSash,
baseMaterial,
panelW,
railThickness,
frameDepth * 0.78,
0,
-panelHeight / 2 + railThickness / 2,
0,
)
addBox(
bottomSash,
baseMaterial,
panelW,
railThickness,
frameDepth * 0.78,
0,
panelHeight / 2 - railThickness / 2,
0,
)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const projectionDepth = Math.max(width * 0.22, 0.28)
const centerW = innerW * 0.48
const sideRun = Math.max((innerW - centerW) / 2, 0.01)
const sideW = Math.hypot(sideRun, projectionDepth)
const sideAngle = Math.atan2(projectionDepth, sideRun)
const panelDepth = Math.max(frameDepth * 0.72, 0.04)
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const glassDepth = Math.max(0.004, frameDepth * 0.08)
const bayFootprint: Array<[number, number]> = [
[-innerW / 2, 0],
[-centerW / 2, projectionDepth],
[centerW / 2, projectionDepth],
[innerW / 2, 0],
]
const addBayPanel = (parent: THREE.Object3D, panelW: number) => {
const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01)
currentWindowSlot = 'frame'
addBox(
parent,
baseMaterial,
panelW,
sashFrameThickness,
panelDepth,
0,
innerH / 2 - sashFrameThickness / 2,
0,
)
addBox(
parent,
baseMaterial,
panelW,
sashFrameThickness,
panelDepth,
0,
-innerH / 2 + sashFrameThickness / 2,
0,
)
addBox(
parent,
baseMaterial,
sashFrameThickness,
innerH,
panelDepth,
-panelW / 2 + sashFrameThickness / 2,
0,
0,
)
addBox(
parent,
baseMaterial,
sashFrameThickness,
innerH,
panelDepth,
panelW / 2 - sashFrameThickness / 2,
0,
0,
)
currentWindowSlot = 'glass'
addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08)
}
const addBayCap = (centerY: number) => {
currentWindowSlot = 'frame'
const halfThickness = frameThickness / 2
const vertices: number[] = []
const indices: number[] = []
for (const [x, z] of bayFootprint) {
vertices.push(x, centerY - halfThickness, z)
}
for (const [x, z] of bayFootprint) {
vertices.push(x, centerY + halfThickness, z)
}
indices.push(
0,
1,
2,
0,
2,
3,
4,
6,
5,
4,
7,
6,
0,
4,
5,
0,
5,
1,
1,
5,
6,
1,
6,
2,
2,
6,
7,
2,
7,
3,
3,
7,
4,
3,
4,
0,
)
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial)))
}
const center = new THREE.Group()
center.position.set(0, 0, projectionDepth)
mesh.add(center)
addBayPanel(center, centerW)
const left = new THREE.Group()
left.position.set((-innerW / 2 - centerW / 2) / 2, 0, projectionDepth / 2)
left.rotation.y = -sideAngle
mesh.add(left)
addBayPanel(left, sideW)
const right = new THREE.Group()
right.position.set((innerW / 2 + centerW / 2) / 2, 0, projectionDepth / 2)
right.rotation.y = sideAngle
mesh.add(right)
addBayPanel(right, sideW)
addBayCap(innerH / 2)
addBayCap(-innerH / 2)
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const mullionCount = 5
const curveSegments = 28
const projectionDepth = Math.max(width * 0.18, 0.22)
const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032)
const halfSpan = innerW / 2
const arcZAt = (x: number) => projectionDepth * (1 - (x / halfSpan) ** 2)
const slabYTop = innerH / 2
const slabYBottom = -innerH / 2
const glassTop = innerH / 2 - sashFrameThickness
const glassBottom = -innerH / 2 + sashFrameThickness
const createCurvedVerticalBand = (yBottom: number, yTop: number, zOffset = 0) => {
const positions: number[] = []
const indices: number[] = []
for (let index = 0; index <= curveSegments; index += 1) {
const x = -halfSpan + (innerW * index) / curveSegments
const z = arcZAt(x) + zOffset
positions.push(x, yBottom, z, x, yTop, z)
}
for (let index = 0; index < curveSegments; index += 1) {
const a = index * 2
indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
const createCurvedCap = (centerY: number, thickness: number) => {
const positions: number[] = []
const indices: number[] = []
const yBottom = centerY - thickness / 2
const yTop = centerY + thickness / 2
for (let index = 0; index <= curveSegments; index += 1) {
const x = -halfSpan + (innerW * index) / curveSegments
const z = arcZAt(x)
positions.push(x, yBottom, 0, x, yBottom, z, x, yTop, 0, x, yTop, z)
}
for (let index = 0; index < curveSegments; index += 1) {
const a = index * 4
const b = a + 4
indices.push(
a,
b,
a + 2,
b,
b + 2,
a + 2,
a + 1,
a + 3,
b + 1,
b + 1,
a + 3,
b + 3,
a + 2,
b + 2,
a + 3,
b + 2,
b + 3,
a + 3,
a,
a + 1,
b,
b,
a + 1,
b + 1,
)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => {
mesh.add(tagWindowSlot(new THREE.Mesh(geometry, material)))
}
currentWindowSlot = 'frame'
addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2))
addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom))
currentWindowSlot = 'glass'
addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04))
currentWindowSlot = 'frame'
addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness))
addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness))
currentWindowSlot = 'frame'
for (let index = 0; index <= mullionCount; index += 1) {
const x = -halfSpan + (innerW * index) / mullionCount
addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x))
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
addShapedLouveredWindowVisuals(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22)))
const slatGap = innerH / slatCount
const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045)
const slatDepth = Math.max(frameDepth * 0.16, 0.012)
const slatAngle = -openAmount * (Math.PI / 3)
const railThickness = Math.max(frameThickness * 0.45, 0.022)
const slats = new THREE.Group()
slats.name = LOUVERED_WINDOW_SLATS_NAME
mesh.add(slats)
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
railThickness,
innerH,
frameDepth * 0.95,
-innerW / 2 + railThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
railThickness,
innerH,
frameDepth * 0.95,
innerW / 2 - railThickness / 2,
0,
0,
)
currentWindowSlot = 'glass'
for (let index = 0; index < slatCount; index += 1) {
const y = innerH / 2 - slatGap * (index + 0.5)
const slat = new THREE.Group()
slat.position.set(0, y, 0)
slat.rotation.x = slatAngle
slats.add(slat)
addBox(
slat,
glassMaterial,
Math.max(innerW - 2 * railThickness, 0.01),
slatHeight,
slatDepth,
0,
0,
0,
)
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node
const halfWidth = width / 2
const bottom = -height / 2
const top = height / 2
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
const innerLeft = -halfWidth + inset
const innerRight = halfWidth - inset
const innerBottom = bottom + inset
const innerTop = top - inset
const innerW = innerRight - innerLeft
const innerH = innerTop - innerBottom
if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createArchedFrameShape(
width,
height,
getClampedArchHeight(width, height, node.archHeight),
frameThickness,
),
frameDepth,
)
} else {
currentWindowSlot = 'frame'
addShape(
mesh,
baseMaterial,
createRoundedFrameShape(
width,
height,
frameThickness,
getWindowRoundedRadii(node, width, height),
),
frameDepth,
)
}
if (innerW > 0.01 && innerH > 0.01) {
const openAmount = getWindowRenderOpenAmount(node)
const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22)))
const slatGap = innerH / slatCount
const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045)
const slatDepth = Math.max(frameDepth * 0.16, 0.012)
const slatAngle = -openAmount * (Math.PI / 3)
const railThickness = Math.max(frameThickness * 0.45, 0.022)
const slatInset = railThickness + 0.004
const slats = new THREE.Group()
slats.name = LOUVERED_WINDOW_SLATS_NAME
mesh.add(slats)
const getBoundsAtY =
node.openingShape === 'arch'
? (() => {
const outerArchHeight = getClampedArchHeight(width, height, node.archHeight)
const archHeight = getClampedArchHeight(innerW, innerH, outerArchHeight - inset)
const springY = top - outerArchHeight
return (y: number) => {
const half = getArchedOpeningHalfWidthAtY(y, innerW / 2, springY, archHeight)
return { minX: -half, maxX: half }
}
})()
: (() => {
const innerRadii = insetCornerRadii(
getWindowRoundedRadii(node, width, height),
inset,
innerW,
innerH,
)
return (y: number) =>
getRoundedHorizontalBoundsAtY(y, innerLeft, innerRight, innerTop, innerRadii)
})()
const addVerticalRail = (x: number) => {
currentWindowSlot = 'frame'
const railX1 = x
const railX2 = x + (x < 0 ? railThickness : -railThickness)
const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2)
const railTop =
node.openingShape === 'arch'
? getArchBoundaryY(
sampleX,
innerW / 2,
top - getClampedArchHeight(width, height, node.archHeight),
getClampedArchHeight(
innerW,
innerH,
getClampedArchHeight(width, height, node.archHeight) - inset,
),
)
: getRoundedBoundaryYAtX(
sampleX,
innerLeft,
innerRight,
innerTop,
insetCornerRadii(getWindowRoundedRadii(node, width, height), inset, innerW, innerH),
)
addShape(
mesh,
baseMaterial,
createRectShape(Math.min(railX1, railX2), Math.max(railX1, railX2), innerBottom, railTop),
frameDepth * 0.95,
)
}
addVerticalRail(innerLeft)
addVerticalRail(innerRight)
currentWindowSlot = 'glass'
for (let index = 0; index < slatCount; index += 1) {
const y = innerTop - slatGap * (index + 0.5)
const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop))
const bottomBounds = getBoundsAtY(Math.max(y - slatHeight / 2, innerBottom))
const minX = Math.max(topBounds.minX, bottomBounds.minX) + slatInset
const maxX = Math.min(topBounds.maxX, bottomBounds.maxX) - slatInset
const slatW = Math.max(maxX - minX, 0)
if (slatW <= 0.01) continue
const slat = new THREE.Group()
slat.position.set((minX + maxX) / 2, y, 0)
slat.rotation.x = slatAngle
slats.add(slat)
addBox(slat, glassMaterial, slatW, slatHeight, slatDepth, 0, 0, 0)
}
}
if (sill) {
const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
}
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
currentWindowSlot = undefined
// Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial
// Sync transform from node (React may lag behind the system by a frame during drag)
mesh.position.set(node.position[0], node.position[1], node.position[2])
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
// Dispose and remove all old visual children; preserve 'cutout'
for (const child of [...mesh.children]) {
if (child.name === 'cutout') continue
disposeObjectGeometry(child)
mesh.remove(child)
}
// Point the builder-facing frame/glass materials at this window's slot
// overrides for the duration of its build (recomputed per node, so the next
// window resets cleanly without a restore).
baseMaterial = resolveWindowSlotMaterial(node, 'frame')
glassMaterial = resolveWindowSlotMaterial(node, 'glass')
const {
width,
height,
frameDepth,
frameThickness,
columnRatios,
rowRatios,
columnDividerThickness,
rowDividerThickness,
sill,
sillDepth,
sillThickness,
openingKind,
openingShape,
windowType,
} = node
if (openingKind === 'opening') {
syncWindowCutout(node, mesh)
return
}
if (windowType === 'sliding') {
addSlidingWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'casement') {
addCasementWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'awning') {
addAwningWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'hopper') {
addAwningWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'single-hung') {
addSingleHungWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'double-hung') {
addDoubleHungWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'bay') {
addBayWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'bow') {
addBowWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (windowType === 'louvered') {
addLouveredWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (openingShape === 'arch') {
addArchedWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
if (openingShape === 'rounded') {
addRoundedWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
// ── Frame members ──
// Top / bottom — full width
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
height / 2 - frameThickness / 2,
0,
)
addBox(
mesh,
baseMaterial,
width,
frameThickness,
frameDepth,
0,
-height / 2 + frameThickness / 2,
0,
)
// Left / right — inner height to avoid corner overlap
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
-width / 2 + frameThickness / 2,
0,
0,
)
addBox(
mesh,
baseMaterial,
frameThickness,
innerH,
frameDepth,
width / 2 - frameThickness / 2,
0,
0,
)
// ── Pane grid ──
const numCols = columnRatios.length
const numRows = rowRatios.length
const usableW = innerW - (numCols - 1) * columnDividerThickness
const usableH = innerH - (numRows - 1) * rowDividerThickness
const colSum = columnRatios.reduce((a, b) => a + b, 0)
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
// Compute column x-centers starting from left edge of inner area
const colXCenters: number[] = []
let cx = -innerW / 2
for (let c = 0; c < numCols; c++) {
colXCenters.push(cx + colWidths[c]! / 2)
cx += colWidths[c]!
if (c < numCols - 1) cx += columnDividerThickness
}
// Compute row y-centers starting from top edge of inner area (R1 = top)
const rowYCenters: number[] = []
let cy = innerH / 2
for (let r = 0; r < numRows; r++) {
rowYCenters.push(cy - rowHeights[r]! / 2)
cy -= rowHeights[r]!
if (r < numRows - 1) cy -= rowDividerThickness
}
// Column dividers — full inner height
cx = -innerW / 2
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
addBox(
mesh,
baseMaterial,
columnDividerThickness,
innerH,
frameDepth,
cx + columnDividerThickness / 2,
0,
0,
)
cx += columnDividerThickness
}
// Row dividers — per column width, so they don't overlap column dividers (top to bottom)
cy = innerH / 2
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) {
cy -= rowHeights[r]!
const divY = cy - rowDividerThickness / 2
for (let c = 0; c < numCols; c++) {
addBox(
mesh,
baseMaterial,
colWidths[c]!,
rowDividerThickness,
frameDepth,
colXCenters[c]!,
divY,
0,
)
}
cy -= rowDividerThickness
}
// Glass panes
const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
for (let c = 0; c < numCols; c++) {
for (let r = 0; r < numRows; r++) {
addBox(
mesh,
glassMaterial,
colWidths[c]!,
rowHeights[r]!,
glassDepth,
colXCenters[c]!,
rowYCenters[r]!,
0,
)
}
}
// ── Sill ──
if (sill) {
const sillW = width + sillDepth * 0.4 // slightly wider than frame
// Protrudes from the front face of the frame (+Z)
const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox(
mesh,
baseMaterial,
sillW,
sillThickness,
sillDepth,
0,
-height / 2 - sillThickness / 2,
sillZ,
)
}
syncWindowCutout(node, mesh)
}
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed window —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the window's parts to find the slot.
mesh.add(cutout)
}
cutout.geometry.dispose()
if (isRectangleOnlyWindowType(node)) {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
} else if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape(
-node.width / 2,
node.width / 2,
-node.height / 2,
node.height / 2,
getWindowRoundedRadii(node, node.width, node.height),
),
{
depth: 1,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
}
cutout.visible = false
}