diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index fa467e9dd..78a3258e6 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,19 @@ 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 PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -173,6 +187,8 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & + GuideEvents & + DoorAnimationEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50dad3366..f5b297f4b 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,6 +69,8 @@ export { } from './store/history-control' export { type ControlValue, + type DoorAnimationState, + type DoorInteractiveState, type ItemInteractiveState, useInteractive, } from './store/use-interactive' 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/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/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 8b165559f..19a1c78de 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -12,8 +12,24 @@ 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 +} + type InteractiveStore = { items: Record + doors: Record + doorAnimations: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -23,6 +39,18 @@ 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 } const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { @@ -40,6 +68,8 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa export const useInteractive = create((set, get) => ({ items: {}, + doors: {}, + doorAnimations: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -74,4 +104,39 @@ 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 } + }) + }, })) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ee39342ec..f3597c20d 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,12 +1,17 @@ '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 { + DOOR_SWING_OPEN_ANGLE, + isOperationDoorType, + toggleDoorOpenState, +} from '../../lib/door-interaction' import useEditor from '../../store/use-editor' import { buildFirstPersonColliderWorldFromRegistry, @@ -22,7 +27,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,6 +47,12 @@ 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') @@ -113,10 +123,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 @@ -151,13 +196,8 @@ export const FirstPersonControls = () => { 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, - }) - - requestAnimationFrame(rebuildColliderWorld) - }, [rebuildColliderWorld, resolveInteractableDoorId]) + toggleDoorOpenState(doorId, { persist: false }) + }, [resolveInteractableDoorId]) const placedSpawn = useMemo(() => { if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null @@ -198,6 +238,11 @@ export const FirstPersonControls = () => { } }, [rebuildColliderWorld]) + useEffect(() => { + emitter.on('door:animation-completed', rebuildColliderWorld) + return () => emitter.off('door:animation-completed', rebuildColliderWorld) + }, [rebuildColliderWorld]) + useEffect(() => { if (!world) return if (controllerStart) return @@ -260,7 +305,7 @@ 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() toggleInteractableDoor() 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..f695ee5bd 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4680,6 +4680,292 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ x: (svgP2.x + svgP3.x) / 2, y: (svgP2.y + svgP3.y) / 2, } + const isFoldingDoor = opening.doorType === 'folding' + const foldingPanelCount = opening.leafCount === 2 ? 2 : 4 + const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) + const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y)) + const foldingPanelLength = foldingSpan / foldingPanelCount + const foldingAngle = Math.PI * 0.44 * foldingAmount + const foldingPoints = isFoldingDoor + ? Array.from({ length: foldingPanelCount + 1 }).reduce( + (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 ? ( 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/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/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..9d5383a7c 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,12 +1,11 @@ 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 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 @@ -154,11 +153,7 @@ export const useKeyboard = ({ 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 && 'rotation' in node) { @@ -184,7 +179,7 @@ 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 && 'rotation' in node) { 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/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 192e3c93a..6d6c61562 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' @@ -225,6 +226,7 @@ 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)