diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5b297f4..d3875421 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,8 @@ export { type DoorInteractiveState, type ItemInteractiveState, useInteractive, + type WindowAnimationState, + type WindowInteractiveState, } from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ed182320..7314180e 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -82,7 +82,7 @@ export { getWallSurfaceMaterialSignature, WallNode, } from './nodes/wall' -export { WindowNode } from './nodes/window' +export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' // Union types diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index a497175a..50fdcbb4 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -3,6 +3,20 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const WindowType = z.enum([ + 'fixed', + 'sliding', + 'casement', + 'awning', + 'hopper', + 'single-hung', + 'double-hung', + 'bay', + 'bow', + 'louvered', +]) +export type WindowType = z.infer + export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), @@ -21,6 +35,13 @@ export const WindowNode = BaseNode.extend({ // Opening mode - when set to "opening", the window is only a shaped cutout openingKind: z.enum(['window', 'opening']).default('window'), + + // Window family + windowType: WindowType.default('fixed'), + operationState: z.number().min(0).max(1).default(0), + awningDirection: z.enum(['up', 'down']).default('up'), + casementStyle: z.enum(['single', 'french']).default('single'), + hingesSide: z.enum(['left', 'right']).default('left'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), openingRadiusMode: z.enum(['all', 'individual']).default('all'), openingCornerRadii: z @@ -50,6 +71,7 @@ export const WindowNode = BaseNode.extend({ }).describe(dedent`Window node - a parametric window placed on a wall - position: center of the window in wall-local coordinate system - width/height: overall outer dimensions + - windowType: explicit window family, defaulting old windows to fixed - frameThickness: width of the frame members - frameDepth: how deep the frame sits within the wall - columnRatios/rowRatios: pane division ratios diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 19a1c78d..fa72904e 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -26,10 +26,25 @@ export type DoorAnimationState = { persist: boolean } +export type WindowInteractiveState = { + operationState?: number +} + +export type WindowAnimationState = { + field: keyof WindowInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + persist: boolean +} + type InteractiveStore = { items: Record doors: Record doorAnimations: Record + windows: Record + windowAnimations: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -51,6 +66,18 @@ type InteractiveStore = { /** Cancel a queued door animation */ cancelDoorAnimation: (doorId: AnyNodeId) => void + + /** Set transient window open state without committing it to the scene node */ + setWindowOpenState: (windowId: AnyNodeId, value: WindowInteractiveState) => void + + /** Clear transient window open state */ + removeWindowOpenState: (windowId: AnyNodeId) => void + + /** Queue a window animation for the viewer frame loop */ + startWindowAnimation: (windowId: AnyNodeId, value: WindowAnimationState) => void + + /** Cancel a queued window animation */ + cancelWindowAnimation: (windowId: AnyNodeId) => void } const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { @@ -70,6 +97,8 @@ export const useInteractive = create((set, get) => ({ items: {}, doors: {}, doorAnimations: {}, + windows: {}, + windowAnimations: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -139,4 +168,39 @@ export const useInteractive = create((set, get) => ({ return { doorAnimations: rest } }) }, + + setWindowOpenState: (windowId, value) => { + set((state) => ({ + windows: { + ...state.windows, + [windowId]: { + ...state.windows[windowId], + ...value, + }, + }, + })) + }, + + removeWindowOpenState: (windowId) => { + set((state) => { + const { [windowId]: _, ...rest } = state.windows + return { windows: rest } + }) + }, + + startWindowAnimation: (windowId, value) => { + set((state) => ({ + windowAnimations: { + ...state.windowAnimations, + [windowId]: value, + }, + })) + }, + + cancelWindowAnimation: (windowId) => { + set((state) => { + const { [windowId]: _, ...rest } = state.windowAnimations + return { windowAnimations: rest } + }) + }, })) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f695ee5b..dcfd5e13 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -6,6 +6,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type ColumnNode, calculateLevelMiters, DoorNode, emitter, @@ -589,6 +590,7 @@ type FloorplanItemEntry = { type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] + columnEntries: ReferenceFloorColumnEntry[] fenceEntries: FloorplanFenceEntry[] itemEntries: FloorplanItemEntry[] openingPolygons: OpeningPolygonEntry[] @@ -596,6 +598,12 @@ type ReferenceFloorData = { wallPolygons: WallPolygonEntry[] } +type ReferenceFloorColumnEntry = { + column: ColumnNode + points: string + polygon: Point2D[] +} + type FloorplanStairSegmentEntry = { centerLine: FloorplanLineSegment | null innerPoints: string @@ -1644,6 +1652,51 @@ function getRotatedRectanglePolygon( }) } +function getColumnPlanFootprint(column: ColumnNode): Point2D[] { + const center = { x: column.position[0], y: column.position[2] } + const shaftWidth = + column.crossSection === 'round' || + column.crossSection === 'octagonal' || + column.crossSection === 'sixteen-sided' + ? column.radius * 2 + : column.width + const shaftDepth = + column.crossSection === 'round' || + column.crossSection === 'octagonal' || + column.crossSection === 'sixteen-sided' + ? column.radius * 2 + : column.depth + const width = Math.max( + shaftWidth, + column.width * column.baseWidthScale, + column.width * column.capitalWidthScale, + ) + const depth = Math.max( + shaftDepth, + column.depth * column.baseDepthScale, + column.depth * column.capitalDepthScale, + ) + + if (column.crossSection === 'square' || column.crossSection === 'rectangular') { + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + + const segmentCount = + column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32 + + return Array.from({ length: segmentCount }, (_, index) => { + const angle = (index / segmentCount) * Math.PI * 2 + const localX = Math.cos(angle) * (width / 2) + const localY = Math.sin(angle) * (depth / 2) + const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation) + + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) +} + function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D { return { x: start.x + (end.x - start.x) * t, @@ -3711,6 +3764,17 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer( /> ))} + {data.columnEntries.map(({ column, points }) => ( + + ))} + {data.openingPolygons.map(({ opening, points }) => ( node.type === 'wall') const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence') + const referenceColumns = children.filter((node): node is ColumnNode => node.type === 'column') const referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab') const referenceCeilings = children.filter( (node): node is CeilingNode => node.type === 'ceiling', @@ -7886,6 +7951,21 @@ export function FloorplanPanel() { return [{ fence, centerline, markerFrames: [], path }] }) + const columnEntries = referenceColumns.flatMap((column) => { + const polygon = getColumnPlanFootprint(column) + if (polygon.length < 3) { + return [] + } + + return [ + { + column, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + const transformCache = new Map() const itemEntries = referenceDescendants.flatMap((node) => { if ( @@ -7920,6 +8000,7 @@ export function FloorplanPanel() { return { ceilingPolygons, + columnEntries, fenceEntries, itemEntries, openingPolygons, diff --git a/packages/editor/src/components/tools/window/move-window-tool.tsx b/packages/editor/src/components/tools/window/move-window-tool.tsx index 16d784e4..a3509708 100644 --- a/packages/editor/src/components/tools/window/move-window-tool.tsx +++ b/packages/editor/src/components/tools/window/move-window-tool.tsx @@ -294,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin parentId: event.node.id, width: movingWindowNode.width, height: movingWindowNode.height, + windowType: movingWindowNode.windowType, + operationState: movingWindowNode.operationState, + awningDirection: movingWindowNode.awningDirection, + casementStyle: movingWindowNode.casementStyle, + hingesSide: movingWindowNode.hingesSide, frameThickness: movingWindowNode.frameThickness, frameDepth: movingWindowNode.frameDepth, columnRatios: movingWindowNode.columnRatios, diff --git a/packages/editor/src/components/tools/window/window-tool.tsx b/packages/editor/src/components/tools/window/window-tool.tsx index fcf948f4..48e7d638 100644 --- a/packages/editor/src/components/tools/window/window-tool.tsx +++ b/packages/editor/src/components/tools/window/window-tool.tsx @@ -262,6 +262,11 @@ export const WindowTool: React.FC = () => { parentId: event.node.id, width: draft.width, height: draft.height, + windowType: draft.windowType, + operationState: draft.operationState, + awningDirection: draft.awningDirection, + casementStyle: draft.casementStyle, + hingesSide: draft.hingesSide, frameThickness: draft.frameThickness, frameDepth: draft.frameDepth, columnRatios: draft.columnRatios, diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index db80f6f9..1ffc4223 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, emitter, + useInteractive, useScene, WindowNode, } from '@pascal-app/core' @@ -11,6 +12,7 @@ import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' import { usePresetsAdapter } from '../../../contexts/presets-context' +import { cn } from '../../../lib/utils' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -67,6 +69,26 @@ function isSameRadiusTuple( return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) } +const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [ + { label: 'Fixed', value: 'fixed' }, + { label: 'Sliding', value: 'sliding' }, + { label: 'Casement', value: 'casement' }, + { label: 'Awning', value: 'awning' }, + { label: 'Single Hung', value: 'single-hung' }, + { label: 'Double Hung', value: 'double-hung' }, + { label: 'Bay', value: 'bay' }, + { label: 'Bow', value: 'bow' }, + { label: 'Louvered', value: 'louvered' }, +] + +const rectangleOnlyWindowTypes = new Set([ + 'sliding', + 'single-hung', + 'double-hung', + 'bay', + 'bow', +]) + export function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -182,6 +204,11 @@ export function WindowPanel() { parentId: node.parentId, width: node.width, height: node.height, + windowType: node.windowType, + operationState: node.operationState, + awningDirection: node.awningDirection, + casementStyle: node.casementStyle, + hingesSide: node.hingesSide, frameThickness: node.frameThickness, frameDepth: node.frameDepth, openingKind: node.openingKind, @@ -210,6 +237,11 @@ export function WindowPanel() { return { width: node.width, height: node.height, + windowType: node.windowType, + operationState: node.operationState, + awningDirection: node.awningDirection, + casementStyle: node.casementStyle, + hingesSide: node.hingesSide, frameThickness: node.frameThickness, frameDepth: node.frameDepth, openingKind: node.openingKind, @@ -274,6 +306,22 @@ export function WindowPanel() { const archHeight = node.archHeight ?? 0.35 const openingRevealRadius = node.openingRevealRadius ?? 0.025 const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) + const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed') + const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up') + const isOperableWindow = + node.windowType === 'sliding' || + node.windowType === 'casement' || + node.windowType === 'awning' || + node.windowType === 'hopper' || + node.windowType === 'single-hung' || + node.windowType === 'double-hung' || + node.windowType === 'louvered' + + const setOperationState = (value: number) => { + useInteractive.getState().cancelWindowAnimation(node.id) + useInteractive.getState().removeWindowOpenState(node.id) + handleUpdate({ operationState: Math.max(0, Math.min(1, value)) }) + } const getDimensionUpdates = (updates: Partial>) => { const nextWidth = updates.width ?? node.width @@ -398,6 +446,96 @@ export function WindowPanel() { /> + {!isOpening && ( + +
+ {windowTypeOptions.map((option) => { + const isSelected = displayedWindowType === option.value + return ( + + ) + })} +
+ {displayedWindowType === 'awning' && ( +
+ + handleUpdate({ + windowType: 'awning', + awningDirection: value as WindowNode['awningDirection'], + }) + } + options={[ + { value: 'up', label: 'Up' }, + { value: 'down', label: 'Down' }, + ]} + value={awningDirection} + /> +
+ )} + {node.windowType === 'casement' && ( +
+ + handleUpdate({ casementStyle: value as WindowNode['casementStyle'] }) + } + options={[ + { value: 'single', label: 'Single' }, + { value: 'french', label: 'French' }, + ]} + value={node.casementStyle ?? 'single'} + /> + {(node.casementStyle ?? 'single') === 'single' && ( + + handleUpdate({ hingesSide: value as WindowNode['hingesSide'] }) + } + options={[ + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]} + value={node.hingesSide ?? 'left'} + /> + )} +
+ )} + {isOperableWindow && ( +
+ +
+ )} +
+ )} + - {!isOpening && ( + {!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && ( @@ -470,6 +608,7 @@ export function WindowPanel() { openingCornerRadii, cornerRadius: Math.min(cornerRadius, maxRoundedRadius), openingRevealRadius, + sill: false, } : {}), ...(value === 'arch' ? { archHeight } : {}), diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 9d5383a7..4e565c94 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' +import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction' import useEditor from '../store/use-editor' // Tools call this in their onCancel handler when they have an active mid-action to cancel, @@ -146,7 +147,7 @@ export const useKeyboard = ({ } } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) - // Doors use R to toggle their leaf open/closed around the hinge. + // Operable doors/windows use R to toggle their open/closed state. const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] @@ -156,6 +157,20 @@ export const useKeyboard = ({ toggleDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') } + } else if ( + node?.type === 'window' && + node.openingKind !== 'opening' && + (node.windowType === 'sliding' || + node.windowType === 'casement' || + node.windowType === 'awning' || + node.windowType === 'hopper' || + node.windowType === 'single-hung' || + node.windowType === 'double-hung' || + node.windowType === 'louvered') + ) { + e.preventDefault() + toggleWindowOpenState(node.id) + sfxEmitter.emit('sfx:item-rotate') } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 @@ -182,6 +197,20 @@ export const useKeyboard = ({ closeDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') } + } else if ( + node?.type === 'window' && + node.openingKind !== 'opening' && + (node.windowType === 'sliding' || + node.windowType === 'casement' || + node.windowType === 'awning' || + node.windowType === 'hopper' || + node.windowType === 'single-hung' || + node.windowType === 'double-hung' || + node.windowType === 'louvered') + ) { + e.preventDefault() + closeWindowOpenState(node.id) + sfxEmitter.emit('sfx:item-rotate') } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts new file mode 100644 index 00000000..4935e6eb --- /dev/null +++ b/packages/editor/src/lib/window-interaction.ts @@ -0,0 +1,86 @@ +import { + type AnyNodeId, + useInteractive, + useScene, + type WindowInteractiveState, +} from '@pascal-app/core' + +export const WINDOW_TOGGLE_ANIMATION_MS = 520 + +type WindowOpenAnimationOptions = { + persist?: boolean +} + +function isOperableWindowType(windowType: string | undefined) { + return ( + windowType === 'sliding' || + windowType === 'casement' || + windowType === 'awning' || + windowType === 'hopper' || + windowType === 'single-hung' || + windowType === 'double-hung' || + windowType === 'louvered' + ) +} + +function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { + const interactive = useInteractive.getState() + const runtimeValue = interactive.windows[windowId]?.operationState + if (runtimeValue !== undefined) return runtimeValue + + const queuedValue = interactive.windowAnimations[windowId]?.from + if (queuedValue !== undefined) return queuedValue + + return nodeValue ?? 0 +} + +function startWindowOpenAnimation( + windowId: AnyNodeId, + field: keyof WindowInteractiveState, + from: number, + to: number, + options?: WindowOpenAnimationOptions, +) { + useInteractive.getState().startWindowAnimation(windowId, { + field, + from, + to, + startedAt: null, + durationMs: WINDOW_TOGGLE_ANIMATION_MS, + persist: options?.persist ?? true, + }) +} + +export function toggleWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) { + const node = useScene.getState().nodes[windowId] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState) + startWindowOpenAnimation( + windowId, + 'operationState', + currentOpenAmount, + currentOpenAmount >= 0.5 ? 0 : 1, + options, + ) +} + +export function closeWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) { + const node = useScene.getState().nodes[windowId] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState) + startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 0, options) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6d6c6156..bc2b5b85 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -19,6 +19,7 @@ import { SlabSystem } from '../../systems/slab/slab-system' import { StairSystem } from '../../systems/stair/stair-system' import { WallCutout } from '../../systems/wall/wall-cutout' import { WallSystem } from '../../systems/wall/wall-system' +import { WindowAnimationSystem } from '../../systems/window/window-animation-system' import { WindowSystem } from '../../systems/window/window-system' import { ZoneSystem } from '../../systems/zone/zone-system' import { ErrorBoundary } from '../error-boundary' @@ -227,6 +228,7 @@ const Viewer: React.FC = ({ {/* Core systems */} + diff --git a/packages/viewer/src/systems/window/window-animation-system.tsx b/packages/viewer/src/systems/window/window-animation-system.tsx new file mode 100644 index 00000000..1af29df6 --- /dev/null +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -0,0 +1,108 @@ +import { type AnyNodeId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { + AWNING_WINDOW_SASH_NAME, + CASEMENT_WINDOW_SASH_NAME, + FRENCH_CASEMENT_LEFT_SASH_NAME, + FRENCH_CASEMENT_RIGHT_SASH_NAME, + HOPPER_WINDOW_SASH_NAME, +} from './window-system' + +const easeWindowAnimation = (value: number) => value * value * (3 - 2 * value) + +function markWindowDirty(windowId: AnyNodeId) { + const scene = useScene.getState() + const node = scene.nodes[windowId] + scene.dirtyNodes.add(windowId) +} + +function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) { + const node = useScene.getState().nodes[windowId] + if (node?.type !== 'window') return false + + const mesh = sceneRegistry.nodes.get(windowId) + + if (node.windowType === 'casement') { + if ((node.casementStyle ?? 'single') === 'french') { + const leftSash = mesh?.getObjectByName(FRENCH_CASEMENT_LEFT_SASH_NAME) + const rightSash = mesh?.getObjectByName(FRENCH_CASEMENT_RIGHT_SASH_NAME) + if (!(leftSash && rightSash)) return false + + leftSash.rotation.y = -value * (Math.PI / 2) + rightSash.rotation.y = value * (Math.PI / 2) + return true + } + + const sash = mesh?.getObjectByName(CASEMENT_WINDOW_SASH_NAME) + if (!sash) return false + + const hingeSign = (node.hingesSide ?? 'left') === 'left' ? -1 : 1 + sash.rotation.y = hingeSign * value * (Math.PI / 2) + return true + } + + if (node.windowType === 'awning') { + const sash = mesh?.getObjectByName(AWNING_WINDOW_SASH_NAME) + if (!sash) return false + + sash.rotation.x = -value * (Math.PI / 3) + return true + } + + if (node.windowType === 'hopper') { + const sash = + mesh?.getObjectByName(AWNING_WINDOW_SASH_NAME) ?? + mesh?.getObjectByName(HOPPER_WINDOW_SASH_NAME) + if (!sash) return false + + sash.rotation.x = -value * (Math.PI / 3) + return true + } + + return false +} + +export const WindowAnimationSystem = () => { + useFrame(({ clock }) => { + const interactive = useInteractive.getState() + const entries = Object.entries(interactive.windowAnimations) + if (entries.length === 0) return + + const now = clock.getElapsedTime() * 1000 + + for (const [windowId, animation] of entries) { + const typedWindowId = windowId as AnyNodeId + const scene = useScene.getState() + const node = scene.nodes[typedWindowId] + if (node?.type !== 'window') { + interactive.cancelWindowAnimation(typedWindowId) + interactive.removeWindowOpenState(typedWindowId) + continue + } + + const startedAt = animation.startedAt ?? now + if (animation.startedAt === null) { + interactive.startWindowAnimation(typedWindowId, { ...animation, startedAt }) + } + + const progress = Math.min(1, (now - startedAt) / animation.durationMs) + const value = animation.from + (animation.to - animation.from) * easeWindowAnimation(progress) + interactive.setWindowOpenState(typedWindowId, { [animation.field]: value }) + const appliedDirectly = applyDirectWindowAnimation(typedWindowId, value) + if (!appliedDirectly) markWindowDirty(typedWindowId) + + if (progress < 1) continue + + interactive.cancelWindowAnimation(typedWindowId) + if (animation.persist) { + scene.updateNode(typedWindowId, { [animation.field]: animation.to }) + interactive.removeWindowOpenState(typedWindowId) + markWindowDirty(typedWindowId) + } else { + interactive.setWindowOpenState(typedWindowId, { [animation.field]: animation.to }) + } + } + }, 2) + + return null +} diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 81382b1e..7d41d1b4 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,10 +1,21 @@ -import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core' +import { + type AnyNodeId, + sceneRegistry, + useInteractive, + useScene, + type WindowNode, +} from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) +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 AWNING_WINDOW_SASH_NAME = 'awning-window-sash' +export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash' export const WindowSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) @@ -67,6 +78,12 @@ function addShape( 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) @@ -316,6 +333,30 @@ function getRoundedBoundaryYAtX( 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, @@ -565,6 +606,2393 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } +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. + 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 leftPanelX = -innerW / 4 - panelOverlap / 4 + 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) + + // Twin tracks signal the sliding operation without adding editor-only state. + addBox( + mesh, + baseMaterial, + innerW, + trackThickness, + frameDepth, + 0, + innerH / 2 - trackThickness / 2, + 0, + ) + addBox( + mesh, + baseMaterial, + innerW, + trackThickness, + frameDepth, + 0, + -innerH / 2 + trackThickness / 2, + 0, + ) + + addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, leftPanelX, 0, leftZ) + 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. + addBox( + mesh, + baseMaterial, + railThickness, + panelH, + frameDepth * 0.72, + leftPanelX - panelWidth / 2 + railThickness / 2, + 0, + leftZ, + ) + addBox( + mesh, + baseMaterial, + railThickness, + panelH, + frameDepth * 0.72, + rightPanelX + panelWidth / 2 - railThickness / 2, + 0, + rightZ, + ) + addBox( + mesh, + baseMaterial, + railThickness, + panelH, + frameDepth * 0.78, + leftPanelX + panelWidth / 2 - railThickness / 2, + 0, + leftZ, + ) + 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 + 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) + + 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, + ) + 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 + 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 + 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) { + 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, + ) + 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) { + 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. + 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 + 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') { + addShape( + mesh, + baseMaterial, + createArchedFrameShape( + width, + height, + getClampedArchHeight(width, height, node.archHeight), + frameThickness, + ), + frameDepth, + ) + } else { + 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 + 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, + ) + 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 + 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) + 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 + addShape( + sashVisual, + glassMaterial, + createRoundedShape( + -glassW / 2, + glassW / 2, + -glassH / 2, + glassH / 2, + insetCornerRadii(outerRadii, glassInset, glassW, glassH), + ), + glassDepth, + sashDepth * 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, + ) + 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 + 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. + 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) + + 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, + ) + addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) + + // Small hinge markers make the pivot side legible when the sash is closed. + 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 + 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. + 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) + + 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, + ) + addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) + + // Compact hinge rail, visible even when the sash is closed. + 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 + 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') { + addShape( + mesh, + baseMaterial, + createArchedFrameShape( + width, + height, + getClampedArchHeight(width, height, node.archHeight), + frameThickness, + ), + frameDepth, + ) + } else { + 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, + ) + 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 + 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) + 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 + addShape( + sashVisual, + glassMaterial, + createRoundedShape( + -glassW / 2, + glassW / 2, + -glassH / 2, + glassH / 2, + insetCornerRadii(outerRadii, glassInset, glassW, glassH), + ), + glassDepth, + sashDepth * 0.08, + ) + } + } + + 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 + 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. + 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) + + 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, + ) + addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) + + // Compact bottom hinge rail, visible even when the sash is closed. + 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 + 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') { + addShape( + mesh, + baseMaterial, + createArchedFrameShape( + width, + height, + getClampedArchHeight(width, height, node.archHeight), + frameThickness, + ), + frameDepth, + ) + } else { + 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, + ) + 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 + 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) + 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 + addShape( + sashVisual, + glassMaterial, + createRoundedShape( + -glassW / 2, + glassW / 2, + -glassH / 2, + glassH / 2, + insetCornerRadii(outerRadii, glassInset, glassW, glassH), + ), + glassDepth, + sashDepth * 0.08, + ) + } + } + + 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 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + +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. + 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) + + // Side tracks show the lower sash is the moving element. + 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 addSash = (centerY: number, z: number) => { + addBox( + mesh, + baseMaterial, + panelW, + sashFrameThickness, + frameDepth * 0.72, + 0, + centerY + panelHeight / 2 - sashFrameThickness / 2, + z, + ) + addBox( + mesh, + baseMaterial, + panelW, + sashFrameThickness, + frameDepth * 0.72, + 0, + centerY - panelHeight / 2 + sashFrameThickness / 2, + z, + ) + addBox( + mesh, + baseMaterial, + sashFrameThickness, + panelHeight, + frameDepth * 0.72, + -panelW / 2 + sashFrameThickness / 2, + centerY, + z, + ) + addBox( + mesh, + baseMaterial, + sashFrameThickness, + panelHeight, + frameDepth * 0.72, + panelW / 2 - sashFrameThickness / 2, + centerY, + z, + ) + addBox(mesh, glassMaterial, glassW, glassH, glassDepth, 0, centerY, z) + } + + addSash(topPanelY, topZ) + addSash(bottomPanelY, bottomZ) + + // Meeting rails: top sash fixed, bottom sash moves upward over it. + addBox( + mesh, + baseMaterial, + panelW, + railThickness, + frameDepth * 0.78, + 0, + topPanelY - panelHeight / 2 + railThickness / 2, + topZ, + ) + addBox( + mesh, + baseMaterial, + panelW, + railThickness, + frameDepth * 0.78, + 0, + bottomPanelY + panelHeight / 2 - railThickness / 2, + bottomZ, + ) + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + 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. + 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) + + // Side tracks show both sashes move vertically. + 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 addSash = (centerY: number, z: number) => { + addBox( + mesh, + baseMaterial, + panelW, + sashFrameThickness, + frameDepth * 0.72, + 0, + centerY + panelHeight / 2 - sashFrameThickness / 2, + z, + ) + addBox( + mesh, + baseMaterial, + panelW, + sashFrameThickness, + frameDepth * 0.72, + 0, + centerY - panelHeight / 2 + sashFrameThickness / 2, + z, + ) + addBox( + mesh, + baseMaterial, + sashFrameThickness, + panelHeight, + frameDepth * 0.72, + -panelW / 2 + sashFrameThickness / 2, + centerY, + z, + ) + addBox( + mesh, + baseMaterial, + sashFrameThickness, + panelHeight, + frameDepth * 0.72, + panelW / 2 - sashFrameThickness / 2, + centerY, + z, + ) + addBox(mesh, glassMaterial, glassW, glassH, glassDepth, 0, centerY, z) + } + + addSash(topPanelY, topZ) + addSash(bottomPanelY, bottomZ) + + // Opposing meeting rails: top sash descends while bottom sash rises. + addBox( + mesh, + baseMaterial, + panelW, + railThickness, + frameDepth * 0.78, + 0, + topPanelY - panelHeight / 2 + railThickness / 2, + topZ, + ) + addBox( + mesh, + baseMaterial, + panelW, + railThickness, + frameDepth * 0.78, + 0, + bottomPanelY + panelHeight / 2 - railThickness / 2, + bottomZ, + ) + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + 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 + + 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 addBayPanel = (parent: THREE.Object3D, panelW: number) => { + const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) + const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) + 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, + ) + addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) + } + + 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) + + addBox( + mesh, + baseMaterial, + innerW, + frameThickness, + projectionDepth, + 0, + innerH / 2, + projectionDepth / 2, + ) + addBox( + mesh, + baseMaterial, + innerW, + frameThickness, + projectionDepth, + 0, + -innerH / 2, + projectionDepth / 2, + ) + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + 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 + + 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(new THREE.Mesh(geometry, material)) + } + + addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2)) + addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) + addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) + addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) + addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) + + 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 + 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 + + 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) + + 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, + ) + + 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 + mesh.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 + 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') { + addShape( + mesh, + baseMaterial, + createArchedFrameShape( + width, + height, + getClampedArchHeight(width, height, node.archHeight), + frameThickness, + ), + frameDepth, + ) + } else { + 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 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) => { + 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) + + 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 + mesh.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 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -578,7 +3006,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Dispose and remove all old visual children; preserve 'cutout' for (const child of [...mesh.children]) { if (child.name === 'cutout') continue - if (child instanceof THREE.Mesh) child.geometry.dispose() + disposeObjectGeometry(child) mesh.remove(child) } @@ -596,6 +3024,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sillThickness, openingKind, openingShape, + windowType, } = node if (openingKind === 'opening') { @@ -603,6 +3032,60 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.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) @@ -775,7 +3258,9 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - if (node.openingShape === 'arch') { + 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,