diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index fa467e9dd..0364a8804 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -7,6 +7,7 @@ import type { ColumnNode, DoorNode, FenceNode, + GuideNode, ItemNode, LevelNode, RoofNode, @@ -132,6 +133,26 @@ type ToolEvents = { 'tool:cancel': undefined } +type GuideEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + +type DoorAnimationEvents = { + 'door:animation-completed': { + doorId: DoorNode['id'] + field: 'operationState' | 'swingAngle' + } +} + +type WindowAnimationEvents = { + 'window:animation-completed': { + windowId: WindowNode['id'] + field: 'operationState' + } +} + type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -173,6 +194,9 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & + GuideEvents & + DoorAnimationEvents & + WindowAnimationEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50dad3366..d3875421f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,13 @@ export { } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { + clampDoorOperationState, + getDoorRenderOpenAmount, + getGarageVisibleOpeningRatio, + isOperationDoorType, + SECTIONAL_GARAGE_RENDER_OPEN_SCALE, +} from './lib/door-operation' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, @@ -62,8 +69,12 @@ export { } from './store/history-control' export { type ControlValue, + type DoorAnimationState, + 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/lib/door-operation.ts b/packages/core/src/lib/door-operation.ts new file mode 100644 index 000000000..cdfb93da0 --- /dev/null +++ b/packages/core/src/lib/door-operation.ts @@ -0,0 +1,42 @@ +import type { DoorNode, DoorType } from '../schema/nodes/door' + +export const SECTIONAL_GARAGE_RENDER_OPEN_SCALE = 0.88 + +export function clampDoorOperationState(value: number | undefined) { + return Math.max(0, Math.min(1, value ?? 0)) +} + +export function isOperationDoorType( + doorType: DoorType | DoorNode['doorType'] | string | undefined, +) { + return ( + doorType === 'folding' || + doorType === 'pocket' || + doorType === 'barn' || + doorType === 'sliding' || + doorType === 'garage-sectional' || + doorType === 'garage-rollup' || + doorType === 'garage-tiltup' + ) +} + +export function getDoorRenderOpenAmount( + doorType: DoorType | DoorNode['doorType'], + operationState: number | undefined, +) { + const openAmount = clampDoorOperationState(operationState) + return doorType === 'garage-sectional' + ? openAmount * SECTIONAL_GARAGE_RENDER_OPEN_SCALE + : openAmount +} + +export function getGarageVisibleOpeningRatio( + doorType: DoorType | DoorNode['doorType'], + operationState: number | undefined, +) { + if (doorType === 'garage-sectional') { + return Math.min(1, clampDoorOperationState(operationState) / SECTIONAL_GARAGE_RENDER_OPEN_SCALE) + } + + return clampDoorOperationState(operationState) +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ed182320a..7314180ea 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/door.ts b/packages/core/src/schema/nodes/door.ts index 8940270a4..b64938c74 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -18,6 +18,25 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer +export const DoorCategory = z.enum(['interior', 'garage']) +export const DoorType = z.enum([ + 'hinged', + 'double', + 'french', + 'folding', + 'pocket', + 'barn', + 'sliding', + 'garage-sectional', + 'garage-rollup', + 'garage-tiltup', +]) +export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) + +export type DoorCategory = z.infer +export type DoorType = z.infer +export type DoorTrackStyle = z.infer + export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), @@ -32,6 +51,15 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Door family + doorCategory: DoorCategory.default('interior'), + doorType: DoorType.default('hinged'), + leafCount: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1), + operationState: z.number().min(0).max(1).default(0), + slideDirection: z.enum(['left', 'right']).default('left'), + trackStyle: DoorTrackStyle.default('none'), + garagePanelCount: z.number().int().min(1).max(12).default(4), + // Opening mode openingKind: z.enum(['door', 'opening']).default('door'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), @@ -90,6 +118,7 @@ export const DoorNode = BaseNode.extend({ panicBarHeight: z.number().default(1.0), }).describe(dedent`Door node - a parametric door placed on a wall - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) + - doorCategory/doorType: explicit operation family, defaulting old doors to interior hinged - openingKind/openingShape: hinged door or frameless wall opening shape - segments: rows stacked top to bottom, each defining its own columnRatios - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index a497175a8..50fdcbb42 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 8b165559f..fa72904e8 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -12,8 +12,39 @@ export type ItemInteractiveState = { controlValues: ControlValue[] } +export type DoorInteractiveState = { + operationState?: number + swingAngle?: number +} + +export type DoorAnimationState = { + field: keyof DoorInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + 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 @@ -23,6 +54,30 @@ type InteractiveStore = { /** Remove a node's state (e.g. on unmount) */ removeItem: (itemId: AnyNodeId) => void + + /** Set transient door open state without committing it to the scene node */ + setDoorOpenState: (doorId: AnyNodeId, value: DoorInteractiveState) => void + + /** Clear transient door open state */ + removeDoorOpenState: (doorId: AnyNodeId) => void + + /** Queue a door animation for the viewer frame loop */ + startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void + + /** 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 => { @@ -40,6 +95,10 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa export const useInteractive = create((set, get) => ({ items: {}, + doors: {}, + doorAnimations: {}, + windows: {}, + windowAnimations: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -74,4 +133,74 @@ export const useInteractive = create((set, get) => ({ return { items: rest } }) }, + + setDoorOpenState: (doorId, value) => { + set((state) => ({ + doors: { + ...state.doors, + [doorId]: { + ...state.doors[doorId], + ...value, + }, + }, + })) + }, + + removeDoorOpenState: (doorId) => { + set((state) => { + const { [doorId]: _, ...rest } = state.doors + return { doors: rest } + }) + }, + + startDoorAnimation: (doorId, value) => { + set((state) => ({ + doorAnimations: { + ...state.doorAnimations, + [doorId]: value, + }, + })) + }, + + cancelDoorAnimation: (doorId) => { + set((state) => { + const { [doorId]: _, ...rest } = state.doorAnimations + 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/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ee39342ec..cbc7ad140 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,12 +1,23 @@ 'use client' import '../../three-types' -import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' +import { + closeDoorOpenState, + DOOR_SWING_OPEN_ANGLE, + isOperationDoorType, + toggleDoorOpenState, +} from '../../lib/door-interaction' +import { + closeWindowOpenState, + isOperableWindowType, + toggleWindowOpenState, +} from '../../lib/window-interaction' import useEditor from '../../store/use-editor' import { buildFirstPersonColliderWorldFromRegistry, @@ -22,7 +33,6 @@ const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 -const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, @@ -43,8 +53,20 @@ const doorLeafLocalHit = new Vector3() const doorLeafLocalRay = new Ray() const doorLeafMatrix = new Matrix4() const doorLeafWorldHit = new Vector3() +const doorOpeningBox = new Box3() +const doorOpeningInverseMatrix = new Matrix4() +const doorOpeningLocalHit = new Vector3() +const doorOpeningLocalRay = new Ray() +const doorOpeningMatrix = new Matrix4() +const doorOpeningWorldHit = new Vector3() const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') +const windowInteractionRaycaster = new Raycaster() + +type FirstPersonInteractableTarget = { + id: AnyNodeId + type: 'door' | 'window' +} const resolvePlacedSpawnNode = ( nodes: ReturnType['nodes'], @@ -63,7 +85,7 @@ export const FirstPersonControls = () => { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) - const interactableDoorIdRef = useRef(null) + const interactableTargetRef = useRef(null) const worldRef = useRef(null) const [world, setWorld] = useState(null) const [controllerStart, setControllerStart] = useState<{ @@ -113,10 +135,45 @@ export const FirstPersonControls = () => { if (leafW <= 0 || leafH <= 0) continue const leafCenterY = -node.frameThickness / 2 + + if (isOperationDoorType(node.doorType)) { + doorOpeningMatrix + .copy(object.matrixWorld) + .multiply(new Matrix4().makeTranslation(0, leafCenterY, 0)) + doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert() + doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) + doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) + doorOpeningLocalRay + .copy(doorInteractionRaycaster.ray) + .applyMatrix4(doorOpeningInverseMatrix) + + const localOpeningHit = doorOpeningLocalRay.intersectBox( + doorOpeningBox, + doorOpeningLocalHit, + ) + if (!localOpeningHit) continue + + doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix) + const openingHitDistance = doorOpeningWorldHit.distanceTo( + doorInteractionRaycaster.ray.origin, + ) + + if ( + openingHitDistance <= DOOR_INTERACTION_DISTANCE && + openingHitDistance < closestDistance + ) { + closestDoorId = doorId as AnyNodeId + closestDistance = openingHitDistance + } + continue + } + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 - const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0)) + const currentSwingAngle = + useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0 + const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign doorLeafMatrix @@ -144,20 +201,94 @@ export const FirstPersonControls = () => { return closestDoorId }, [camera]) - const toggleInteractableDoor = useCallback(() => { - const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId() - if (!doorId) return + const resolveInteractableWindowId = useCallback((): AnyNodeId | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestWindowId: AnyNodeId | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const windowId of sceneRegistry.byType.window) { + const node = nodes[windowId as AnyNodeId] + if (node?.type !== 'window') continue + if (node.openingKind === 'opening') continue + if (!isOperableWindowType(node.windowType)) continue + + const object = sceneRegistry.nodes.get(windowId) + if (!object) continue + + const hit = windowInteractionRaycaster + .intersectObject(object, true) + .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) + if (!(hit && hit.distance < closestDistance)) continue + + closestWindowId = windowId as AnyNodeId + closestDistance = hit.distance + } + + return closestWindowId + }, [camera]) + + const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => { + const doorId = resolveInteractableDoorId() + if (doorId) return { id: doorId, type: 'door' } + + const windowId = resolveInteractableWindowId() + if (windowId) return { id: windowId, type: 'window' } + + return null + }, [resolveInteractableDoorId, resolveInteractableWindowId]) + + const toggleInteractableTarget = useCallback(() => { + const target = interactableTargetRef.current ?? resolveInteractableTarget() + if (!target) return + + if (target.type === 'window') { + const node = useScene.getState().nodes[target.id] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + toggleWindowOpenState(target.id, { persist: false }) + return + } + + const doorId = target.id const node = useScene.getState().nodes[doorId] if (node?.type !== 'door' || node.openingKind === 'opening') return - const currentSwingAngle = node.swingAngle ?? 0 - useScene.getState().updateNode(doorId, { - swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) + toggleDoorOpenState(doorId, { persist: false }) + }, [resolveInteractableTarget]) + + const closeInteractableTarget = useCallback(() => { + const target = interactableTargetRef.current ?? resolveInteractableTarget() + if (!target) return + + if (target.type === 'window') { + const node = useScene.getState().nodes[target.id] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + closeWindowOpenState(target.id, { persist: false }) + return + } - requestAnimationFrame(rebuildColliderWorld) - }, [rebuildColliderWorld, resolveInteractableDoorId]) + const node = useScene.getState().nodes[target.id] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + closeDoorOpenState(target.id, { persist: false }) + }, [resolveInteractableTarget]) const placedSpawn = useMemo(() => { if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null @@ -198,6 +329,15 @@ export const FirstPersonControls = () => { } }, [rebuildColliderWorld]) + useEffect(() => { + emitter.on('door:animation-completed', rebuildColliderWorld) + emitter.on('window:animation-completed', rebuildColliderWorld) + return () => { + emitter.off('door:animation-completed', rebuildColliderWorld) + emitter.off('window:animation-completed', rebuildColliderWorld) + } + }, [rebuildColliderWorld]) + useEffect(() => { if (!world) return if (controllerStart) return @@ -260,10 +400,14 @@ export const FirstPersonControls = () => { document.exitPointerLock() } useEditor.getState().setFirstPersonMode(false) - } else if (event.code === 'KeyE') { + } else if (event.code === 'KeyE' || event.code === 'KeyR') { + event.preventDefault() + event.stopPropagation() + toggleInteractableTarget() + } else if (event.code === 'KeyT') { event.preventDefault() event.stopPropagation() - toggleInteractableDoor() + closeInteractableTarget() } } @@ -271,7 +415,7 @@ export const FirstPersonControls = () => { return () => { document.removeEventListener('keydown', handleKeyDown, true) } - }, [gl, toggleInteractableDoor]) + }, [closeInteractableTarget, gl, toggleInteractableTarget]) useFrame((_, delta) => { if (!controllerRef.current?.group) return @@ -283,16 +427,20 @@ export const FirstPersonControls = () => { camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) - const nextInteractableDoorId = resolveInteractableDoorId() - if (interactableDoorIdRef.current !== nextInteractableDoorId) { - interactableDoorIdRef.current = nextInteractableDoorId - useViewer.getState().setHoveredId(nextInteractableDoorId) + const nextInteractableTarget = resolveInteractableTarget() + const previousInteractableTarget = interactableTargetRef.current + if ( + previousInteractableTarget?.id !== nextInteractableTarget?.id || + previousInteractableTarget?.type !== nextInteractableTarget?.type + ) { + interactableTargetRef.current = nextInteractableTarget + useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) } }) useEffect(() => { return () => { - if (useViewer.getState().hoveredId === interactableDoorIdRef.current) { + if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) { useViewer.getState().setHoveredId(null) } } @@ -407,6 +555,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
+ +
Click to look around diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index c373d6daa..a908d3e05 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,4 +1,12 @@ -import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + getGarageVisibleOpeningRatio, + type AnyNodeId, + type DoorNode, + isOperationDoorType, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' @@ -23,6 +31,7 @@ const UP = new THREE.Vector3(0, 1, 0) const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 const DOOR_LEAF_COLLIDER_DEPTH = 0.06 +const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -104,14 +113,46 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { if (leafW <= 0 || leafH <= 0) return null const leafCenterY = -node.frameThickness / 2 + const runtimeDoorState = useInteractive.getState().doors[node.id] + const operationState = runtimeDoorState?.operationState ?? node.operationState + const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle + + root.updateWorldMatrix(true, false) + + if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') { + const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState) + const visibleHeight = leafH * (1 - openAmount) + if (visibleHeight <= 0.12) return null + + const sourceGeometry = new THREE.BoxGeometry( + leafW, + visibleHeight, + DOOR_LEAF_COLLIDER_DEPTH, + ).toNonIndexed() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) + geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + sourceGeometry.dispose() + const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 + geometry.applyMatrix4( + root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)), + ) + return geometry + } + + if ( + isOperationDoorType(node.doorType) && + (operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD + ) { + return null + } + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 - const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - root.updateWorldMatrix(true, false) - const sourceGeometry = new THREE.BoxGeometry( leafW, leafH, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f51880129..dcfd5e13c 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 }) => ( ( + (points, _, index) => { + if (index === 0) return [{ x: svgP1.x, y: svgP1.y }] + + const previous = points[index - 1]! + const direction = (index - 1) % 2 === 0 ? -1 : 1 + const angle = direction * foldingAngle + const along = Math.cos(angle) * foldingPanelLength + const out = Math.sin(angle) * foldingPanelLength * swingSign + points.push({ + x: previous.x + nx * along + px * out, + y: previous.y + ny * along + py * out, + }) + return points + }, + [], + ) + : [] + const foldingPath = + foldingPoints.length > 0 + ? foldingPoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' ') + : null + const isPocketDoor = opening.doorType === 'pocket' + const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) + const pocketSign = opening.slideDirection === 'right' ? 1 : -1 + const pocketShift = pocketSign * foldingSpan * pocketAmount + const pocketTrackStart = + pocketSign > 0 + ? svgP1 + : { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan } + const pocketTrackEnd = + pocketSign > 0 + ? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan } + : svgP2 + const pocketLeafStart = { + x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, + y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, + } + const pocketLeafEnd = { + x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, + y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, + } + const pocketLeafPoints = [ + { + x: pocketLeafStart.x - px * leafHalfThickness, + y: pocketLeafStart.y - py * leafHalfThickness, + }, + { + x: pocketLeafEnd.x - px * leafHalfThickness, + y: pocketLeafEnd.y - py * leafHalfThickness, + }, + { + x: pocketLeafEnd.x + px * leafHalfThickness, + y: pocketLeafEnd.y + py * leafHalfThickness, + }, + { + x: pocketLeafStart.x + px * leafHalfThickness, + y: pocketLeafStart.y + py * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const isBarnDoor = opening.doorType === 'barn' + const barnLeafStart = { + x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75, + y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75, + } + const barnLeafEnd = { + x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75, + y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75, + } + const barnLeafPoints = [ + { + x: barnLeafStart.x - px * leafHalfThickness, + y: barnLeafStart.y - py * leafHalfThickness, + }, + { + x: barnLeafEnd.x - px * leafHalfThickness, + y: barnLeafEnd.y - py * leafHalfThickness, + }, + { + x: barnLeafEnd.x + px * leafHalfThickness, + y: barnLeafEnd.y + py * leafHalfThickness, + }, + { + x: barnLeafStart.x + px * leafHalfThickness, + y: barnLeafStart.y + py * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const isSlidingDoor = opening.doorType === 'sliding' + const slidingPanelSpan = foldingSpan * 0.54 + const slidingActiveOnRight = opening.slideDirection !== 'right' + const slidingFixedSign = slidingActiveOnRight ? -1 : 1 + const slidingActiveSign = slidingActiveOnRight ? 1 : -1 + const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23 + const slidingActiveCenter = + slidingActiveSign * foldingSpan * 0.23 - + slidingActiveSign * foldingSpan * 0.44 * pocketAmount + const slidingPanelPoints = (centerOffset: number, faceOffset: number) => { + const start = { + x: + svgP1.x + + nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + + px * swingSign * faceOffset, + y: + svgP1.y + + ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + + py * swingSign * faceOffset, + } + const end = { + x: + svgP1.x + + nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + + px * swingSign * faceOffset, + y: + svgP1.y + + ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + + py * swingSign * faceOffset, + } + return [ + { x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness }, + { x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness }, + { x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness }, + { x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + } + const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34) + const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68) + const isGarageSectionalDoor = opening.doorType === 'garage-sectional' + const isGarageRollupDoor = opening.doorType === 'garage-rollup' + const isGarageTiltupDoor = opening.doorType === 'garage-tiltup' + const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4)) + const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => { + const t = (index + 1) / garagePanelCount + return { + start: { + x: svgP1.x + (svgP2.x - svgP1.x) * t, + y: svgP1.y + (svgP2.y - svgP1.y) * t, + }, + end: { + x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78, + y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78, + }, + } + }) + const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french' + const doubleLeafPlans = isDoubleSwingDoor + ? ( + [ + { + key: 'left', + hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) }, + strikePoint: { x: cx, y: cy }, + }, + { + key: 'right', + hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) }, + strikePoint: { x: cx, y: cy }, + }, + ] as const + ).map(({ key, hingePoint, strikePoint }) => { + const tangentSign = key === 'left' ? 1 : -1 + const planHingeCubeCenter = { + x: hingePoint.x + nx * tangentSign * doorCubeInset, + y: hingePoint.y + ny * tangentSign * doorCubeInset, + } + const planStrikeCubeCenter = { + x: strikePoint.x - nx * tangentSign * doorCubeInset, + y: strikePoint.y - ny * tangentSign * doorCubeInset, + } + const planLeafStart = { + x: + planHingeCubeCenter.x + + px * swingSign * (doorCubeSize / 2) + + nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness), + y: + planHingeCubeCenter.y + + py * swingSign * (doorCubeSize / 2) + + ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness), + } + const planArcEnd = { + x: + planStrikeCubeCenter.x + + px * swingSign * (doorCubeSize / 2) - + nx * tangentSign * (doorCubeSize / 2), + y: + planStrikeCubeCenter.y + + py * swingSign * (doorCubeSize / 2) - + ny * tangentSign * (doorCubeSize / 2), + } + const planSwingRadius = Math.hypot( + planArcEnd.x - planLeafStart.x, + planArcEnd.y - planLeafStart.y, + ) + const planClosedLeafVector = { + x: planArcEnd.x - planLeafStart.x, + y: planArcEnd.y - planLeafStart.y, + } + const planOpenAngle = swingAngle * swingSign * tangentSign + const planOpenCos = Math.cos(planOpenAngle) + const planOpenSin = Math.sin(planOpenAngle) + const planLeafEnd = { + x: + planLeafStart.x + + planClosedLeafVector.x * planOpenCos - + planClosedLeafVector.y * planOpenSin, + y: + planLeafStart.y + + planClosedLeafVector.x * planOpenSin + + planClosedLeafVector.y * planOpenCos, + } + const planSweepFlag = + key === 'left' + ? swingDirection === 'inward' + ? 0 + : 1 + : swingDirection === 'inward' + ? 1 + : 0 + + return { + key, + hingeCubeCenter: planHingeCubeCenter, + strikeCubeCenter: planStrikeCubeCenter, + hingeMarkerX: planHingeCubeCenter.x, + hingeMarkerY: planHingeCubeCenter.y, + swingRadius: planSwingRadius, + sweepFlag: planSweepFlag, + arcEnd: planArcEnd, + leafEnd: planLeafEnd, + leafPolygonPoints: [ + { + x: planLeafStart.x - nx * leafHalfThickness, + y: planLeafStart.y - ny * leafHalfThickness, + }, + { + x: planLeafEnd.x - nx * leafHalfThickness, + y: planLeafEnd.y - ny * leafHalfThickness, + }, + { + x: planLeafEnd.x + nx * leafHalfThickness, + y: planLeafEnd.y + ny * leafHalfThickness, + }, + { + x: planLeafStart.x + nx * leafHalfThickness, + y: planLeafStart.y + ny * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' '), + closedLeafHintPoints: [ + { + x: planLeafStart.x - nx * leafHalfThickness * 0.7, + y: planLeafStart.y - ny * leafHalfThickness * 0.7, + }, + { + x: planArcEnd.x - nx * leafHalfThickness * 0.7, + y: planArcEnd.y - ny * leafHalfThickness * 0.7, + }, + { + x: planArcEnd.x + nx * leafHalfThickness * 0.7, + y: planArcEnd.y + ny * leafHalfThickness * 0.7, + }, + { + x: planLeafStart.x + nx * leafHalfThickness * 0.7, + y: planLeafStart.y + ny * leafHalfThickness * 0.7, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' '), + } + }) + : [] return ( - {swingSweepPath && ( - - )} - {swingAngle > 0.03 && ( - + {isFoldingDoor ? ( + <> + + {foldingPath && ( + + )} + {foldingPoints.map((point, index) => ( + + ))} + + ) : isPocketDoor ? ( + <> + + + + + + ) : isBarnDoor ? ( + <> + + + + {[0.28, 0.72].map((ratio) => { + const wheel = { + x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio, + y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio, + } + return ( + + ) + })} + + ) : isSlidingDoor ? ( + <> + + + + + + ) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? ( + <> + + + {isGarageRollupDoor ? ( + + ) : isGarageTiltupDoor ? ( + + ) : ( + garagePanelLines.map((line, index) => ( + + )) + )} + + ) : isDoubleSwingDoor ? ( + <> + {doubleLeafPlans.map((leaf) => + leaf.swingRadius > 1e-6 ? ( + + ) : null, + )} + {swingAngle > 0.03 && + doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + + ) : ( + <> + {swingSweepPath && ( + + )} + {swingAngle > 0.03 && ( + + )} + {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( + + ))} + + + + + )} - {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( - - ))} - - - - )} {isSelected ? ( @@ -7202,6 +7847,7 @@ export function FloorplanPanel() { ) const referenceWalls = children.filter((node): node is WallNode => 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', @@ -7305,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 ( @@ -7339,6 +8000,7 @@ export function FloorplanPanel() { return { ceilingPolygons, + columnEntries, fenceEntries, itemEntries, openingPolygons, diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/editor/src/components/tools/door/door-tool.tsx index 2f4b6e889..76da29e71 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/editor/src/components/tools/door/door-tool.tsx @@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => { parentId: event.node.id, width: draft.width, height: draft.height, + doorCategory: draft.doorCategory, + doorType: draft.doorType, + leafCount: draft.leafCount, + operationState: draft.operationState, + slideDirection: draft.slideDirection, + trackStyle: draft.trackStyle, + garagePanelCount: draft.garagePanelCount, frameThickness: draft.frameThickness, frameDepth: draft.frameDepth, threshold: draft.threshold, diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/editor/src/components/tools/door/move-door-tool.tsx index 799723690..ee1a80c3c 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/editor/src/components/tools/door/move-door-tool.tsx @@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } + const getPlacementOrientation = (event: WallEvent) => { + const faceSide = getSideFromNormal(event.normal) + const side = movingDoorNode.side ?? faceSide + const rotationOffset = side !== faceSide ? Math.PI : 0 + return { + side, + itemRotation: calculateItemRotation(event.normal) + rotationOffset, + cursorRotation: + calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset, + } + } + const onWallEnter = (event: WallEvent) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { @@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod } if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( @@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod } if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( @@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod if (isCurvedWall(event.node)) return if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) + const { side, itemRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( 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 16d784e49..a3509708e 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 fcf948f40..48e7d6386 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/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index fabd1c3ba..01d41bd2c 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -5,12 +5,14 @@ import { type AnyNodeId, DoorNode, emitter, + useInteractive, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' +import { BookMarked, Copy, DoorOpen, 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' @@ -22,6 +24,73 @@ import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' import { PresetsPopover } from './presets/presets-popover' +const doorTypeOptions = [ + { label: 'Hinged', value: 'hinged', available: true }, + { label: 'Double', value: 'double', available: true }, + { label: 'French', value: 'french', available: true }, + { label: 'Folding', value: 'folding', available: true }, + { label: 'Pocket', value: 'pocket', available: true }, + { label: 'Barn', value: 'barn', available: true }, + { label: 'Sliding', value: 'sliding', available: true }, +] satisfies { + label: string + value: DoorNode['doorType'] + available: boolean +}[] + +const garageDoorTypeOptions = [ + { label: 'Sectional', value: 'garage-sectional', available: true }, + { label: 'Roll-up', value: 'garage-rollup', available: true }, + { label: 'Tilt-up', value: 'garage-tiltup', available: true }, +] satisfies { + label: string + value: DoorNode['doorType'] + available: boolean +}[] + +const frenchDoorSegments: DoorNode['segments'] = [ + { + type: 'glass', + heightRatio: 0.76, + columnRatios: [1, 1], + dividerThickness: 0.025, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.24, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.012, + panelInset: 0.035, + }, +] + +const foldingDoorSegments: DoorNode['segments'] = [ + { + type: 'panel', + heightRatio: 1, + columnRatios: [1], + dividerThickness: 0.02, + panelDepth: 0.008, + panelInset: 0.025, + }, +] + +const defaultDoorDimensions: Record = { + hinged: { width: 0.9, height: 2.1 }, + double: { width: 1.5, height: 2.1 }, + french: { width: 1.5, height: 2.1 }, + folding: { width: 1.8, height: 2.1 }, + pocket: { width: 0.9, height: 2.1 }, + barn: { width: 1, height: 2.1 }, + sliding: { width: 1.5, height: 2.1 }, + 'garage-sectional': { width: 2.7, height: 2.4 }, + 'garage-rollup': { width: 2.7, height: 2.4 }, + 'garage-tiltup': { width: 2.7, height: 2.4 }, +} + function isSameDoorValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { return Math.abs(current - next) < 1e-6 @@ -64,6 +133,9 @@ export function DoorPanel() { }) if (!hasChange) return + if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) { + useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) + } updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, @@ -195,6 +267,13 @@ export function DoorPanel() { const getDoorPresetData = useCallback(() => { if (!node) return null return { + doorCategory: node.doorCategory, + doorType: node.doorType, + leafCount: node.leafCount, + operationState: node.operationState, + slideDirection: node.slideDirection, + trackStyle: node.trackStyle, + garagePanelCount: node.garagePanelCount, width: node.width, height: node.height, frameThickness: node.frameThickness, @@ -261,6 +340,16 @@ export function DoorPanel() { const archHeight = node.archHeight ?? 0.45 const openingRevealRadius = node.openingRevealRadius ?? 0.025 const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) + const doorType = node.doorType ?? 'hinged' + const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french' + const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding' + const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-') + const isSectionalGarageDoor = doorType === 'garage-sectional' + const isRollupGarageDoor = doorType === 'garage-rollup' + const isTiltupGarageDoor = doorType === 'garage-tiltup' + const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' + const supportsHandleSide = isSwingDoor + const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { const next = [...openingTopRadii] as [number, number] @@ -272,6 +361,150 @@ export function DoorPanel() { } } + const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial => { + const dimensions = defaultDoorDimensions[nextDoorType] + const dimensionUpdates = { + width: dimensions.width, + height: dimensions.height, + position: [node.position[0], dimensions.height / 2, node.position[2]] as DoorNode['position'], + } + + if (nextDoorType === 'double' || nextDoorType === 'french') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 2, + ...dimensionUpdates, + handleSide: 'right', + ...(nextDoorType === 'french' + ? { + contentPadding: [0.045, 0.055], + segments: frenchDoorSegments, + } + : {}), + } + } + + if (nextDoorType === 'folding') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 4, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + operationState: Math.max(node.operationState ?? 0, 0.65), + contentPadding: [0.03, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'pocket') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'pocket', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.035, 0.045], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'barn') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.035, 0.045], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'sliding') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 2, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.03, 0.04], + segments: frenchDoorSegments, + } + } + + if (nextDoorType === 'garage-sectional') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'garage-rollup') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: 4, + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'garage-tiltup') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: 4, + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + threshold: true, + } + } + return (
+ {!isOpening && ( +
+ {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => { + const isSelected = doorType === option.value + return ( + + ) + })} +
+ )} @@ -354,10 +621,100 @@ export function DoorPanel() { )} + {doorType === 'folding' && !isOpening && ( + +
+
+ + Panels + + handleUpdate({ leafCount: v === '2' ? 2 : 4 })} + options={[ + { label: '2', value: '2' }, + { label: '4', value: '4' }, + ]} + value={node.leafCount === 2 ? '2' : '4'} + /> +
+
+ handleUpdate({ operationState: v / 100 })} + precision={0} + restoreOnCommit={false} + step={5} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ )} + + {isSlidingDoor && !isOpening && ( + +
+
+ + {doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'} + + handleUpdate({ slideDirection: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.slideDirection ?? 'left'} + /> +
+
+ handleUpdate({ operationState: v / 100 })} + precision={0} + restoreOnCommit={false} + step={5} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ )} + + {(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && ( + + handleUpdate({ operationState: v / 100 })} + precision={0} + restoreOnCommit={false} + step={5} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> + {isSectionalGarageDoor && ( + handleUpdate({ garagePanelCount: Math.round(v) })} + precision={0} + restoreOnCommit={false} + step={1} + value={node.garagePanelCount ?? 4} + /> + )} + + )} + handleUpdate({ width: v })} precision={2} @@ -582,316 +939,334 @@ export function DoorPanel() { {!isOpening && ( <> - - handleUpdate({ frameThickness: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} - /> - handleUpdate({ frameDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameDepth * 1000) / 1000} - /> - - - - handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[0] * 1000) / 1000} - /> - handleUpdate({ contentPadding: [node.contentPadding[0], v] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[1] * 1000) / 1000} - /> - - - -
-
- - Hinges Side - - handleUpdate({ hingesSide: v })} - options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - ]} - value={node.hingesSide} - /> -
-
- - Direction - - handleUpdate({ swingDirection: v })} - options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, - ]} - value={node.swingDirection} - /> -
-
-
- - - handleUpdate({ threshold: checked })} - /> - {node.threshold && ( -
+ handleUpdate({ thresholdHeight: v })} + label="Thickness" + max={0.2} + min={0.01} + onChange={(v) => handleUpdate({ frameThickness: v })} precision={3} - step={0.005} + step={0.01} unit="m" - value={Math.round(node.thresholdHeight * 1000) / 1000} + value={Math.round(node.frameThickness * 1000) / 1000} /> -
- )} -
- - - handleUpdate({ handle: checked })} - /> - {node.handle && ( -
handleUpdate({ handleHeight: v })} - precision={2} - step={0.05} + label="Depth" + max={0.3} + min={0.01} + onChange={(v) => handleUpdate({ frameDepth: v })} + precision={3} + step={0.01} unit="m" - value={Math.round(node.handleHeight * 100) / 100} + value={Math.round(node.frameDepth * 1000) / 1000} /> + + + {!isGarageDoor && ( + + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[0] * 1000) / 1000} + /> + handleUpdate({ contentPadding: [node.contentPadding[0], v] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[1] * 1000) / 1000} + /> + + )} + + {isSwingDoor && ( + +
- Handle Side + Hinges Side handleUpdate({ handleSide: v })} + onChange={(v) => handleUpdate({ hingesSide: v })} options={[ { label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }, ]} - value={node.handleSide} + value={node.hingesSide} />
-
- )} -
- - - handleUpdate({ doorCloser: checked })} - /> - handleUpdate({ panicBar: checked })} - /> - {node.panicBar && ( -
- handleUpdate({ panicBarHeight: v })} - precision={2} - step={0.05} - unit="m" - value={Math.round(node.panicBarHeight * 100) / 100} - /> -
- )} -
- - - {node.segments.map((seg, i) => { - const numCols = seg.columnRatios.length - const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) - const normCols = seg.columnRatios.map((r) => r / colSum) - return ( -
-
- Segment {i + 1} -
- +
+ + Direction + { - const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s)) - handleUpdate({ segments: updated }) - }} + onChange={(v) => handleUpdate({ swingDirection: v })} options={[ - { label: 'Panel', value: 'panel' }, - { label: 'Glass', value: 'glass' }, - { label: 'Empty', value: 'empty' }, + { label: 'Inward', value: 'inward' }, + { label: 'Outward', value: 'outward' }, ]} - value={seg.type} + value={node.swingDirection} /> +
+
+
+ )} + {isSwingDoor && ( + + handleUpdate({ threshold: checked })} + /> + {node.threshold && ( +
setSegmentHeightRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(normHeights[i]! * 100 * 10) / 10} + max={0.1} + min={0.005} + onChange={(v) => handleUpdate({ thresholdHeight: v })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.thresholdHeight * 1000) / 1000} /> +
+ )} +
+ )} + {!isGarageDoor && ( + + {isSwingDoor && ( + handleUpdate({ handle: checked })} + /> + )} + {(node.handle || !isSwingDoor) && ( +
{ - const n = Math.max(1, Math.min(8, Math.round(v))) - const updated = node.segments.map((s, idx) => - idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s, - ) - handleUpdate({ segments: updated }) - }} - precision={0} - step={1} - value={numCols} + label="Height" + max={node.height - 0.1} + min={0.5} + onChange={(v) => handleUpdate({ handleHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(node.handleHeight * 100) / 100} /> - - {numCols > 1 && ( -
- {normCols.map((ratio, ci) => ( - setSegmentColumnRatio(i, ci, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} - { - const updated = node.segments.map((s, idx) => - idx === i ? { ...s, dividerThickness: v } : s, - ) - handleUpdate({ segments: updated }) - }} - precision={3} - step={0.005} - unit="m" - value={Math.round(seg.dividerThickness * 1000) / 1000} - /> -
- )} - - {seg.type === 'panel' && ( -
- { - const updated = node.segments.map((s, idx) => - idx === i ? { ...s, panelInset: v } : s, - ) - handleUpdate({ segments: updated }) - }} - precision={3} - step={0.005} - unit="m" - value={Math.round(seg.panelInset * 1000) / 1000} - /> - { - const updated = node.segments.map((s, idx) => - idx === i ? { ...s, panelDepth: v } : s, - ) - handleUpdate({ segments: updated }) - }} - precision={3} - step={0.005} - unit="m" - value={Math.round(seg.panelDepth * 1000) / 1000} + {supportsHandleSide && ( +
+ + Handle Side + + handleUpdate({ handleSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.handleSide} />
)}
- ) - })} + )} + + )} -
- { - const updated = [ - ...node.segments, - { - type: 'panel' as const, - heightRatio: 1, - columnRatios: [1], - dividerThickness: 0.03, - panelDepth: 0.01, - panelInset: 0.04, - }, - ] - handleUpdate({ segments: updated }) - }} + {isSwingDoor && ( + + handleUpdate({ doorCloser: checked })} + /> + handleUpdate({ panicBar: checked })} /> - {node.segments.length > 1 && ( + {node.panicBar && ( +
+ handleUpdate({ panicBarHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(node.panicBarHeight * 100) / 100} + /> +
+ )} +
+ )} + + {!isGarageDoor && ( + + {node.segments.map((seg, i) => { + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const normCols = seg.columnRatios.map((r) => r / colSum) + return ( +
+
+ Segment {i + 1} +
+ + { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, type: t } : s, + ) + handleUpdate({ segments: updated }) + }} + options={[ + { label: 'Panel', value: 'panel' }, + { label: 'Glass', value: 'glass' }, + { label: 'Empty', value: 'empty' }, + ]} + value={seg.type} + /> + + setSegmentHeightRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(normHeights[i]! * 100 * 10) / 10} + /> + + { + const n = Math.max(1, Math.min(8, Math.round(v))) + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={0} + step={1} + value={numCols} + /> + + {numCols > 1 && ( +
+ {normCols.map((ratio, ci) => ( + setSegmentColumnRatio(i, ci, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} + { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, dividerThickness: v } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={3} + step={0.005} + unit="m" + value={Math.round(seg.dividerThickness * 1000) / 1000} + /> +
+ )} + + {seg.type === 'panel' && ( +
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelInset: v } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={3} + step={0.005} + unit="m" + value={Math.round(seg.panelInset * 1000) / 1000} + /> + { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelDepth: v } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={3} + step={0.005} + unit="m" + value={Math.round(seg.panelDepth * 1000) / 1000} + /> +
+ )} +
+ ) + })} + +
handleUpdate({ segments: node.segments.slice(0, -1) })} + label="+ Add Segment" + onClick={() => { + const updated = [ + ...node.segments, + { + type: 'panel' as const, + heightRatio: 1, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + ] + handleUpdate({ segments: updated }) + }} /> - )} -
-
+ {node.segments.length > 1 && ( + handleUpdate({ segments: node.segments.slice(0, -1) })} + /> + )} +
+ + )} )} diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index db80f6f9f..32ee9d4d3 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,97 @@ 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 +609,7 @@ export function WindowPanel() { openingCornerRadii, cornerRadius: Math.min(cornerRadius, maxRoundedRadius), openingRevealRadius, + sill: false, } : {}), ...(value === 'arch' ? { archHeight } : {}), diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 04ea2bb64..6fb4f2e00 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Item Placement', shortcuts: [ - { keys: ['R'], action: 'Rotate item clockwise by 90 degrees' }, - { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' }, + { keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' }, + { keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' }, { keys: ['Shift'], action: 'Temporarily bypass placement validation constraints', diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index dd7623865..4e565c946 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,12 +1,12 @@ import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' 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' -const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 - // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -147,20 +147,30 @@ 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]!] if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - const currentSwingAngle = node.swingAngle ?? 0 - useScene.getState().updateNode(node.id, { - swingAngle: - currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) + 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 @@ -184,9 +194,23 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - useScene.getState().updateNode(node.id, { swingAngle: 0 }) + 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/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts new file mode 100644 index 000000000..7d9efc48c --- /dev/null +++ b/packages/editor/src/lib/door-interaction.ts @@ -0,0 +1,88 @@ +import { + type AnyNodeId, + type DoorInteractiveState, + isOperationDoorType, + useInteractive, + useScene, +} from '@pascal-app/core' + +export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 +export const DOOR_TOGGLE_ANIMATION_MS = 520 + +export { isOperationDoorType } + +type DoorOpenAnimationOptions = { + persist?: boolean +} + +function getDisplayedDoorValue( + doorId: AnyNodeId, + field: keyof DoorInteractiveState, + nodeValue: number | undefined, +) { + const interactive = useInteractive.getState() + const runtimeValue = interactive.doors[doorId]?.[field] + if (runtimeValue !== undefined) return runtimeValue + + const queuedValue = interactive.doorAnimations[doorId]?.from + if (queuedValue !== undefined) return queuedValue + + return nodeValue ?? 0 +} + +function startDoorOpenAnimation( + doorId: AnyNodeId, + field: keyof DoorInteractiveState, + from: number, + to: number, + options?: DoorOpenAnimationOptions, +) { + useInteractive.getState().startDoorAnimation(doorId, { + field, + from, + to, + startedAt: null, + durationMs: DOOR_TOGGLE_ANIMATION_MS, + persist: options?.persist ?? true, + }) +} + +export function toggleDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) { + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + if (isOperationDoorType(node.doorType)) { + const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState) + startDoorOpenAnimation( + doorId, + 'operationState', + currentOpenAmount, + currentOpenAmount >= 0.5 ? 0 : 1, + options, + ) + return + } + + const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) + startDoorOpenAnimation( + doorId, + 'swingAngle', + currentSwingAngle, + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + options, + ) +} + +export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) { + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + if (isOperationDoorType(node.doorType)) { + const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState) + startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options) + return + } + + const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) + startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options) +} diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts new file mode 100644 index 000000000..64c4ce993 --- /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 +} + +export 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 192e3c93a..bc2b5b85b 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import useViewer from '../../store/use-viewer' import { CeilingSystem } from '../../systems/ceiling/ceiling-system' +import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' @@ -18,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' @@ -225,6 +227,8 @@ const Viewer: React.FC = ({ {/* Core systems */} + + diff --git a/packages/viewer/src/systems/door/door-animation-system.tsx b/packages/viewer/src/systems/door/door-animation-system.tsx new file mode 100644 index 000000000..2c2f380ac --- /dev/null +++ b/packages/viewer/src/systems/door/door-animation-system.tsx @@ -0,0 +1,59 @@ +import { type AnyNodeId, type DoorNode, emitter, useInteractive, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' + +const easeDoorAnimation = (value: number) => value * value * (3 - 2 * value) + +function markDoorDirty(doorId: AnyNodeId) { + const scene = useScene.getState() + const node = scene.nodes[doorId] + scene.dirtyNodes.add(doorId) + if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) +} + +export const DoorAnimationSystem = () => { + useFrame(({ clock }) => { + const interactive = useInteractive.getState() + const entries = Object.entries(interactive.doorAnimations) + if (entries.length === 0) return + + const now = clock.getElapsedTime() * 1000 + + for (const [doorId, animation] of entries) { + const typedDoorId = doorId as AnyNodeId + const scene = useScene.getState() + const node = scene.nodes[typedDoorId] + if (node?.type !== 'door') { + interactive.cancelDoorAnimation(typedDoorId) + interactive.removeDoorOpenState(typedDoorId) + continue + } + + const startedAt = animation.startedAt ?? now + if (animation.startedAt === null) { + interactive.startDoorAnimation(typedDoorId, { ...animation, startedAt }) + } + + const progress = Math.min(1, (now - startedAt) / animation.durationMs) + const value = animation.from + (animation.to - animation.from) * easeDoorAnimation(progress) + interactive.setDoorOpenState(typedDoorId, { [animation.field]: value }) + markDoorDirty(typedDoorId) + + if (progress < 1) continue + + interactive.cancelDoorAnimation(typedDoorId) + if (animation.persist) { + scene.updateNode(typedDoorId, { [animation.field]: animation.to }) + interactive.removeDoorOpenState(typedDoorId) + markDoorDirty(typedDoorId) + } else { + interactive.setDoorOpenState(typedDoorId, { [animation.field]: animation.to }) + } + emitter.emit('door:animation-completed', { + doorId: typedDoorId as DoorNode['id'], + field: animation.field, + }) + } + }, 2) + + return null +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 6e87ca9ee..f19c0de5c 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,10 +1,19 @@ -import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + clampDoorOperationState, + type AnyNodeId, + type DoorNode, + getDoorRenderOpenAmount, + sceneRegistry, + useInteractive, + useScene, +} 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 }) +const revealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) export const DoorSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) @@ -50,6 +59,40 @@ function addBox( parent.add(m) } +function addRotatedBox( + parent: THREE.Object3D, + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + rotationY: number, +) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + m.position.set(x, y, z) + m.rotation.y = rotationY + parent.add(m) +} + +function addBoxWithRotation( + parent: THREE.Object3D, + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + rotation: [number, number, number], +) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + m.position.set(x, y, z) + m.rotation.set(rotation[0], rotation[1], rotation[2]) + parent.add(m) +} + function addShape( parent: THREE.Object3D, material: THREE.Material, @@ -350,6 +393,1014 @@ function disposeObject(object: THREE.Object3D) { }) } +function addLeafSegmentContent({ + addLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty = false, +}: { + addLeafBox: ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => void + leafWidth: number + leafHeight: number + leafCenterX: number + leafCenterY: number + leafDepth: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + keepFrameWhenEmpty?: boolean +}) { + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty + const cpX = contentPadding[0] + const cpY = contentPadding[1] + if (shouldRenderFrame && cpY > 0) { + addLeafBox( + baseMaterial, + leafWidth, + cpY, + leafDepth, + leafCenterX, + leafCenterY + leafHeight / 2 - cpY / 2, + 0, + ) + addLeafBox( + baseMaterial, + leafWidth, + cpY, + leafDepth, + leafCenterX, + leafCenterY - leafHeight / 2 + cpY / 2, + 0, + ) + } + if (shouldRenderFrame && cpX > 0) { + const innerH = leafHeight - 2 * cpY + addLeafBox( + baseMaterial, + cpX, + innerH, + leafDepth, + leafCenterX - leafWidth / 2 + cpX / 2, + leafCenterY, + 0, + ) + addLeafBox( + baseMaterial, + cpX, + innerH, + leafDepth, + leafCenterX + leafWidth / 2 - cpX / 2, + leafCenterY, + 0, + ) + } + + const contentW = leafWidth - 2 * cpX + const contentH = leafHeight - 2 * cpY + const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) + const contentTop = leafCenterY + contentH / 2 + + let segY = contentTop + for (const seg of segments) { + const segH = (seg.heightRatio / totalRatio) * contentH + const segCenterY = segY - segH / 2 + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const usableW = contentW - (numCols - 1) * seg.dividerThickness + const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW) + + const colXCenters: number[] = [] + let cx = leafCenterX - contentW / 2 + for (let c = 0; c < numCols; c++) { + colXCenters.push(cx + colWidths[c]! / 2) + cx += colWidths[c]! + if (c < numCols - 1) cx += seg.dividerThickness + } + + if (seg.type !== 'empty') { + cx = leafCenterX - contentW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + cx += seg.dividerThickness + } + } + + for (let c = 0; c < numCols; c++) { + const colW = colWidths[c]! + const colX = colXCenters[c]! + + if (seg.type === 'glass') { + const glassDepth = Math.max(0.004, leafDepth * 0.15) + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } else if (seg.type === 'panel') { + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + const panelW = colW - 2 * seg.panelInset + const panelH = segH - 2 * seg.panelInset + if (panelW > 0.01 && panelH > 0.01) { + const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) + const panelZ = leafDepth / 2 + effectiveDepth / 2 + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } + } + } + + segY -= segH + } +} + +function addDoorLeaf( + mesh: THREE.Mesh, + { + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + hingeX, + hingeSide, + swingRotation, + segments, + contentPadding, + handle, + handleBothSides = false, + handleHeight, + handleSide, + doorCloser, + panicBar, + panicBarHeight, + doorHeight, + }: { + leafWidth: number + leafHeight: number + leafCenterX: number + leafCenterY: number + leafDepth: number + hingeX: number + hingeSide: 'left' | 'right' + swingRotation: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + handle: boolean + handleBothSides?: boolean + handleHeight: number + handleSide: DoorNode['handleSide'] + doorCloser: boolean + panicBar: boolean + panicBarHeight: number + doorHeight: number + }, +) { + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const leafGroup = new THREE.Group() + leafGroup.position.set(hingeX, 0, 0) + leafGroup.rotation.y = swingRotation + mesh.add(leafGroup) + + const addLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + + addLeafSegmentContent({ + addLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + }) + + if (hasLeafContent && handle) { + const handleY = handleHeight - doorHeight / 2 + const faceZ = leafDepth / 2 + const handleX = + handleSide === 'right' + ? leafCenterX + leafWidth / 2 - 0.045 + : leafCenterX - leafWidth / 2 + 0.045 + + addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + + if (handleBothSides) { + addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) + addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) + } + } + + if (hasLeafContent && doorCloser) { + const closerY = leafCenterY + leafHeight / 2 - 0.04 + addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) + addLeafBox( + baseMaterial, + 0.14, + 0.015, + 0.015, + leafCenterX + leafWidth / 4, + closerY + 0.025, + leafDepth / 2 + 0.015, + ) + } + + if (hasLeafContent && panicBar) { + const barY = panicBarHeight - doorHeight / 2 + addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03) + } + + if (hasLeafContent) { + const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 + const hingeH = 0.1 + const hingeW = 0.024 + const hingeD = leafDepth + 0.016 + const leafBottom = leafCenterY - leafHeight / 2 + const leafTop = leafCenterY + leafHeight / 2 + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + } +} + +function addFoldingDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + leafCount, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + leafCount: DoorNode['leafCount'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const panelCount = leafCount === 2 ? 2 : 4 + const foldAmount = clampDoorOperationState(operationState) + const panelLength = insideWidth / panelCount + const foldAngle = Math.PI * 0.44 * foldAmount + + addBox( + mesh, + baseMaterial, + insideWidth, + Math.min(frameThickness * 0.5, 0.025), + Math.max(frameDepth * 0.45, 0.035), + 0, + leafCenterY + leafHeight / 2 - 0.018, + 0, + ) + + const vertices: Array<{ x: number; z: number }> = [{ x: -insideWidth / 2, z: 0 }] + for (let index = 0; index < panelCount; index++) { + const previous = vertices[index]! + const direction = index % 2 === 0 ? -1 : 1 + const angle = direction * foldAngle + vertices.push({ + x: previous.x + panelLength * Math.cos(angle), + z: previous.z + panelLength * Math.sin(angle), + }) + } + + for (let index = 0; index < panelCount; index++) { + const start = vertices[index]! + const end = vertices[index + 1]! + const dx = end.x - start.x + const dz = end.z - start.z + const centerX = (start.x + end.x) / 2 + const centerZ = (start.z + end.z) / 2 + const rotationY = Math.atan2(-dz, dx) + const localX = { + x: Math.cos(rotationY), + z: -Math.sin(rotationY), + } + + const addFoldingLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => { + addRotatedBox( + mesh, + material, + w, + h, + d, + centerX + localX.x * x + Math.sin(rotationY) * z, + y, + centerZ + localX.z * x + Math.cos(rotationY) * z, + rotationY, + ) + } + + addLeafSegmentContent({ + addLeafBox: addFoldingLeafBox, + leafWidth: Math.max(0.08, panelLength), + leafHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + + for (const point of [start, end]) { + addBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.92, + leafDepth + 0.016, + point.x, + leafCenterY, + point.z, + ) + } + } + + const handlePoint = vertices[vertices.length - 1]! + const handleY = handleHeight - doorHeight / 2 + addBox( + mesh, + baseMaterial, + 0.035, + 0.16, + leafDepth + 0.035, + handlePoint.x - 0.035, + handleY, + handlePoint.z + 0.045, + ) + addBox( + mesh, + baseMaterial, + 0.035, + 0.16, + leafDepth + 0.035, + handlePoint.x - 0.035, + handleY, + handlePoint.z - 0.045, + ) +} + +function addPocketDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = clampDoorOperationState(operationState) + const slideSign = slideDirection === 'right' ? 1 : -1 + const leafWidth = insideWidth + const leafCenterX = slideSign * insideWidth * openAmount + const topY = leafCenterY + leafHeight / 2 + const pocketCenterX = slideSign * insideWidth + const handleY = handleHeight - doorHeight / 2 + const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + + addBox( + mesh, + baseMaterial, + insideWidth * 2, + Math.min(frameThickness * 0.45, 0.024), + Math.max(frameDepth * 0.38, 0.03), + slideSign * (insideWidth / 2), + topY - 0.018, + 0, + ) + addBox( + mesh, + revealMaterial, + insideWidth * 0.9, + 0.018, + Math.max(frameDepth * 0.32, 0.026), + pocketCenterX, + topY - 0.055, + 0, + ) + addBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.94, + leafDepth + 0.014, + slideSign * insideWidth * 0.5, + leafCenterY, + 0, + ) + + const addPocketLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x, y, z) + + addLeafSegmentContent({ + addLeafBox: addPocketLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + }) + addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) + addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02) +} + +function addBarnDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = clampDoorOperationState(operationState) + const slideSign = slideDirection === 'right' ? 1 : -1 + const leafWidth = insideWidth * 1.06 + const leafCenterX = slideSign * insideWidth * openAmount + const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028 + const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045) + const railLength = insideWidth * 2.25 + const railCenterX = slideSign * (insideWidth * 0.56) + const handleY = handleHeight - doorHeight / 2 + const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) + const wheelY = trackY - 0.075 + + addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) + addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) + addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + + const addBarnLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x, y, faceZ + z) + + addLeafSegmentContent({ + addLeafBox: addBarnLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + + addRotatedBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.86, + 0.012, + leafCenterX, + leafCenterY, + faceZ + leafDepth / 2 + 0.014, + -0.52, + ) + addRotatedBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.86, + 0.012, + leafCenterX, + leafCenterY, + faceZ + leafDepth / 2 + 0.014, + 0.52, + ) + + for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { + addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) + addBox( + mesh, + revealMaterial, + 0.026, + 0.16, + 0.026, + leafCenterX + offset, + wheelY - 0.075, + faceZ + 0.022, + ) + } + + addBox( + mesh, + baseMaterial, + 0.032, + 0.22, + leafDepth + 0.034, + handleX, + handleY, + faceZ + leafDepth / 2 + 0.02, + ) + addBox( + mesh, + baseMaterial, + 0.032, + 0.22, + leafDepth + 0.034, + handleX, + handleY, + faceZ - leafDepth / 2 - 0.02, + ) +} + +function addSlidingDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = clampDoorOperationState(operationState) + const activeOnRight = slideDirection === 'left' + const fixedSign = activeOnRight ? -1 : 1 + const activeSign = activeOnRight ? 1 : -1 + const panelWidth = insideWidth * 0.54 + const panelHeight = leafHeight + const closedActiveX = activeSign * insideWidth * 0.23 + const fixedX = fixedSign * insideWidth * 0.23 + const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount + const frontZ = leafDepth / 2 + 0.016 + const backZ = -leafDepth / 2 - 0.006 + const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02) + const handleY = handleHeight - doorHeight / 2 + const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) + + addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0) + addBox( + mesh, + revealMaterial, + insideWidth, + 0.018, + Math.max(frameDepth * 0.28, 0.022), + 0, + -leafHeight / 2 + 0.04, + 0, + ) + + const addFixedPanelBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ) + + const addActivePanelBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x + activeX, y, z + frontZ) + + addLeafSegmentContent({ + addLeafBox: addFixedPanelBox, + leafWidth: panelWidth, + leafHeight: panelHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + addLeafSegmentContent({ + addLeafBox: addActivePanelBox, + leafWidth: panelWidth, + leafHeight: panelHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01) + addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01) +} + +function addGarageSectionalDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + garagePanelCount, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + garagePanelCount: number + }, +) { + const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState) + const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount))) + const panelHeight = leafHeight / panelCount + const panelGap = Math.min(0.012, panelHeight * 0.08) + const travelDepth = Math.max(leafHeight, 1.4) + const curveRadius = panelHeight * 0.58 + const curveLength = (Math.PI / 2) * curveRadius + const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65) + const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2 + const railY = leafCenterY + leafHeight / 2 - 0.04 + const railZ = -travelDepth / 2 + + addBox( + mesh, + revealMaterial, + 0.035, + Math.max(0.04, frameThickness * 0.75), + travelDepth, + -insideWidth / 2 + 0.035, + railY, + railZ, + ) + addBox( + mesh, + revealMaterial, + 0.035, + Math.max(0.04, frameThickness * 0.75), + travelDepth, + insideWidth / 2 - 0.035, + railY, + railZ, + ) + + for (let index = 0; index < panelCount; index++) { + const orderFromTop = panelCount - 1 - index + const pathPosition = travel - orderFromTop * panelHeight + let y = overheadY + pathPosition + let z = 0 + let rotationX = 0 + + if (pathPosition > 0 && pathPosition <= curveLength) { + const theta = pathPosition / curveRadius + rotationX = -theta + y = overheadY + curveRadius * Math.sin(theta) + z = -curveRadius * (1 - Math.cos(theta)) + } else if (pathPosition > curveLength) { + rotationX = -Math.PI / 2 + y = overheadY + curveRadius + z = -(curveRadius + pathPosition - curveLength) + } + + const revealOffset = (panelHeight - panelGap) * 0.22 + const trimDepth = 0.01 + const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 + const addSectionalTrim = (localY: number) => { + addBoxWithRotation( + mesh, + revealMaterial, + insideWidth - 0.16, + 0.012, + trimDepth, + 0, + y + localY * Math.cos(rotationX) - trimFaceOffset * Math.sin(rotationX), + z + localY * Math.sin(rotationX) + trimFaceOffset * Math.cos(rotationX), + [rotationX, 0, 0], + ) + } + + addBoxWithRotation( + mesh, + baseMaterial, + insideWidth, + Math.max(0.04, panelHeight - panelGap), + leafDepth, + 0, + y, + z, + [rotationX, 0, 0], + ) + addSectionalTrim(revealOffset) + addSectionalTrim(-revealOffset) + } + + addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) +} + +function addGarageRollupDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + }, +) { + const openAmount = clampDoorOperationState(operationState) + const slatHeight = Math.max(0.055, Math.min(0.11, leafHeight / 22)) + const visibleHeight = leafHeight * (1 - openAmount) + const visibleSlatCount = Math.ceil(visibleHeight / slatHeight) + const topY = leafCenterY + leafHeight / 2 + const curtainCenterY = topY - visibleHeight / 2 + const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075)) + const drumY = topY + drumMaxRadius * 0.12 + const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 + + addBox( + mesh, + revealMaterial, + 0.032, + leafHeight, + Math.max(frameDepth * 0.48, 0.035), + -insideWidth / 2 + 0.03, + leafCenterY, + 0, + ) + addBox( + mesh, + revealMaterial, + 0.032, + leafHeight, + Math.max(frameDepth * 0.48, 0.035), + insideWidth / 2 - 0.03, + leafCenterY, + 0, + ) + + if (visibleHeight > 0.01) { + addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) + + for (let index = 0; index < visibleSlatCount; index++) { + const y = topY - Math.min(visibleHeight, index * slatHeight) + addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) + } + + addBox( + mesh, + revealMaterial, + insideWidth - 0.04, + 0.028, + leafDepth + 0.018, + 0, + topY - visibleHeight, + leafDepth / 2 + 0.004, + ) + } + + const drum = new THREE.Mesh( + new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), + baseMaterial, + ) + drum.position.set(0, drumY, drumZ) + drum.rotation.z = Math.PI / 2 + mesh.add(drum) + + addBox( + mesh, + revealMaterial, + insideWidth + frameThickness, + 0.026, + Math.max(frameDepth * 0.52, 0.04), + 0, + topY + 0.02, + 0, + ) +} + +function addGarageTiltupDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + }, +) { + const openAmount = clampDoorOperationState(operationState) + const angle = (Math.PI / 2) * openAmount + const hingeY = leafCenterY + leafHeight / 2 + const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2) + const panelCenterZ = -Math.sin(angle) * (leafHeight / 2) + const railLength = Math.max(leafHeight * 0.72, 1.2) + const railY = hingeY - frameThickness * 0.35 + const railZ = -railLength / 2 + + addBox( + mesh, + revealMaterial, + 0.03, + Math.max(frameThickness * 0.7, 0.035), + railLength, + -insideWidth / 2 + 0.04, + railY, + railZ, + ) + addBox( + mesh, + revealMaterial, + 0.03, + Math.max(frameThickness * 0.7, 0.035), + railLength, + insideWidth / 2 - 0.04, + railY, + railZ, + ) + + addBoxWithRotation( + mesh, + baseMaterial, + insideWidth, + leafHeight, + leafDepth, + 0, + panelCenterY, + panelCenterZ, + [-angle, 0, 0], + ) + + const insetWidth = Math.max(0.1, insideWidth - 0.22) + const insetHeight = Math.max(0.1, leafHeight - 0.28) + const trimDepth = 0.012 + const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 + const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { + addBoxWithRotation( + mesh, + revealMaterial, + trimWidth, + trimHeight, + trimDepth, + localX, + panelCenterY + localY * Math.cos(angle) + trimFaceOffset * Math.sin(angle), + panelCenterZ - localY * Math.sin(angle) + trimFaceOffset * Math.cos(angle), + [-angle, 0, 0], + ) + } + + addTiltupTrim(0, insetHeight / 2, insetWidth, 0.018) + addTiltupTrim(0, -insetHeight / 2, insetWidth, 0.018) + addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight) + addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight) + + addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -386,9 +1437,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { contentPadding, hingesSide, swingDirection, - swingAngle = 0, + swingAngle: nodeSwingAngle = 0, + doorType = 'hinged', + operationState: nodeOperationState = 0, + leafCount = 1, + slideDirection = 'left', + garagePanelCount = 4, } = node - const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const runtimeDoorState = useInteractive.getState().doors[node.id] + const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle + const operationState = runtimeDoorState?.operationState ?? nodeOperationState const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle)) if (openingKind === 'opening') { @@ -396,39 +1454,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { return } - // Leaf occupies the full opening (no bottom frame bar — door opens to floor) - const leafW = width - 2 * frameThickness + const insideWidth = width - 2 * frameThickness const leafH = height - frameThickness // only top frame const leafDepth = 0.04 - // Leaf center is shifted down from door center by half the top frame const leafCenterY = -frameThickness / 2 - const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2 const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 - const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 - const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - const leafGroup = new THREE.Group() - leafGroup.position.set(hingeX, 0, 0) - leafGroup.rotation.y = leafSwingRotation - mesh.add(leafGroup) - const addLeafBox = ( - material: THREE.Material, - w: number, - h: number, - d: number, - x: number, - y: number, - z: number, - ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) - const addLeafShape = (shape: THREE.Shape, material: THREE.Material, depth: number, z = 0) => { - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - bevelEnabled: false, - curveSegments: 24, - }) - geometry.translate(-hingeX, 0, -depth / 2 + z) - const leafMesh = new THREE.Mesh(geometry, material) - leafGroup.add(leafMesh) - } // ── Frame members ── if (openingShape === 'arch') { @@ -530,7 +1560,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { addBox( mesh, baseMaterial, - leafW, + insideWidth, thresholdHeight, frameDepth, 0, @@ -539,307 +1569,162 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - const usesShapedLeaf = openingShape === 'arch' || openingShape === 'rounded' - const leafBottom = leafCenterY - leafH / 2 - const leafTop = leafCenterY + leafH / 2 - const leafArchHeight = getClampedArchHeight( - leafW, - leafH, - Math.max((node.archHeight ?? leafW / 2) - frameThickness, 0.01), - ) - const leafArchSpringY = leafTop - leafArchHeight - const frameRadii = getDoorTopRadii(node, width, height) - const leafTopRadii = normalizeTopCornerRadii( - { - topLeft: Math.max(frameRadii.topLeft - frameThickness, 0), - topRight: Math.max(frameRadii.topRight - frameThickness, 0), - }, - leafW, - leafH, - ) - const cpX = contentPadding[0] - const cpY = contentPadding[1] - const useShallowLeafHeadBar = openingShape === 'arch' && cpY > 0 && leafArchHeight <= cpY * 2 - const shallowLeafHeadBottomY = leafArchSpringY - cpY - const getLeafBoundaryY = (x: number) => { - if (openingShape === 'arch') { - if (useShallowLeafHeadBar) return shallowLeafHeadBottomY - - const innerTop = leafTop - cpY - const innerSpringY = Math.min(Math.max(leafArchSpringY + cpY, leafBottom + cpY), innerTop) - const innerArchHeight = Math.max(innerTop - innerSpringY, 0.001) - const halfContentW = Math.max((leafW - 2 * cpX) / 2, 0.001) - const outerBoundaryY = getArchBoundaryY(x, leafW / 2, leafArchSpringY, leafArchHeight) - return Math.min( - getArchBoundaryY(x, halfContentW, innerSpringY, innerArchHeight), - outerBoundaryY - 0.001, - ) - } - - if (openingShape === 'rounded') { - const left = -leafW / 2 + cpX - const right = leafW / 2 - cpX - const top = leafTop - cpY - const innerRadii = normalizeTopCornerRadii( - { - topLeft: Math.max(leafTopRadii.topLeft - Math.max(cpX, cpY), 0), - topRight: Math.max(leafTopRadii.topRight - Math.max(cpX, cpY), 0), - }, - right - left, - top - (leafBottom + cpY), - ) - - if (innerRadii.topLeft > 1e-6 && x < left + innerRadii.topLeft) { - const centerX = left + innerRadii.topLeft - const centerY = top - innerRadii.topLeft - const dx = x - centerX - return centerY + Math.sqrt(Math.max(innerRadii.topLeft * innerRadii.topLeft - dx * dx, 0)) - } - - if (innerRadii.topRight > 1e-6 && x > right - innerRadii.topRight) { - const centerX = right - innerRadii.topRight - const centerY = top - innerRadii.topRight - const dx = x - centerX - return centerY + Math.sqrt(Math.max(innerRadii.topRight * innerRadii.topRight - dx * dx, 0)) - } - - return top - } - - return leafTop - } - const createLeafCellShape = (left: number, right: number, bottom: number, top: number) => - createTopClippedRectShape(left, right, bottom, top, getLeafBoundaryY) - - // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── - if (hasLeafContent && openingShape === 'arch') { - const leafInnerTopY = leafTop - cpY - const leafInnerSpringY = Math.min( - Math.max(leafArchSpringY + cpY, leafBottom + cpY), - leafInnerTopY, - ) - const sideBottom = leafBottom + cpY - const sideTop = useShallowLeafHeadBar ? shallowLeafHeadBottomY : leafArchSpringY - const sideHeight = Math.max(sideTop - sideBottom, 0) - - if (cpY > 0) { - addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafBottom + cpY / 2, 0) - } - if (cpX > 0 && sideHeight > 0.01) { - addLeafBox( - baseMaterial, - cpX, - sideHeight, - leafDepth, - -leafW / 2 + cpX / 2, - sideBottom + sideHeight / 2, - 0, - ) - addLeafBox( - baseMaterial, - cpX, - sideHeight, - leafDepth, - leafW / 2 - cpX / 2, - sideBottom + sideHeight / 2, - 0, - ) - } - addLeafShape( - useShallowLeafHeadBar - ? createArchHeadBarShape(leafW, shallowLeafHeadBottomY, leafArchSpringY, leafTop) - : createArchBandShape( - leafW, - leafArchSpringY, - leafTop, - leafInnerSpringY, - leafInnerTopY, - cpX, - ), - baseMaterial, + if (doorType === 'garage-sectional') { + addGarageSectionalDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, leafDepth, - ) - } else if (hasLeafContent && openingShape === 'rounded') { - addLeafShape( - createRoundedLeafFrameShape(leafW, leafBottom, leafTop, leafTopRadii, cpX, cpY), - baseMaterial, + frameThickness, + frameDepth, + operationState, + garagePanelCount, + }) + } else if (doorType === 'garage-rollup') { + addGarageRollupDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, leafDepth, - ) - } else if (hasLeafContent && cpY > 0) { - // Top strip - addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) - // Bottom strip - addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) - } - if (hasLeafContent && !usesShapedLeaf && cpX > 0) { - const innerH = leafH - 2 * cpY - // Left strip - addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) - // Right strip - addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) - } - - // Content area inside padding - const contentW = leafW - 2 * cpX - const contentH = leafH - 2 * cpY - - // ── Segments (stacked top to bottom within content area) ── - const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) - const contentTop = leafCenterY + contentH / 2 - - let segY = contentTop - for (let segIndex = 0; segIndex < segments.length; segIndex += 1) { - const seg = segments[segIndex]! - const segH = (seg.heightRatio / totalRatio) * contentH - const segCenterY = segY - segH / 2 - const segTop = segY - const segBottom = segY - segH - - const numCols = seg.columnRatios.length - const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) - const usableW = contentW - (numCols - 1) * seg.dividerThickness - const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW) - - // Column x-centers (relative to mesh center) - const colXCenters: number[] = [] - let cx = -contentW / 2 - for (let c = 0; c < numCols; c++) { - colXCenters.push(cx + colWidths[c]! / 2) - cx += colWidths[c]! - if (c < numCols - 1) cx += seg.dividerThickness - } - - // Column dividers within this segment - if (seg.type !== 'empty') { - cx = -contentW / 2 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! - if (usesShapedLeaf) { - const dividerLeft = cx - const dividerRight = cx + seg.dividerThickness - const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop) - if (dividerShape) { - addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) - } - } else { - addLeafBox( - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) - } - cx += seg.dividerThickness - } - } - - // Segment content per column - for (let c = 0; c < numCols; c++) { - const colW = colWidths[c]! - const colX = colXCenters[c]! - const cellLeft = colX - colW / 2 - const cellRight = colX + colW / 2 - - if (seg.type === 'glass') { - const glassDepth = Math.max(0.004, leafDepth * 0.15) - if (usesShapedLeaf) { - const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) - if (shape) - addLeafShape(shape, glassMaterial, glassDepth, leafDepth / 2 + glassDepth / 2 + 0.004) - } else { - // Glass only — no opaque backing so it's truly transparent - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) - } - } else if (seg.type === 'panel') { - if (usesShapedLeaf) { - const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) - if (shape) addLeafShape(shape, baseMaterial, leafDepth) - } else { - // Opaque leaf backing for this column - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) - } - // Raised panel detail - const panelW = colW - 2 * seg.panelInset - const panelH = segH - 2 * seg.panelInset - if (panelW > 0.01 && panelH > 0.01) { - const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) - const panelZ = leafDepth / 2 + effectiveDepth / 2 - if (usesShapedLeaf) { - const shape = createLeafCellShape( - colX - panelW / 2, - colX + panelW / 2, - segCenterY - panelH / 2, - segCenterY + panelH / 2, - ) - if (shape) addLeafShape(shape, baseMaterial, effectiveDepth, panelZ) - } else { - addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) - } - } - } else { - // 'empty' leaves the opening unfilled - } - } - - if (usesShapedLeaf && segIndex < segments.length - 1) { - const railThickness = Math.min(Math.max(cpY, 0.02), Math.max(segH * 0.35, 0.02)) - const railShape = createLeafCellShape( - -contentW / 2, - contentW / 2, - segBottom - railThickness / 2, - segBottom + railThickness / 2, - ) - if (railShape) addLeafShape(railShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) - } - - segY -= segH - } - - // ── Handle ── - if (hasLeafContent && handle) { - // Convert from floor-based height to mesh-center-based Y - const handleY = handleHeight - height / 2 - // Handle grip sits on the front face (+Z) of the leaf - const faceZ = leafDepth / 2 - - // X position: handleSide refers to which side the grip is on - const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045 - - // Backplate - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - // Grip lever - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) - } - - // ── Door closer (commercial hardware at top) ── - if (hasLeafContent && doorCloser) { - const closerY = leafCenterY + leafH / 2 - 0.04 - // Body - addLeafBox(baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) - // Arm (simplified as thin bar to frame side) - addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015) - } - - // ── Panic bar ── - if (hasLeafContent && panicBar) { - const barY = panicBarHeight - height / 2 - addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) - } - - // ── Hinges (3 knuckle-style hinges on the hinge side) ── - if (hasLeafContent) { - const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 - const hingeZ = 0 // centered in leaf depth - const hingeH = 0.1 - const hingeW = 0.024 - const hingeD = leafDepth + 0.016 - // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) + frameThickness, + frameDepth, + operationState, + }) + } else if (doorType === 'garage-tiltup') { + addGarageTiltupDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + }) + } else if (doorType === 'folding') { + addFoldingDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + leafCount, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'pocket') { + addPocketDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'barn') { + addBarnDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'sliding') { + addSlidingDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'double' || doorType === 'french') { + const doubleLeafW = insideWidth / 2 + addDoorLeaf(mesh, { + leafWidth: doubleLeafW, + leafHeight: leafH, + leafCenterX: -insideWidth / 4, + leafCenterY, + leafDepth, + hingeX: -insideWidth / 2, + hingeSide: 'left', + swingRotation: -clampedSwingAngle * swingDirectionSign, + segments, + contentPadding, + handle, + handleBothSides: doorType === 'double' || doorType === 'french', + handleHeight, + handleSide: 'right', + doorCloser, + panicBar, + panicBarHeight, + doorHeight: height, + }) + addDoorLeaf(mesh, { + leafWidth: doubleLeafW, + leafHeight: leafH, + leafCenterX: insideWidth / 4, + leafCenterY, + leafDepth, + hingeX: insideWidth / 2, + hingeSide: 'right', + swingRotation: clampedSwingAngle * swingDirectionSign, + segments, + contentPadding, + handle, + handleBothSides: doorType === 'double' || doorType === 'french', + handleHeight, + handleSide: 'left', + doorCloser: false, + panicBar, + panicBarHeight, + doorHeight: height, + }) + } else { + const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2 + const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 + addDoorLeaf(mesh, { + leafWidth: insideWidth, + leafHeight: leafH, + leafCenterX: 0, + leafCenterY, + leafDepth, + hingeX, + hingeSide: hingesSide, + swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign, + segments, + contentPadding, + handle, + handleBothSides: doorType === 'hinged', + handleHeight, + handleSide, + doorCloser, + panicBar, + panicBarHeight, + doorHeight: height, + }) } syncDoorCutout(node, mesh) 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 000000000..bf6dbfd90 --- /dev/null +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -0,0 +1,170 @@ +import { + type AnyNodeId, + emitter, + sceneRegistry, + useInteractive, + useScene, + type WindowNode, +} from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { + AWNING_WINDOW_SASH_NAME, + CASEMENT_WINDOW_SASH_NAME, + DOUBLE_HUNG_BOTTOM_SASH_NAME, + DOUBLE_HUNG_TOP_SASH_NAME, + FRENCH_CASEMENT_LEFT_SASH_NAME, + FRENCH_CASEMENT_RIGHT_SASH_NAME, + HOPPER_WINDOW_SASH_NAME, + LOUVERED_WINDOW_SLATS_NAME, + SINGLE_HUNG_ACTIVE_SASH_NAME, + SLIDING_WINDOW_ACTIVE_PANEL_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 === 'sliding') { + const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME) + if (!activePanel) return false + + const innerW = node.width - 2 * node.frameThickness + const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerW * 0.12) + const travel = Math.max(innerW / 2 - panelOverlap, 0) * value + activePanel.position.x = -innerW / 4 - panelOverlap / 4 + travel + return true + } + + if (node.windowType === 'single-hung') { + const activeSash = mesh?.getObjectByName(SINGLE_HUNG_ACTIVE_SASH_NAME) + if (!activeSash) return false + + const innerH = node.height - 2 * node.frameThickness + const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerH * 0.12) + const travel = Math.max(innerH / 2 - panelOverlap, 0) * value + activeSash.position.y = -innerH / 4 - panelOverlap / 4 + travel + return true + } + + if (node.windowType === 'double-hung') { + const topSash = mesh?.getObjectByName(DOUBLE_HUNG_TOP_SASH_NAME) + const bottomSash = mesh?.getObjectByName(DOUBLE_HUNG_BOTTOM_SASH_NAME) + if (!(topSash && bottomSash)) return false + + const innerH = node.height - 2 * node.frameThickness + const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerH * 0.12) + const travel = Math.max(innerH / 2 - panelOverlap, 0) * value + topSash.position.y = innerH / 4 + panelOverlap / 4 - travel + bottomSash.position.y = -innerH / 4 - panelOverlap / 4 + travel + return true + } + + if (node.windowType === 'louvered') { + const slats = mesh?.getObjectByName(LOUVERED_WINDOW_SLATS_NAME) + if (!slats) return false + + const slatAngle = -value * (Math.PI / 3) + for (const slat of slats.children) { + slat.rotation.x = slatAngle + } + return true + } + + 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 }) + } + emitter.emit('window:animation-completed', { + windowId: typedWindowId as WindowNode['id'], + field: animation.field, + }) + } + }, 2) + + return null +} diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 81382b1e7..c4657ef2f 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,10 +1,26 @@ -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 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' export const WindowSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) @@ -67,6 +83,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 +338,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,61 +611,32 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } -function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { - // 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 - if (child instanceof THREE.Mesh) child.geometry.dispose() - mesh.remove(child) - } - - const { - width, - height, - frameDepth, - frameThickness, - columnRatios, - rowRatios, - columnDividerThickness, - rowDividerThickness, - sill, - sillDepth, - sillThickness, - openingKind, - openingShape, - } = node +function getWindowRenderOpenAmount(node: WindowNode) { + const runtimeValue = useInteractive.getState().windows[node.id]?.operationState + return Math.min(Math.max(runtimeValue ?? node.operationState ?? 0, 0), 1) +} - if (openingKind === 'opening') { - syncWindowCutout(node, mesh) - return - } +function getAwningDirection(node: WindowNode) { + return node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up') +} - if (openingShape === 'arch') { - addArchedWindowVisuals(node, mesh) - syncWindowCutout(node, mesh) - return - } +function isRectangleOnlyWindowType(node: WindowNode) { + return ( + node.windowType === 'sliding' || + node.windowType === 'single-hung' || + node.windowType === 'double-hung' || + node.windowType === 'bay' || + node.windowType === 'bow' + ) +} - if (openingShape === 'rounded') { - addRoundedWindowVisuals(node, mesh) - syncWindowCutout(node, mesh) - return - } +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 - // ── Frame members ── - // Top / bottom — full width + // Outer frame. addBox( mesh, baseMaterial, @@ -640,7 +657,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { -height / 2 + frameThickness / 2, 0, ) - // Left / right — inner height to avoid corner overlap addBox( mesh, baseMaterial, @@ -662,94 +678,96 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { 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 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! + 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. addBox( mesh, baseMaterial, - columnDividerThickness, - innerH, + innerW, + trackThickness, frameDepth, - cx + columnDividerThickness / 2, 0, + innerH / 2 - trackThickness / 2, + 0, + ) + addBox( + mesh, + baseMaterial, + innerW, + trackThickness, + frameDepth, + 0, + -innerH / 2 + trackThickness / 2, 0, ) - cx += columnDividerThickness - } - // Row dividers — per column width, so they don't overlap column dividers (top to bottom) - cy = innerH / 2 - 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 - } + addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) + addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) - // Glass panes - const glassDepth = Math.max(0.004, frameDepth * 0.08) - 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, - ) - } + // The right sash stays fixed. The left sash is the active panel that slides across it. + 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, + ) } - // ── Sill ── if (sill) { - const sillW = width + sillDepth * 0.4 // slightly wider than frame - // Protrudes from the front face of the frame (+Z) + const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, @@ -762,20 +780,2570 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sillZ, ) } +} - syncWindowCutout(node, mesh) +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 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' - mesh.add(cutout) +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, + ) } - cutout.geometry.dispose() - if (node.openingShape === 'arch') { +} + +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 addHungSash( + parent: THREE.Object3D, + panelW: number, + panelHeight: number, + sashFrameThickness: number, + frameDepth: number, + glassDepth: number, + glassW: number, + glassH: number, +) { + 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, + ) + 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. + 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. + 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. + 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 + 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) + 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. + 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. + 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 + 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 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) + 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 addBayCap = (centerY: number) => { + 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(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 + 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) + const slats = new THREE.Group() + + slats.name = LOUVERED_WINDOW_SLATS_NAME + mesh.add(slats) + + 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 + 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 + 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 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) => { + 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 + 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 + 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() + 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) + } + + 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 + 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 + 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 + 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) + 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 + 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' + 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,