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,
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 &
+9
View File
@@ -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'
+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 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({
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
@@ -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<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState>
doorAnimations: Record<AnyNodeId, DoorAnimationState>
/** 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<InteractiveStore>((set, get) => ({
items: {},
doors: {},
doorAnimations: {},
initItem: (itemId, interactive) => {
const { controls } = interactive
@@ -74,4 +104,39 @@ export const useInteractive = create<InteractiveStore>((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 }
})
},
}))
@@ -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<FirstPersonSpawn | null>(() => {
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()
@@ -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,
@@ -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<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 (
<g
@@ -4780,72 +5066,367 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
points={doorBackgroundPoints}
stroke="none"
/>
{swingSweepPath && (
<path
d={swingSweepPath}
fill={doorSwingFill}
stroke="none"
vectorEffect="non-scaling-stroke"
/>
{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 && (
<path
d={swingSweepPath}
fill={doorSwingFill}
stroke="none"
vectorEffect="non-scaling-stroke"
/>
)}
{swingAngle > 0.03 && (
<polygon
fill="none"
points={closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
)}
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
rx={doorCubeSize * 0.12}
stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<circle
cx={hingeCubeCenter.x}
cy={hingeCubeCenter.y}
fill={doorStroke}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={strikeTickStart.x}
x2={strikeTickEnd.x}
y1={strikeTickStart.y}
y2={strikeTickEnd.y}
/>
<polygon
fill={doorLeafFill}
points={leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
{swingAngle > 0.03 && (
<polygon
fill="none"
points={closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
)}
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
rx={doorCubeSize * 0.12}
stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<circle
cx={hingeCubeCenter.x}
cy={hingeCubeCenter.y}
fill={doorStroke}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={strikeTickStart.x}
x2={strikeTickEnd.x}
y1={strikeTickStart.y}
y2={strikeTickEnd.y}
/>
<polygon
fill={doorLeafFill}
points={leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
{isSelected ? (
@@ -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,
@@ -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(
File diff suppressed because it is too large Load Diff
@@ -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',
+3 -8
View File
@@ -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) {
@@ -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 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<ViewerProps> = ({
<WallCutout />
{/* Core systems */}
<CeilingSystem />
<DoorAnimationSystem />
<DoorSystem />
<FenceSystem />
<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