Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ColumnNode,
DoorNode,
FenceNode,
GuideNode,
ItemNode,
LevelNode,
RoofNode,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -173,6 +187,8 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
DoorAnimationEvents &
PresetEvents &
ThumbnailEvents &
SnapshotEvents &
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +69,8 @@ export {
} from './store/history-control'
export {
type ControlValue,
type DoorAnimationState,
type DoorInteractiveState,
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/lib/door-operation.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions packages/core/src/schema/nodes/door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/store/use-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 => {
Expand All @@ -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
Expand Down Expand Up @@ -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 }
})
},
}))
67 changes: 56 additions & 11 deletions packages/editor/src/components/editor/first-person-controls.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'] },
Expand All @@ -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')

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading