Merge pull request #293 from sudhir9297/feat/door-improvement

Feat/door improvement
This commit is contained in:
Wassim SAMAD
2026-05-07 08:47:57 -04:00
committed by GitHub
17 changed files with 2960 additions and 714 deletions
+16
View File
@@ -7,6 +7,7 @@ import type {
ColumnNode, ColumnNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
GuideNode,
ItemNode, ItemNode,
LevelNode, LevelNode,
RoofNode, RoofNode,
@@ -132,6 +133,19 @@ type ToolEvents = {
'tool:cancel': undefined '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 = { type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:generate-thumbnail': { presetId: string; nodeId: string }
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
@@ -173,6 +187,8 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents &
DoorAnimationEvents &
PresetEvents & PresetEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
+9
View File
@@ -34,6 +34,13 @@ export {
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage' 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 { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
detectSpacesForLevel, detectSpacesForLevel,
@@ -62,6 +69,8 @@ export {
} from './store/history-control' } from './store/history-control'
export { export {
type ControlValue, type ControlValue,
type DoorAnimationState,
type DoorInteractiveState,
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
+42
View File
@@ -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)
}
+29
View File
@@ -18,6 +18,25 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment> export type DoorSegment = z.infer<typeof DoorSegment>
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<typeof DoorCategory>
export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
export const DoorNode = BaseNode.extend({ export const DoorNode = BaseNode.extend({
id: objectId('door'), id: objectId('door'),
type: nodeType('door'), type: nodeType('door'),
@@ -32,6 +51,15 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9), width: z.number().default(0.9),
height: z.number().default(2.1), 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 // Opening mode
openingKind: z.enum(['door', 'opening']).default('door'), openingKind: z.enum(['door', 'opening']).default('door'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
@@ -90,6 +118,7 @@ export const DoorNode = BaseNode.extend({
panicBarHeight: z.number().default(1.0), panicBarHeight: z.number().default(1.0),
}).describe(dedent`Door node - a parametric door placed on a wall }).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) - 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 - openingKind/openingShape: hinged door or frameless wall opening shape
- segments: rows stacked top to bottom, each defining its own columnRatios - 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 - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
@@ -12,8 +12,24 @@ export type ItemInteractiveState = {
controlValues: ControlValue[] 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 = { type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState> items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState>
doorAnimations: Record<AnyNodeId, DoorAnimationState>
/** Initialize a node's interactive state from its asset definition (idempotent) */ /** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void initItem: (itemId: AnyNodeId, interactive: Interactive) => void
@@ -23,6 +39,18 @@ type InteractiveStore = {
/** Remove a node's state (e.g. on unmount) */ /** Remove a node's state (e.g. on unmount) */
removeItem: (itemId: AnyNodeId) => void 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 => { const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
@@ -40,6 +68,8 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa
export const useInteractive = create<InteractiveStore>((set, get) => ({ export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {}, items: {},
doors: {},
doorAnimations: {},
initItem: (itemId, interactive) => { initItem: (itemId, interactive) => {
const { controls } = interactive const { controls } = interactive
@@ -74,4 +104,39 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
return { items: rest } 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 }
})
},
})) }))
@@ -1,12 +1,17 @@
'use client' 'use client'
import '../../three-types' 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 { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei' import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' 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 useEditor from '../../store/use-editor'
import { import {
buildFirstPersonColliderWorldFromRegistry, buildFirstPersonColliderWorldFromRegistry,
@@ -22,7 +27,6 @@ const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85 const CONTROLLER_CENTER_FROM_EYE = 0.85
const DOOR_INTERACTION_DISTANCE = 2.5 const DOOR_INTERACTION_DISTANCE = 2.5
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const keyboardMap = [ const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
@@ -43,6 +47,12 @@ const doorLeafLocalHit = new Vector3()
const doorLeafLocalRay = new Ray() const doorLeafLocalRay = new Ray()
const doorLeafMatrix = new Matrix4() const doorLeafMatrix = new Matrix4()
const doorLeafWorldHit = new Vector3() 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 spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
@@ -113,10 +123,45 @@ export const FirstPersonControls = () => {
if (leafW <= 0 || leafH <= 0) continue if (leafW <= 0 || leafH <= 0) continue
const leafCenterY = -node.frameThickness / 2 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 hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 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 const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
doorLeafMatrix doorLeafMatrix
@@ -151,13 +196,8 @@ export const FirstPersonControls = () => {
const node = useScene.getState().nodes[doorId] const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return if (node?.type !== 'door' || node.openingKind === 'opening') return
const currentSwingAngle = node.swingAngle ?? 0 toggleDoorOpenState(doorId, { persist: false })
useScene.getState().updateNode(doorId, { }, [resolveInteractableDoorId])
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
requestAnimationFrame(rebuildColliderWorld)
}, [rebuildColliderWorld, resolveInteractableDoorId])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => { const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -198,6 +238,11 @@ export const FirstPersonControls = () => {
} }
}, [rebuildColliderWorld]) }, [rebuildColliderWorld])
useEffect(() => {
emitter.on('door:animation-completed', rebuildColliderWorld)
return () => emitter.off('door:animation-completed', rebuildColliderWorld)
}, [rebuildColliderWorld])
useEffect(() => { useEffect(() => {
if (!world) return if (!world) return
if (controllerStart) return if (controllerStart) return
@@ -260,7 +305,7 @@ export const FirstPersonControls = () => {
document.exitPointerLock() document.exitPointerLock()
} }
useEditor.getState().setFirstPersonMode(false) useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE') { } else if (event.code === 'KeyE' || event.code === 'KeyR') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
toggleInteractableDoor() toggleInteractableDoor()
@@ -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 * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' 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 SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25 const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06 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 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 if (leafW <= 0 || leafH <= 0) return null
const leafCenterY = -node.frameThickness / 2 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 hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 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 const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
root.updateWorldMatrix(true, false)
const sourceGeometry = new THREE.BoxGeometry( const sourceGeometry = new THREE.BoxGeometry(
leafW, leafW,
leafH, leafH,
@@ -4680,6 +4680,292 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
x: (svgP2.x + svgP3.x) / 2, x: (svgP2.x + svgP3.x) / 2,
y: (svgP2.y + svgP3.y) / 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<Point2D[]>(
(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 ( return (
<g <g
@@ -4780,6 +5066,299 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
points={doorBackgroundPoints} points={doorBackgroundPoints}
stroke="none" stroke="none"
/> />
{isFoldingDoor ? (
<>
<path
d={`M ${svgP1.x} ${svgP1.y} L ${svgP2.x} ${svgP2.y}`}
fill="none"
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
{foldingPath && (
<path
d={foldingPath}
fill="none"
stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
)}
{foldingPoints.map((point, index) => (
<circle
cx={point.x}
cy={point.y}
fill={
index === 0 || index === foldingPoints.length - 1
? doorStroke
: doorLeafFill
}
key={`${opening.id}:folding-node:${index}`}
r={
index === 0 || index === foldingPoints.length - 1
? hingeMarkerRadius
: hingeMarkerRadius * 0.72
}
stroke={doorStroke}
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
))}
</>
) : isPocketDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeDasharray="0.09 0.06"
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={pocketTrackStart.x}
x2={pocketTrackEnd.x}
y1={pocketTrackStart.y}
y2={pocketTrackEnd.y}
/>
<line
stroke={doorStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<polygon
fill={doorLeafFill}
points={pocketLeafPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={pocketLeafEnd.x}
cy={pocketLeafEnd.y}
fill={doorStroke}
r={hingeMarkerRadius * 0.72}
vectorEffect="non-scaling-stroke"
/>
</>
) : isBarnDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
x1={pocketTrackStart.x}
x2={pocketTrackEnd.x}
y1={pocketTrackStart.y + py * swingSign * doorCubeSize * 1.15}
y2={pocketTrackEnd.y + py * swingSign * doorCubeSize * 1.15}
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<polygon
fill={doorLeafFill}
points={barnLeafPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
{[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 (
<circle
cx={wheel.x}
cy={wheel.y}
fill={doorStroke}
key={`${opening.id}:barn-wheel:${ratio}`}
r={hingeMarkerRadius * 0.62}
vectorEffect="non-scaling-stroke"
/>
)
})}
</>
) : isSlidingDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.34}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.34}
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.68}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.68}
/>
<polygon
fill="rgba(224, 242, 254, 0.7)"
points={slidingFixedPoints}
stroke={doorSoftStroke}
strokeLinejoin="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<polygon
fill={doorLeafFill}
points={slidingActivePoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
</>
) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? (
<>
<line
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<line
stroke={doorSoftStroke}
strokeDasharray="0.12 0.08"
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x + px * swingSign * doorCubeSize * 0.78}
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.78}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
/>
{isGarageRollupDoor ? (
<circle
cx={(svgP1.x + svgP2.x) / 2 + px * swingSign * doorCubeSize * 0.78}
cy={(svgP1.y + svgP2.y) / 2 + py * swingSign * doorCubeSize * 0.78}
fill="none"
r={doorCubeSize * 0.22}
stroke={doorSoftStroke}
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
/>
) : isGarageTiltupDoor ? (
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x + px * swingSign * doorCubeSize * 0.18}
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.18}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
/>
) : (
garagePanelLines.map((line, index) => (
<line
key={`${opening.id}:garage-section:${index}`}
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
x1={line.start.x}
x2={line.end.x}
y1={line.start.y}
y2={line.end.y}
/>
))
)}
</>
) : isDoubleSwingDoor ? (
<>
{doubleLeafPlans.map((leaf) =>
leaf.swingRadius > 1e-6 ? (
<path
d={`M ${leaf.arcEnd.x} ${leaf.arcEnd.y} A ${leaf.swingRadius} ${leaf.swingRadius} 0 0 ${leaf.sweepFlag} ${leaf.leafEnd.x} ${leaf.leafEnd.y}`}
fill="none"
key={`${opening.id}:double-sweep:${leaf.key}`}
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
/>
) : null,
)}
{swingAngle > 0.03 &&
doubleLeafPlans.map((leaf) => (
<polygon
fill="none"
key={`${opening.id}:double-hint:${leaf.key}`}
points={leaf.closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
))}
{doubleLeafPlans.map((leaf) => (
<rect
fill={doorLeafFill}
height={doorCubeSize}
key={`${opening.id}:double-hinge:${leaf.key}`}
rx={doorCubeSize * 0.12}
stroke={doorStroke}
strokeWidth="1.35"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={leaf.hingeCubeCenter.x - doorCubeSize / 2}
y={leaf.hingeCubeCenter.y - doorCubeSize / 2}
/>
))}
{doubleLeafPlans.map((leaf) => (
<circle
cx={leaf.hingeMarkerX}
cy={leaf.hingeMarkerY}
fill={doorStroke}
key={`${opening.id}:double-hinge-marker:${leaf.key}`}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
))}
{doubleLeafPlans.map((leaf) => (
<polygon
fill={doorLeafFill}
key={`${opening.id}:double-leaf:${leaf.key}`}
points={leaf.leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
))}
</>
) : (
<>
{swingSweepPath && ( {swingSweepPath && (
<path <path
d={swingSweepPath} d={swingSweepPath}
@@ -4848,6 +5427,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
/> />
</> </>
)} )}
</>
)}
{isSelected ? ( {isSelected ? (
<> <>
<circle <circle
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
parentId: event.node.id, parentId: event.node.id,
width: draft.width, width: draft.width,
height: draft.height, 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, frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth, frameDepth: draft.frameDepth,
threshold: draft.threshold, threshold: draft.threshold,
@@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) 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) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
@@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
@@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
@@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
if (isCurvedWall(event.node)) return if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
@@ -5,12 +5,14 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
useInteractive,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context' import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -22,6 +24,73 @@ import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover' 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<DoorNode['doorType'], { width: number; height: number }> = {
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 { function isSameDoorValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') { if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6 return Math.abs(current - next) < 1e-6
@@ -64,6 +133,9 @@ export function DoorPanel() {
}) })
if (!hasChange) return 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) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
}, },
@@ -195,6 +267,13 @@ export function DoorPanel() {
const getDoorPresetData = useCallback(() => { const getDoorPresetData = useCallback(() => {
if (!node) return null if (!node) return null
return { 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, width: node.width,
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
@@ -261,6 +340,16 @@ export function DoorPanel() {
const archHeight = node.archHeight ?? 0.45 const archHeight = node.archHeight ?? 0.45
const openingRevealRadius = node.openingRevealRadius ?? 0.025 const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) 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 setOpeningTopRadius = (index: number, value: number, commit = false) => {
const next = [...openingTopRadii] as [number, number] const next = [...openingTopRadii] as [number, number]
@@ -272,6 +361,150 @@ export function DoorPanel() {
} }
} }
const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial<DoorNode> => {
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 ( return (
<PanelWrapper <PanelWrapper
icon="/icons/door.png" icon="/icons/door.png"
@@ -315,16 +548,50 @@ export function DoorPanel() {
archHeight, archHeight,
openingRevealRadius, openingRevealRadius,
} }
: { openingKind: v }, : v === 'garage'
? {
openingKind: 'door',
...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'),
}
: {
openingKind: 'door',
...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}),
},
) )
} }
options={[ options={[
{ label: 'Door', value: 'door' }, { label: 'Door', value: 'door' },
{ label: 'Opening', value: 'opening' }, { label: 'Opening', value: 'opening' },
{ label: 'Garage', value: 'garage' },
]} ]}
value={node.openingKind} value={typeMode}
/> />
</div> </div>
{!isOpening && (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
const isSelected = doorType === option.value
return (
<button
className={cn(
'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
)}
disabled={!option.available}
key={option.value}
onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
type="button"
>
<DoorOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
)}
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
@@ -354,10 +621,100 @@ export function DoorPanel() {
)} )}
</PanelSection> </PanelSection>
{doorType === 'folding' && !isOpening && (
<PanelSection title="Fold">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Panels
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ leafCount: v === '2' ? 2 : 4 })}
options={[
{ label: '2', value: '2' },
{ label: '4', value: '4' },
]}
value={node.leafCount === 2 ? '2' : '4'}
/>
</div>
</div>
<SliderControl
label="Open"
max={100}
min={0}
onChange={(v) => handleUpdate({ operationState: v / 100 })}
precision={0}
restoreOnCommit={false}
step={5}
unit="%"
value={Math.round((node.operationState ?? 0) * 100)}
/>
</PanelSection>
)}
{isSlidingDoor && !isOpening && (
<PanelSection title="Slide">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
{doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'}
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ slideDirection: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.slideDirection ?? 'left'}
/>
</div>
</div>
<SliderControl
label="Open"
max={100}
min={0}
onChange={(v) => handleUpdate({ operationState: v / 100 })}
precision={0}
restoreOnCommit={false}
step={5}
unit="%"
value={Math.round((node.operationState ?? 0) * 100)}
/>
</PanelSection>
)}
{(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && (
<PanelSection title="Garage">
<SliderControl
label="Open"
max={100}
min={0}
onChange={(v) => handleUpdate({ operationState: v / 100 })}
precision={0}
restoreOnCommit={false}
step={5}
unit="%"
value={Math.round((node.operationState ?? 0) * 100)}
/>
{isSectionalGarageDoor && (
<SliderControl
label="Panels"
max={8}
min={3}
onChange={(v) => handleUpdate({ garagePanelCount: Math.round(v) })}
precision={0}
restoreOnCommit={false}
step={1}
value={node.garagePanelCount ?? 4}
/>
)}
</PanelSection>
)}
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
<SliderControl <SliderControl
label="Width" label="Width"
max={3} max={maxDoorWidth}
min={0.5} min={0.5}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
precision={2} precision={2}
@@ -605,6 +962,7 @@ export function DoorPanel() {
/> />
</PanelSection> </PanelSection>
{!isGarageDoor && (
<PanelSection title="Content Padding"> <PanelSection title="Content Padding">
<SliderControl <SliderControl
label="Horizontal" label="Horizontal"
@@ -627,7 +985,9 @@ export function DoorPanel() {
value={Math.round(node.contentPadding[1] * 1000) / 1000} value={Math.round(node.contentPadding[1] * 1000) / 1000}
/> />
</PanelSection> </PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing"> <PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1"> <div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1"> <div className="space-y-1">
@@ -658,7 +1018,9 @@ export function DoorPanel() {
</div> </div>
</div> </div>
</PanelSection> </PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold"> <PanelSection title="Threshold">
<ToggleControl <ToggleControl
checked={node.threshold} checked={node.threshold}
@@ -680,14 +1042,18 @@ export function DoorPanel() {
</div> </div>
)} )}
</PanelSection> </PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Handle"> <PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl <ToggleControl
checked={node.handle} checked={node.handle}
label="Enable Handle" label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })} onChange={(checked) => handleUpdate({ handle: checked })}
/> />
{node.handle && ( )}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1"> <div className="mt-1 flex flex-col gap-1">
<SliderControl <SliderControl
label="Height" label="Height"
@@ -699,6 +1065,7 @@ export function DoorPanel() {
unit="m" unit="m"
value={Math.round(node.handleHeight * 100) / 100} value={Math.round(node.handleHeight * 100) / 100}
/> />
{supportsHandleSide && (
<div className="space-y-1"> <div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider"> <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side Handle Side
@@ -712,10 +1079,13 @@ export function DoorPanel() {
value={node.handleSide} value={node.handleSide}
/> />
</div> </div>
)}
</div> </div>
)} )}
</PanelSection> </PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Hardware"> <PanelSection title="Hardware">
<ToggleControl <ToggleControl
checked={node.doorCloser} checked={node.doorCloser}
@@ -742,7 +1112,9 @@ export function DoorPanel() {
</div> </div>
)} )}
</PanelSection> </PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments"> <PanelSection title="Segments">
{node.segments.map((seg, i) => { {node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length const numCols = seg.columnRatios.length
@@ -756,7 +1128,9 @@ export function DoorPanel() {
<SegmentedControl <SegmentedControl
onChange={(t) => { onChange={(t) => {
const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s)) const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
options={[ options={[
@@ -892,6 +1266,7 @@ export function DoorPanel() {
)} )}
</div> </div>
</PanelSection> </PanelSection>
)}
</> </>
)} )}
@@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ {
title: 'Item Placement', title: 'Item Placement',
shortcuts: [ shortcuts: [
{ keys: ['R'], action: 'Rotate item clockwise by 90 degrees' }, { keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
{ keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' }, { keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
{ {
keys: ['Shift'], keys: ['Shift'],
action: 'Temporarily bypass placement validation constraints', action: 'Temporarily bypass placement validation constraints',
+3 -8
View File
@@ -1,12 +1,11 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' 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, // 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. // so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false let _toolCancelConsumed = false
@@ -154,11 +153,7 @@ export const useKeyboard = ({
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
const currentSwingAngle = node.swingAngle ?? 0 toggleDoorOpenState(node.id)
useScene.getState().updateNode(node.id, {
swingAngle:
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
@@ -184,7 +179,7 @@ export const useKeyboard = ({
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
useScene.getState().updateNode(node.id, { swingAngle: 0 }) closeDoorOpenState(node.id)
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
@@ -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)
}
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system' import { DoorSystem } from '../../systems/door/door-system'
import { FenceSystem } from '../../systems/fence/fence-system' import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system' import { GuideSystem } from '../../systems/guide/guide-system'
@@ -225,6 +226,7 @@ const Viewer: React.FC<ViewerProps> = ({
<WallCutout /> <WallCutout />
{/* Core systems */} {/* Core systems */}
<CeilingSystem /> <CeilingSystem />
<DoorAnimationSystem />
<DoorSystem /> <DoorSystem />
<FenceSystem /> <FenceSystem />
<ItemSystem /> <ItemSystem />
@@ -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
}
File diff suppressed because it is too large Load Diff