Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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 @@ -61,6 +68,7 @@ export {
} from './store/history-control'
export {
type ControlValue,
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
32 changes: 32 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,14 @@ export type ItemInteractiveState = {
controlValues: ControlValue[]
}

export type DoorInteractiveState = {
operationState?: number
swingAngle?: number
}

type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState>

/** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
Expand All @@ -23,6 +29,12 @@ 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
}

const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
Expand All @@ -40,6 +52,7 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa

export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {},
doors: {},

initItem: (itemId, interactive) => {
const { controls } = interactive
Expand Down Expand Up @@ -74,4 +87,23 @@ 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 }
})
},
}))
78 changes: 69 additions & 9 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, 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 {
animateDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
isOperationDoorType,
} 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,6 +123,39 @@ 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
Expand Down Expand Up @@ -151,12 +194,29 @@ 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)
if (isOperationDoorType(node.doorType)) {
const currentOpenAmount =
useInteractive.getState().doors[doorId]?.operationState ?? node.operationState ?? 0
animateDoorOpenState(
doorId,
'operationState',
currentOpenAmount,
currentOpenAmount >= 0.5 ? 0 : 1,
rebuildColliderWorld,
{ persist: false },
)
} else {
const currentSwingAngle =
useInteractive.getState().doors[doorId]?.swingAngle ?? node.swingAngle ?? 0
animateDoorOpenState(
doorId,
'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
rebuildColliderWorld,
{ persist: false },
)
}
}, [rebuildColliderWorld, resolveInteractableDoorId])

const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
Expand Down Expand Up @@ -260,7 +320,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
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

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