Merge branch 'main' of github.com:sudhir9297/editor into feat/improvement-and-fixes

This commit is contained in:
sudhir
2026-04-30 22:59:10 +05:30
64 changed files with 2947 additions and 329 deletions
+5 -4
View File
@@ -13,6 +13,7 @@ import type {
RoofSegmentNode,
SiteNode,
SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
WallNode,
@@ -54,6 +55,7 @@ export type BuildingEvent = NodeEvent<BuildingNode>
export type LevelEvent = NodeEvent<LevelNode>
export type ZoneEvent = NodeEvent<ZoneNode>
export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
@@ -103,10 +105,8 @@ export interface ThumbnailGenerateEvent {
export interface CameraControlFitSceneEvent {
/**
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
* listener choose its default framing pose.
*/
bounds?: {
min: [number, number]
@@ -167,6 +167,7 @@ type EditorEvents = GridEvents &
NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
@@ -18,6 +18,7 @@ export const sceneRegistry = {
fence: new Set<string>(),
item: new Set<string>(),
slab: new Set<string>(),
spawn: new Set<string>(),
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
+1
View File
@@ -14,6 +14,7 @@ export type {
RoofSegmentEvent,
SiteEvent,
SlabEvent,
SpawnEvent,
StairEvent,
StairSegmentEvent,
WallEvent,
+1
View File
@@ -51,6 +51,7 @@ export { ScanNode } from './nodes/scan'
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { SpawnNode } from './nodes/spawn'
export {
getEffectiveStairSurfaceMaterial,
StairNode,
+1 -1
View File
@@ -77,7 +77,7 @@ export const DoorNode = BaseNode.extend({
}).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)
- segments: rows stacked top to bottom, each defining its own columnRatios
- type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
- hingesSide/swingDirection: which way the door opens
- doorCloser/panicBar: commercial and emergency hardware options
`)
+2
View File
@@ -8,6 +8,7 @@ import { ItemNode } from './item'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { SlabNode } from './slab'
import { SpawnNode } from './spawn'
import { StairNode } from './stair'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -28,6 +29,7 @@ export const LevelNode = BaseNode.extend({
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
SpawnNode.shape.id,
]),
)
.default([]),
+11
View File
@@ -0,0 +1,11 @@
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const SpawnNode = BaseNode.extend({
id: objectId('spawn'),
type: nodeType('spawn'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
})
export type SpawnNode = z.infer<typeof SpawnNode>
+2
View File
@@ -11,6 +11,7 @@ import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { WallNode } from './nodes/wall'
@@ -33,6 +34,7 @@ export const AnyNode = z.discriminatedUnion('type', [
StairSegmentNode,
ScanNode,
GuideNode,
SpawnNode,
WindowNode,
DoorNode,
])
@@ -238,27 +238,29 @@ export const createNodesAction = (
const nextRootIds = [...state.rootNodeIds]
for (const { node, parentId } of ops) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
// 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = {
...node,
parentId: parentId ?? null,
parentId: effectiveParentId,
}
nextNodes[newNode.id] = newNode
// 2. Update the Parent's children list
if (parentId && nextNodes[parentId]) {
const parent = nextNodes[parentId]
if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId]
// Type Guard: Check if the parent node is a container that supports children
if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[parentId] = {
nextNodes[effectiveParentId] = {
...parent,
// Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
}
}
} else if (!parentId) {
} else if (!effectiveParentId) {
// 3. Handle Root nodes
if (!nextRootIds.includes(newNode.id)) {
nextRootIds.push(newNode.id)
+73 -2
View File
@@ -11,8 +11,8 @@ import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions'
import { resetSceneHistoryPauseDepth } from './history-control'
function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
@@ -349,6 +349,67 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
return patchedNodes as Record<string, AnyNode>
}
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
if (!('children' in node) || !Array.isArray(node.children)) {
return []
}
return (node.children as unknown[])
.map((child) => {
if (typeof child === 'string') return child
if (child && typeof child === 'object' && 'id' in child && typeof child.id === 'string') {
return child.id
}
return null
})
.filter((id): id is AnyNodeId => typeof id === 'string')
}
function normalizeRootNodeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
): AnyNodeId[] {
const existingRootIds = rootNodeIds.filter((id) => Boolean(nodes[id]))
const siteRootIds = existingRootIds.filter((id) => nodes[id]?.type === 'site')
if (siteRootIds.length > 0) {
return siteRootIds
}
return existingRootIds.filter((id) => nodes[id]?.parentId === null)
}
function collectReachableNodeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
): Set<AnyNodeId> {
const reachable = new Set<AnyNodeId>()
const stack = [...rootNodeIds]
const childIdsByParentId = new Map<AnyNodeId, AnyNodeId[]>()
for (const node of Object.values(nodes)) {
if (!node.parentId) continue
const parentId = node.parentId as AnyNodeId
const children = childIdsByParentId.get(parentId) ?? []
children.push(node.id as AnyNodeId)
childIdsByParentId.set(parentId, children)
}
while (stack.length > 0) {
const id = stack.pop()
if (!id || reachable.has(id)) continue
const node = nodes[id]
if (!node) continue
reachable.add(id)
stack.push(...getNodeChildIds(node))
stack.push(...(childIdsByParentId.get(id) ?? []))
}
return reachable
}
export type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>
@@ -450,9 +511,19 @@ const useScene: UseSceneStore = create<SceneState>()(
}
}
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) {
for (const node of Object.values(cleanedNodes)) {
if (reachableNodeIds.has(node.id as AnyNodeId)) continue
console.warn('[Scene] Removing unreachable node', node.id)
delete cleanedNodes[node.id]
}
}
set({
nodes: cleanedNodes,
rootNodeIds,
rootNodeIds: normalizedRootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
+24 -22
View File
@@ -86,6 +86,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
contentPadding,
hingesSide,
} = node
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
const leafW = width - 2 * frameThickness
@@ -146,13 +147,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
const cpX = contentPadding[0]
const cpY = contentPadding[1]
if (cpY > 0) {
if (hasLeafContent && cpY > 0) {
// Top strip
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
// Bottom strip
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
}
if (cpX > 0) {
if (hasLeafContent && cpX > 0) {
const innerH = leafH - 2 * cpY
// Left strip
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
@@ -188,20 +189,22 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
}
// Column dividers within this segment
cx = -contentW / 2
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
addBox(
mesh,
baseMaterial,
seg.dividerThickness,
segH,
leafDepth + 0.001,
cx + seg.dividerThickness / 2,
segCenterY,
0,
)
cx += seg.dividerThickness
if (seg.type !== 'empty') {
cx = -contentW / 2
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
addBox(
mesh,
baseMaterial,
seg.dividerThickness,
segH,
leafDepth + 0.001,
cx + seg.dividerThickness / 2,
segCenterY,
0,
)
cx += seg.dividerThickness
}
}
// Segment content per column
@@ -225,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
}
} else {
// 'empty' — opaque backing, no detail
addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
// 'empty' leaves the opening unfilled
}
}
@@ -234,7 +236,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
}
// ── Handle ──
if (handle) {
if (hasLeafContent && handle) {
// Convert from floor-based height to mesh-center-based Y
const handleY = handleHeight - height / 2
// Handle grip sits on the front face (+Z) of the leaf
@@ -250,7 +252,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
}
// ── Door closer (commercial hardware at top) ──
if (doorCloser) {
if (hasLeafContent && doorCloser) {
const closerY = leafCenterY + leafH / 2 - 0.04
// Body
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
@@ -268,13 +270,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
}
// ── Panic bar ──
if (panicBar) {
if (hasLeafContent && panicBar) {
const barY = panicBarHeight - height / 2
addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
}
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
{
if (hasLeafContent) {
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
const hingeZ = 0 // centered in leaf depth
const hingeH = 0.1
@@ -1,12 +1,14 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type {
AnyNode,
AnyNodeId,
CeilingNode,
LevelNode,
SlabNode,
StairNode,
StairSegmentNode,
} from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number]
@@ -34,9 +36,10 @@ type AxisAlignedRect = {
maxZ: number
}
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0
const CURVED_STAIR_OPENING_STEP_PADDING = 3
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -423,24 +426,39 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
return polygons
}
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const openingSweep =
Math.sign(totalSweep || 1) *
function getCurvedOpeningStepCount(
stair: StairNode,
innerRadius: number,
outerRadius: number,
totalSweep: number,
) {
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepSweep = Math.abs(totalSweep) / stepCount
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
return Math.min(
stepCount,
Math.max(
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
)
const startAngle = totalSweep / 2 - openingSweep
const endAngle = totalSweep / 2
1,
Math.ceil(1.8 / treadDepth),
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
),
)
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
const segmentCount = Math.max(
10,
Math.min(
32,
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
),
)
const outerPoints: Point2D[] = []
@@ -448,7 +466,7 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
const angle = startAngle + sweep * t
outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
)
@@ -456,7 +474,8 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
const angle = startAngle + sweep * t
innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
)
@@ -465,6 +484,39 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
return [...outerPoints, ...innerPoints]
}
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const stepSweep = totalSweep / stepCount
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const endAngle = totalSweep / 2
const fallbackStartStepIndex = Math.max(
0,
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
)
let startStepIndex = fallbackStartStepIndex
if (typeof targetElevation === 'number') {
for (let index = 0; index < stepCount; index += 1) {
const stepTopElevation = stepHeight * (index + 1)
if (stepTopElevation >= targetElevation - targetThreshold) {
startStepIndex = Math.max(
0,
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
)
break
}
}
}
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48
@@ -569,7 +621,7 @@ function getStairOpeningPolygons(
}
if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair)]
return [getCurvedOpeningPolygon(stair, targetElevation)]
}
if (stair.stairType === 'spiral') {
+1
View File
@@ -46,6 +46,7 @@
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -1,5 +1,4 @@
'use client'
import {
type CameraControlEvent,
type CameraControlFitSceneEvent,
@@ -7,7 +6,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -28,7 +27,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const walkthroughMode = useViewer((s) => s.walkthroughMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId
@@ -433,8 +432,8 @@ export const CustomCameraControls = () => {
useViewer.getState().setCameraDragging(false)
}, [])
if (walkthroughMode) {
return <WalkthroughControls />
if (isFirstPersonMode) {
return null
}
return (
@@ -1,102 +1,153 @@
'use client'
import '../../three-types'
import { KeyboardControls } from '@react-three/drei'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Vector3 } from 'three'
import useEditor from '../../store/use-editor'
import BVHEcctrl from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import {
buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn,
FIRST_PERSON_SPAWN_EYE_HEIGHT,
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
// Average human eye height in meters
const EYE_HEIGHT = 1.65
// Movement speed in meters per second
const MOVE_SPEED = 5
// Sprint multiplier when holding Shift
const SPRINT_MULTIPLIER = 2
// Vertical float speed in meters per second
const VERTICAL_SPEED = 3
// Mouse look sensitivity
const MOUSE_SENSITIVITY = 0.002
// Min Y position (eye height above ground)
const MIN_Y = EYE_HEIGHT
const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
{ name: 'jump', keys: ['Space'] },
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
]
// Reusable vectors to avoid allocations in the render loop
const _forward = new Vector3()
const _right = new Vector3()
const _moveVector = new Vector3()
const _euler = new Euler(0, 0, 0, 'YXZ')
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const resolvePlacedSpawnNode = (
nodes: ReturnType<typeof useScene.getState>['nodes'],
_levelId: string | null,
) => {
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
if (candidates.length === 0) return null
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
}
export const FirstPersonControls = () => {
const { camera, gl } = useThree()
const keysRef = useRef<Set<string>>(new Set())
const selectedLevelId = useViewer((state) => state.selection.levelId)
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const isLockedRef = useRef(false)
const initializedRef = useRef(false)
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
// Initialize camera for first-person view: start at center of scene, on the ground
useEffect(() => {
if (initializedRef.current) return
initializedRef.current = true
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
// Place camera at the origin (center of grid) at eye height, looking along +X
camera.position.set(0, EYE_HEIGHT, 0)
yawRef.current = 0
pitchRef.current = 0
}, [camera])
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
if (spawnObject) {
spawnObject.updateWorldMatrix(true, false)
spawnObject.getWorldPosition(spawnWorldPosition)
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
// Pointer lock and event handlers
useEffect(() => {
const canvas = gl.domElement
const requestLock = () => {
if (!isLockedRef.current) {
canvas.requestPointerLock()
return {
position: [
spawnWorldPosition.x,
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
spawnWorldPosition.z,
],
yaw: spawnWorldEuler.y,
}
}
const handlePointerLockChange = () => {
isLockedRef.current = document.pointerLockElement === canvas
return {
position: [
placedSpawnNode.position[0],
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
placedSpawnNode.position[2],
],
yaw: placedSpawnNode.rotation,
}
}, [placedSpawnNode])
useEffect(() => {
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
if (!nextWorld) {
setWorld(null)
return
}
const handleMouseMove = (e: MouseEvent) => {
if (!isLockedRef.current) return
setWorld(nextWorld)
yawRef.current -= e.movementX * MOUSE_SENSITIVITY
pitchRef.current -= e.movementY * MOUSE_SENSITIVITY
// Clamp pitch to prevent flipping (almost straight up/down)
return () => {
nextWorld.dispose()
setWorld(null)
}
}, [camera])
useEffect(() => {
if (!world) return
yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
pitchRef.current = 0
}, [camera, placedSpawn, world])
useEffect(() => {
const canvas = gl.domElement
const handleMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas) return
yawRef.current -= e.movementX * LOOK_SENSITIVITY
pitchRef.current = Math.max(
-Math.PI / 2 + 0.05,
Math.min(Math.PI / 2 - 0.05, pitchRef.current),
-(Math.PI / 2 - 0.05),
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
)
}
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
const handleClick = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!canvas.contains(target)) return
if (document.pointerLockElement !== canvas) {
canvas.requestPointerLock?.()
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('click', handleClick)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('click', handleClick)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
}
}, [gl])
useEffect(() => {
const canvas = gl.domElement
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const code = e.code
// Movement keys
if (
code === 'KeyW' ||
code === 'KeyA' ||
code === 'KeyS' ||
code === 'KeyD' ||
code === 'KeyQ' ||
code === 'KeyE' ||
code === 'ShiftLeft' ||
code === 'ShiftRight'
) {
e.preventDefault()
e.stopPropagation()
keysRef.current.add(code)
}
// ESC exits first-person mode
if (code === 'Escape') {
e.preventDefault()
e.stopPropagation()
if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
@@ -104,75 +155,73 @@ export const FirstPersonControls = () => {
}
}
const handleKeyUp = (e: KeyboardEvent) => {
keysRef.current.delete(e.code)
}
canvas.addEventListener('click', requestLock)
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('mousemove', handleMouseMove)
// Use capture phase so we intercept movement keys before the global keyboard handler
document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keyup', handleKeyUp)
return () => {
canvas.removeEventListener('click', requestLock)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keyup', handleKeyUp)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
keysRef.current.clear()
}
}, [gl])
// Per-frame movement and camera rotation
useFrame((_, delta) => {
// Clamp delta to avoid huge jumps (e.g. tab switching)
const dt = Math.min(delta, 0.1)
const keys = keysRef.current
if (!controllerRef.current?.group) return
const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
// Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
_forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
_right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
_moveVector.set(0, 0, 0)
if (keys.has('KeyW')) _moveVector.add(_forward)
if (keys.has('KeyS')) _moveVector.sub(_forward)
if (keys.has('KeyA')) _moveVector.sub(_right)
if (keys.has('KeyD')) _moveVector.add(_right)
// Normalize diagonal movement so it's not faster
if (_moveVector.lengthSq() > 0) {
_moveVector.normalize().multiplyScalar(speed * dt)
camera.position.add(_moveVector)
}
// Vertical movement (Q = up, E = down)
if (keys.has('KeyQ')) {
camera.position.y += VERTICAL_SPEED * dt
}
if (keys.has('KeyE')) {
camera.position.y -= VERTICAL_SPEED * dt
}
// Clamp Y so camera never goes below ground level + eye height
if (camera.position.y < MIN_Y) {
camera.position.y = MIN_Y
}
// Apply look rotation
_euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(_euler)
const group = controllerRef.current.group
group.rotation.y = 0
camera.position.copy(group.position).add(cameraOffset)
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
})
return null
const controllerPosition = useMemo(() => {
if (!world) return null
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const
}, [camera, placedSpawn, world])
const spawnYaw = useMemo(() => {
if (!world) return 0
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
}, [camera, placedSpawn, world])
if (!world) {
return null
}
return (
<>
{controllerPosition && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
ref={controllerRef}
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
debug={false}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={controllerPosition}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
/>
</KeyboardControls>
)}
</>
)
}
/**
@@ -180,6 +229,23 @@ export const FirstPersonControls = () => {
* Rendered as a regular DOM overlay (not inside the Canvas).
*/
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
const [isLocked, setIsLocked] = useState(false)
const hasPlacedSpawn = useScene((state) =>
Object.values(state.nodes).some((node) => node.type === 'spawn'),
)
useEffect(() => {
const handlePointerLockChange = () => {
setIsLocked(document.pointerLockElement != null)
}
handlePointerLockChange()
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}
}, [])
const handleExit = useCallback(() => {
if (document.pointerLockElement) {
document.exitPointerLock()
@@ -189,15 +255,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
return (
<>
{/* Crosshair */}
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
<div className="relative h-6 w-6">
<div className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60" />
<div className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60" />
{isLocked && (
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
<div className="relative h-7 w-7">
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
</div>
</div>
</div>
)}
{/* Exit button — top-right */}
<div className="fixed top-4 right-4 z-50">
<button
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
@@ -211,30 +277,37 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
</button>
</div>
{/* Controls hint — bottom-center */}
<div className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2">
<div className="flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
<div className="h-5 w-px bg-border/30" />
<ControlHint label="Up" keys={['Q']} />
<ControlHint label="Down" keys={['E']} />
<div className="h-5 w-px bg-border/30" />
<ControlHint label="Sprint" keys={['Shift']} />
<div className="h-5 w-px bg-border/30" />
<span className="text-muted-foreground/60 text-xs">Click to look around</span>
{!hasPlacedSpawn && (
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
Place a Spawn Point from the Build tab to control where walkthrough starts.
</div>
</div>
</div>
)}
{isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
<div className="h-px w-full bg-border/30" />
<InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">Click to look around</span>
</div>
</div>
)}
</>
)
}
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex flex-col items-center gap-1.5 text-center">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
{label}
</span>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-center gap-1">
{keys.map((key) => (
<kbd
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
@@ -247,3 +320,16 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
</div>
)
}
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
{keyLabel}
</kbd>
</div>
)
}
@@ -0,0 +1,262 @@
import { sceneRegistry, useScene } from '@pascal-app/core'
import {
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
} from 'three-mesh-bvh'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import * as THREE from 'three'
const COLLIDER_NODE_TYPES = [
'wall',
'fence',
'slab',
'stair',
'stair-segment',
'roof',
'roof-segment',
'door',
'item',
] as const
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
export type FirstPersonColliderWorld = {
mesh: THREE.Mesh
bounds: THREE.Box3 | null
dispose: () => void
}
export type FirstPersonSpawn = {
position: [number, number, number]
yaw: number
}
type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
return 'isMesh' in object && (object as THREE.Mesh).isMesh
}
function cloneWorldGeometry(mesh: THREE.Mesh) {
const sourceGeometry = mesh.geometry
const position = sourceGeometry.getAttribute('position')
if (!position || position.count < 3) return null
const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone()
const cleanGeometry = new THREE.BufferGeometry()
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
const normal = workingGeometry.getAttribute('normal')
if (normal) {
cleanGeometry.setAttribute('normal', normal.clone())
} else {
cleanGeometry.computeVertexNormals()
}
cleanGeometry.applyMatrix4(mesh.matrixWorld)
workingGeometry.dispose()
const worldPosition = cleanGeometry.getAttribute('position')
if (!worldPosition || worldPosition.count < 3) {
cleanGeometry.dispose()
return null
}
return cleanGeometry
}
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
if (type !== 'door') return false
const node = useScene.getState().nodes[nodeId]
if (!node || node.type !== 'door') return false
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
}
function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>()
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
nodeTypes.set(nodeId, type)
}
}
return nodeTypes
}
function collectColliderGeometriesFromNode(
root: THREE.Object3D,
rootNodeId: string,
visitedMeshes: WeakSet<THREE.Object3D>,
registeredObjectIds: Map<THREE.Object3D, string>,
registeredNodeTypes: Map<string, ColliderNodeType>,
): THREE.BufferGeometry[] {
const geometries: THREE.BufferGeometry[] = []
const visit = (object: THREE.Object3D) => {
if (visitedMeshes.has(object)) return
visitedMeshes.add(object)
if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) {
const geometry = cloneWorldGeometry(object)
if (geometry) {
geometries.push(geometry)
}
}
for (const child of object.children) {
const childNodeId = registeredObjectIds.get(child)
if (childNodeId && childNodeId !== rootNodeId) {
const childType = registeredNodeTypes.get(childNodeId)
if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
continue
}
}
visit(child)
}
}
visit(root)
return geometries
}
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
const geometries: THREE.BufferGeometry[] = []
const visitedMeshes = new WeakSet<THREE.Object3D>()
const registeredNodeTypes = buildRegisteredNodeTypeLookup()
const registeredObjectIds = new Map<THREE.Object3D, string>()
for (const [nodeId, object] of sceneRegistry.nodes) {
registeredObjectIds.set(object, nodeId)
}
for (const type of COLLIDER_NODE_TYPES) {
for (const nodeId of sceneRegistry.byType[type]) {
if (shouldSkipColliderNode(nodeId, type)) continue
const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue
root.updateMatrixWorld(true)
geometries.push(
...collectColliderGeometriesFromNode(
root,
nodeId,
visitedMeshes,
registeredObjectIds,
registeredNodeTypes,
),
)
}
}
if (geometries.length === 0) {
return null
}
const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => geometry.dispose())
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose()
return null
}
const bvhGeometry = mergedGeometry as THREE.BufferGeometry & {
computeBoundsTree?: typeof computeBoundsTree
disposeBoundsTree?: typeof disposeBoundsTree
}
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
bvhGeometry.computeBoundsTree?.({
maxLeafTris: 12,
strategy: 0,
} as never)
bvhGeometry.computeBoundingBox()
const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL)
mesh.raycast = acceleratedRaycast
mesh.visible = true
mesh.userData = {
type: 'STATIC',
friction: 0.8,
restitution: 0.05,
excludeFloatHit: false,
excludeCollisionCheck: false,
}
mesh.updateMatrixWorld(true)
return {
mesh,
bounds: bvhGeometry.boundingBox?.clone() ?? null,
dispose: () => {
bvhGeometry.disposeBoundsTree?.()
bvhGeometry.dispose()
},
}
}
export function deriveFirstPersonSpawn(
camera: THREE.Camera,
world: FirstPersonColliderWorld,
): FirstPersonSpawn {
const direction = new THREE.Vector3()
camera.getWorldDirection(direction)
direction.y = 0
if (direction.lengthSq() < 1e-6) {
direction.set(0, 0, -1)
} else {
direction.normalize()
}
const yaw = Math.atan2(-direction.x, -direction.z)
const raycaster = new THREE.Raycaster()
const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]]
const boundsCenter = world.bounds?.getCenter(new THREE.Vector3())
if (boundsCenter) {
candidates.push([boundsCenter.x, boundsCenter.z])
}
for (const [x, z] of candidates) {
const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
const intersections = raycaster.intersectObject(world.mesh, false)
const hit = intersections.find((intersection) => {
if (!intersection.face) return true
const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld)
return normal.dot(UP) > 0.2
})
if (hit) {
return {
position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
yaw,
}
}
}
return {
position: [
camera.position.x,
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
camera.position.z,
],
yaw,
}
}
@@ -0,0 +1,795 @@
import '../../../three-types'
import { TransformControls, useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
import {
Suspense,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import type { ReactNode } from 'react'
import * as THREE from 'three'
import { clamp } from 'three/src/math/MathUtils.js'
export type MovementInput = {
forward?: boolean
backward?: boolean
leftward?: boolean
rightward?: boolean
joystick?: { x: number; y: number }
run?: boolean
jump?: boolean
}
export type CharacterAnimationStatus =
| 'IDLE'
| 'WALK'
| 'RUN'
| 'JUMP_START'
| 'JUMP_IDLE'
| 'JUMP_FALL'
| 'JUMP_LAND'
export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH'
export interface BVHEcctrlApi {
group: THREE.Group | null
model: THREE.Group | null
resetLinVel: () => void
addLinVel: (v: THREE.Vector3) => void
setLinVel: (v: THREE.Vector3) => void
setMovement: (input: MovementInput) => void
}
export interface EcctrlProps extends Omit<ThreeElements['group'], 'ref'> {
children?: ReactNode
debug?: boolean
colliderMeshes?: THREE.Mesh[]
colliderCapsuleArgs?: [
radius: number,
length: number,
capSegments: number,
radialSegments: number,
]
paused?: boolean
delay?: number
gravity?: number
fallGravityFactor?: number
maxFallSpeed?: number
mass?: number
sleepTimeout?: number
slowMotionFactor?: number
turnSpeed?: number
maxWalkSpeed?: number
maxRunSpeed?: number
acceleration?: number
deceleration?: number
counterAccFactor?: number
airDragFactor?: number
jumpVel?: number
floatCheckType?: FloatCheckType
maxSlope?: number
floatHeight?: number
floatPullBackHeight?: number
floatSensorRadius?: number
floatSpringK?: number
floatDampingC?: number
collisionCheckIteration?: number
collisionPushBackDamping?: number
collisionPushBackThreshold?: number
}
type CharacterStatus = {
position: THREE.Vector3
linvel: THREE.Vector3
quaternion: THREE.Quaternion
inputDir: THREE.Vector3
movingDir: THREE.Vector3
isOnGround: boolean
isOnMovingPlatform: boolean
animationStatus: CharacterAnimationStatus
}
export const characterStatus: CharacterStatus = {
position: new THREE.Vector3(),
linvel: new THREE.Vector3(),
quaternion: new THREE.Quaternion(),
inputDir: new THREE.Vector3(),
movingDir: new THREE.Vector3(),
isOnGround: false,
isOnMovingPlatform: false,
animationStatus: 'IDLE',
}
const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
(
{
children,
debug = false,
colliderMeshes = [],
colliderCapsuleArgs = [0.3, 0.6, 4, 8],
paused = false,
delay = 1.5,
gravity = 9.81,
fallGravityFactor = 4,
maxFallSpeed = 50,
mass = 1,
sleepTimeout = 10,
slowMotionFactor = 1,
turnSpeed = 15,
maxWalkSpeed = 3,
maxRunSpeed = 5,
acceleration = 30,
deceleration = 20,
counterAccFactor = 0.5,
airDragFactor = 0.3,
jumpVel = 5,
floatCheckType = 'BOTH',
maxSlope = 1,
floatHeight = 0.2,
floatPullBackHeight = 0.25,
floatSensorRadius = 0.12,
floatSpringK = 600,
floatDampingC = 28,
collisionCheckIteration = 3,
collisionPushBackDamping = 0.1,
collisionPushBackThreshold = 0.05,
...props
},
ref,
) => {
const { camera } = useThree()
const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs])
const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs])
const characterGroupRef = useRef<THREE.Group | null>(null)
const characterColliderRef = useRef<THREE.Mesh | null>(null)
const characterModelRef = useRef<THREE.Group | null>(null)
const debugLineStart = useRef<THREE.Mesh | null>(null)
const debugLineEnd = useRef<THREE.Mesh | null>(null)
const debugRaySensorStart = useRef<THREE.Mesh | null>(null)
const debugRaySensorEnd = useRef<THREE.Mesh | null>(null)
const standPointRef = useRef<THREE.Mesh | null>(null)
const lookDirRef = useRef<THREE.Mesh | null>(null)
const inputDirRef = useRef<THREE.ArrowHelper | null>(null)
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
const elapsedRef = useRef(0)
function useIsInsideKeyboardControls() {
try {
return !!useKeyboardControls()
} catch {
return false
}
}
const isInsideKeyboardControls = useIsInsideKeyboardControls()
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
const presetKeys = {
forward: false,
backward: false,
leftward: false,
rightward: false,
jump: false,
run: false,
}
const upAxis = useRef(new THREE.Vector3(0, 1, 0))
const localUpAxis = useRef(new THREE.Vector3())
const gravityDir = useRef(new THREE.Vector3(0, -1, 0))
const currentLinVel = useRef(new THREE.Vector3())
const currentLinVelOnPlane = useRef(new THREE.Vector3())
const isFalling = useRef(false)
const idleTime = useRef(0)
const isSleeping = useRef(false)
const camProjDir = useRef(new THREE.Vector3())
const camRightDir = useRef(new THREE.Vector3())
const inputDir = useRef(new THREE.Vector3())
const inputDirOnPlane = useRef(new THREE.Vector3())
const movingDir = useRef(new THREE.Vector3())
const deltaLinVel = useRef(new THREE.Vector3())
const wantToMoveVel = useRef(new THREE.Vector3())
const forwardState = useRef(false)
const backwardState = useRef(false)
const leftwardState = useRef(false)
const rightwardState = useRef(false)
const joystickState = useRef(new THREE.Vector2())
const runState = useRef(false)
const jumpState = useRef(false)
const isOnGround = useRef(false)
const prevIsOnGround = useRef(false)
const prevAnimation = useRef<CharacterAnimationStatus>('IDLE')
const characterModelTargetQuat = useRef(new THREE.Quaternion())
const characterModelLookMatrix = useRef(new THREE.Matrix4())
const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), [])
const contactDepth = useRef(0)
const contactNormal = useRef(new THREE.Vector3())
const triContactPoint = useRef(new THREE.Vector3())
const capsuleContactPoint = useRef(new THREE.Vector3())
const totalDepth = useRef(0)
const triangleCount = useRef(0)
const accumulatedContactNormal = useRef(new THREE.Vector3())
const accumulatedContactPoint = useRef(new THREE.Vector3())
const absorbVel = useRef(new THREE.Vector3())
const pushBackVel = useRef(new THREE.Vector3())
const characterBbox = useRef(new THREE.Box3())
const characterSegment = useRef(new THREE.Line3())
const localCharacterBbox = useRef(new THREE.Box3())
const localCharacterSegment = useRef(new THREE.Line3())
const collideInvertMatrix = useRef(new THREE.Matrix4())
const relativeCollideVel = useRef(new THREE.Vector3())
const scaledContactRadiusVec = useRef(new THREE.Vector3())
const deltaDist = useRef(new THREE.Vector3())
const currSlopeAngle = useRef(0)
const localMinDistance = useRef(Infinity)
const localClosestPoint = useRef(new THREE.Vector3())
const localHitNormal = useRef(new THREE.Vector3())
const triNormal = useRef(new THREE.Vector3())
const globalMinDistance = useRef(Infinity)
const globalClosestPoint = useRef(new THREE.Vector3())
const triHitPoint = useRef(new THREE.Vector3())
const segHitPoint = useRef(new THREE.Vector3())
const floatHitNormal = useRef(new THREE.Vector3())
const groundFriction = useRef(0.8)
const floatSensorBbox = useRef(new THREE.Box3())
const floatSensorBboxExpendPoint = useRef(new THREE.Vector3())
const floatSensorSegment = useRef(new THREE.Line3())
const localFloatSensorBbox = useRef(new THREE.Box3())
const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3())
const localFloatSensorSegment = useRef(new THREE.Line3())
const floatInvertMatrix = useRef(new THREE.Matrix4())
const floatNormalInverseMatrix = useRef(new THREE.Matrix3())
const floatNormalMatrix = useRef(new THREE.Matrix3())
const floatRaycaster = useRef(new THREE.Raycaster())
const relativeHitPoint = useRef(new THREE.Vector3())
const totalPlatformDeltaPos = useRef(new THREE.Vector3())
const isOnMovingPlatform = useRef(false)
const floatTempPos = useRef(new THREE.Vector3())
const floatTempQuat = useRef(new THREE.Quaternion())
const floatTempScale = useRef(new THREE.Vector3())
const scaledFloatRadiusVec = useRef(new THREE.Vector3())
const deltaHit = useRef(new THREE.Vector3())
const rotationDeltaPos = useRef(new THREE.Vector3())
const yawQuaternion = useRef(new THREE.Quaternion())
const contactTempPos = useRef(new THREE.Vector3())
const contactTempQuat = useRef(new THREE.Quaternion())
const contactTempScale = useRef(new THREE.Vector3())
floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight
const floatRaycastCandidates = useMemo(
() =>
colliderMeshes.filter(
(mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh),
),
[colliderMeshes],
)
const applyGravity = useCallback(
(delta: number) => {
gravityDir.current.copy(upAxis.current).negate()
const fallingSpeed = currentLinVel.current.dot(gravityDir.current)
isFalling.current = fallingSpeed > 0
if (fallingSpeed < maxFallSpeed) {
currentLinVel.current.addScaledVector(
gravityDir.current,
gravity * (isFalling.current ? fallGravityFactor : 1) * delta,
)
}
},
[fallGravityFactor, gravity, maxFallSpeed],
)
const checkCharacterSleep = useCallback(
(jump: boolean, delta: number) => {
const moving = currentLinVel.current.lengthSq() > 1e-6
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) {
idleTime.current += delta
if (idleTime.current > sleepTimeout) isSleeping.current = true
} else {
idleTime.current = 0
isSleeping.current = false
}
},
[sleepTimeout],
)
const setInputDirection = useCallback(
(dir: {
forward?: boolean
backward?: boolean
leftward?: boolean
rightward?: boolean
joystick?: THREE.Vector2
}) => {
inputDir.current.set(0, 0, 0)
camera.getWorldDirection(camProjDir.current)
camProjDir.current.projectOnPlane(upAxis.current).normalize()
camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize()
if (dir.joystick && dir.joystick.lengthSq() > 0) {
inputDir.current
.addScaledVector(camProjDir.current, dir.joystick.y)
.addScaledVector(camRightDir.current, dir.joystick.x)
} else {
if (dir.forward) inputDir.current.add(camProjDir.current)
if (dir.backward) inputDir.current.sub(camProjDir.current)
if (dir.leftward) inputDir.current.sub(camRightDir.current)
if (dir.rightward) inputDir.current.add(camRightDir.current)
}
inputDir.current.normalize()
},
[camera],
)
const handleCharacterMovement = useCallback(
(run: boolean, delta: number) => {
const friction = clamp(groundFriction.current, 0, 1)
if (inputDir.current.lengthSq() > 0) {
if (characterModelRef.current) {
inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current)
characterModelLookMatrix.current.lookAt(
inputDirOnPlane.current,
characterOrigin,
upAxis.current,
)
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed)
}
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed)
const dot = movingDir.current.dot(inputDir.current)
deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current)
deltaLinVel.current.clampLength(
0,
(dot <= 0 ? 1 + counterAccFactor : 1) *
acceleration *
friction *
delta *
(isOnGround.current ? 1 : airDragFactor),
)
currentLinVel.current.add(deltaLinVel.current)
} else if (isOnGround.current) {
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta)
currentLinVel.current.sub(deltaLinVel.current)
}
},
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
)
const updateSegmentBBox = useCallback(() => {
if (!characterGroupRef.current) return
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position)
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position)
characterBbox.current
.makeEmpty()
.expandByPoint(characterSegment.current.start)
.expandByPoint(characterSegment.current.end)
.expandByScalar(capsuleRadius)
floatSensorSegment.current.start.copy(characterSegment.current.end)
floatSensorSegment.current.end
.copy(floatSensorSegment.current.start)
.addScaledVector(gravityDir.current, floatHeight + capsuleRadius)
floatSensorBboxExpendPoint.current
.copy(floatSensorSegment.current.end)
.addScaledVector(gravityDir.current, floatPullBackHeight)
floatSensorBbox.current
.makeEmpty()
.expandByPoint(floatSensorSegment.current.start)
.expandByPoint(floatSensorBboxExpendPoint.current)
.expandByScalar(floatSensorRadius)
}, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius])
const collisionCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current)
collideInvertMatrix.current.copy(originMatrix).invert()
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
scaledContactRadiusVec.current.set(
capsuleRadius / contactTempScale.current.x,
capsuleRadius / contactTempScale.current.y,
capsuleRadius / contactTempScale.current.z,
)
localCharacterBbox.current
.makeEmpty()
.expandByPoint(localCharacterSegment.current.start)
.expandByPoint(localCharacterSegment.current.end)
localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1)
localCharacterBbox.current.max.add(scaledContactRadiusVec.current)
contactDepth.current = 0
contactNormal.current.set(0, 0, 0)
absorbVel.current.set(0, 0, 0)
pushBackVel.current.set(0, 0, 0)
totalDepth.current = 0
triangleCount.current = 0
accumulatedContactNormal.current.set(0, 0, 0)
accumulatedContactPoint.current.set(0, 0, 0)
mesh.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current),
intersectsTriangle: (tri) => {
tri.closestPointToSegment(
localCharacterSegment.current,
triContactPoint.current,
capsuleContactPoint.current,
)
deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current)
deltaDist.current.divide(scaledContactRadiusVec.current)
if (deltaDist.current.lengthSq() < 1) {
triContactPoint.current.applyMatrix4(originMatrix)
capsuleContactPoint.current.applyMatrix4(originMatrix)
contactNormal.current
.copy(capsuleContactPoint.current)
.sub(triContactPoint.current)
.normalize()
contactDepth.current =
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current)
accumulatedContactPoint.current.add(triContactPoint.current)
totalDepth.current += contactDepth.current
triangleCount.current += 1
}
},
})
if (triangleCount.current > 0) {
accumulatedContactNormal.current.normalize()
accumulatedContactPoint.current.divideScalar(triangleCount.current)
const avgDepth = totalDepth.current / triangleCount.current
relativeCollideVel.current.copy(currentLinVel.current)
const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current)
if (intoSurfaceVel < 0) {
absorbVel.current
.copy(accumulatedContactNormal.current)
.multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05)))
currentLinVel.current.add(absorbVel.current)
}
if (avgDepth > collisionPushBackThreshold) {
const correction = (collisionPushBackDamping / delta) * avgDepth
pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction)
currentLinVel.current.add(pushBackVel.current)
}
}
},
[capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold],
)
const handleCollisionResponse = useCallback(
(meshes: THREE.Mesh[], delta: number) => {
if (meshes.length === 0) return
for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) {
for (const mesh of meshes) {
collisionCheck(mesh, mesh.matrixWorld, delta)
}
}
},
[collisionCheck, collisionCheckIteration],
)
const floatingCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
floatInvertMatrix.current.copy(originMatrix).invert()
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
floatNormalMatrix.current.getNormalMatrix(originMatrix)
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current)
localFloatSensorBboxExpendPoint.current
.copy(floatSensorBboxExpendPoint.current)
.applyMatrix4(floatInvertMatrix.current)
scaledFloatRadiusVec.current.set(
floatSensorRadius / floatTempScale.current.x,
floatSensorRadius / floatTempScale.current.y,
floatSensorRadius / floatTempScale.current.z,
)
localFloatSensorBbox.current
.makeEmpty()
.expandByPoint(localFloatSensorSegment.current.start)
.expandByPoint(localFloatSensorBboxExpendPoint.current)
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
localMinDistance.current = Infinity
localClosestPoint.current.set(Infinity, Infinity, Infinity)
mesh.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
intersectsTriangle: (tri) => {
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current)
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize()
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
deltaHit.current.divide(scaledFloatRadiusVec.current)
const totalLengthSq = deltaHit.current.lengthSq()
const dot = deltaHit.current.dot(localUpAxis.current)
const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
if (horizontalLength < 1 && verticalLength < 1) {
tri.getNormal(triNormal.current)
triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize()
triHitPoint.current.applyMatrix4(originMatrix)
const slopeAngle = triNormal.current.angleTo(upAxis.current)
if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) {
localMinDistance.current = verticalLength
localClosestPoint.current.copy(triHitPoint.current)
localHitNormal.current.copy(triNormal.current)
}
}
},
})
if (localMinDistance.current < globalMinDistance.current) {
globalMinDistance.current = localMinDistance.current
globalClosestPoint.current.copy(localClosestPoint.current)
floatHitNormal.current.copy(localHitNormal.current)
}
},
[capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope],
)
const handleFloatingResponse = useCallback(
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
if (meshes.length === 0) return
globalMinDistance.current = Infinity
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
floatHitNormal.current.set(0, 1, 0)
isOnGround.current = false
totalPlatformDeltaPos.current.set(0, 0, 0)
isOnMovingPlatform.current = false
if (floatCheckType !== 'RAYCAST') {
for (const mesh of meshes) {
floatingCheck(mesh, mesh.matrixWorld)
}
}
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) {
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
floatRaycaster.current.ray.direction.copy(gravityDir.current)
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
const hit = hits[0]
if (hit?.point) {
globalClosestPoint.current.copy(hit.point)
if (hit.face) {
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize()
}
}
}
if (globalClosestPoint.current.x === Infinity) return
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start)
const currentDistance = relativeHitPoint.current.length()
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
if (currentDistance < floatHeight + capsuleRadius) {
isOnGround.current = true
jump = false
}
if (!jump) {
const displacement = floatHeight + capsuleRadius - currentDistance
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
const springForce = displacement * floatSpringK
const dampingForce = -velocityOnHitNormal * floatDampingC
const totalForce = springForce + dampingForce - mass * gravity
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
}
},
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass],
)
const updateCharacterWithPlatform = useCallback(() => {
if (!characterGroupRef.current) return
rotationDeltaPos.current.copy(totalPlatformDeltaPos.current)
characterGroupRef.current.position.add(rotationDeltaPos.current)
yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current)
}, [upAxis])
const updateCharacterAnimation = useCallback(
(run: boolean, jump: boolean): CharacterAnimationStatus => {
if (prevIsOnGround.current && jump) return 'JUMP_START'
if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE'
if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL'
if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND'
if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK'
return 'IDLE'
},
[],
)
const updateCharacterStatus = useCallback(
(run: boolean, jump: boolean) => {
characterModelRef.current?.getWorldPosition(characterStatus.position)
characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion)
characterStatus.linvel.copy(currentLinVel.current)
characterStatus.inputDir.copy(inputDir.current)
characterStatus.movingDir.copy(movingDir.current)
characterStatus.isOnGround = isOnGround.current
characterStatus.isOnMovingPlatform = isOnMovingPlatform.current
characterStatus.animationStatus = updateCharacterAnimation(run, jump)
prevAnimation.current = characterStatus.animationStatus
},
[updateCharacterAnimation],
)
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), [])
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), [])
const setMovement = useCallback((movement: MovementInput) => {
if (movement.forward !== undefined) forwardState.current = movement.forward
if (movement.backward !== undefined) backwardState.current = movement.backward
if (movement.leftward !== undefined) leftwardState.current = movement.leftward
if (movement.rightward !== undefined) rightwardState.current = movement.rightward
if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y)
if (movement.run !== undefined) runState.current = movement.run
if (movement.jump !== undefined) jumpState.current = movement.jump
}, [])
useImperativeHandle(
ref,
() => ({
get group() {
return characterGroupRef.current
},
get model() {
return characterModelRef.current
},
resetLinVel,
addLinVel,
setLinVel,
setMovement,
}),
[addLinVel, resetLinVel, setLinVel, setMovement],
)
const updateDebugger = useCallback(() => {
debugLineStart.current?.position.copy(characterSegment.current.start)
debugLineEnd.current?.position.copy(characterSegment.current.end)
debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start)
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
standPointRef.current?.position.copy(globalClosestPoint.current)
if (characterGroupRef.current) {
lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7)
}
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
inputDirRef.current?.position.copy(characterSegment.current.end)
inputDirRef.current?.setDirection(inputDir.current)
inputDirRef.current?.setLength(inputDir.current.lengthSq())
moveDirRef.current?.position.copy(characterSegment.current.end)
moveDirRef.current?.setDirection(currentLinVel.current)
moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed)
}, [characterSegment, maxWalkSpeed])
useFrame((_, delta) => {
elapsedRef.current += delta
if (paused || elapsedRef.current < delay) return
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys
const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward
const rightward = rightwardState.current || keys.rightward
const run = runState.current || keys.run
const jump = jumpState.current || keys.jump
setInputDirection({
forward,
backward,
leftward,
rightward,
joystick: joystickState.current,
})
handleCharacterMovement(run, deltaTime)
if (jump && isOnGround.current) currentLinVel.current.y = jumpVel
movingDir.current.copy(currentLinVel.current).normalize()
currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current)
checkCharacterSleep(jump, deltaTime)
if (!isSleeping.current) {
if (!isOnGround.current) applyGravity(deltaTime)
updateSegmentBBox()
handleCollisionResponse(colliderMeshes, deltaTime)
handleFloatingResponse(colliderMeshes, jump, deltaTime)
updateCharacterWithPlatform()
if (characterGroupRef.current) {
characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime)
}
updateCharacterStatus(run, jump)
prevIsOnGround.current = isOnGround.current
}
if (debug) updateDebugger()
})
return (
<Suspense fallback={null}>
<group {...props} ref={characterGroupRef} dispose={null}>
{debug && (
<mesh ref={characterColliderRef}>
<capsuleGeometry args={colliderCapsuleArgs} />
<meshNormalMaterial wireframe />
</mesh>
)}
<group name="BVHEcctrl-Model" ref={characterModelRef}>
{children}
</group>
</group>
{debug && (
<group>
<TransformControls object={characterGroupRef.current!} />
<box3Helper args={[characterBbox.current]} />
<mesh ref={debugLineStart}>
<octahedronGeometry args={[0.05, 0]} />
<meshNormalMaterial />
</mesh>
<mesh ref={debugLineEnd}>
<octahedronGeometry args={[0.05, 0]} />
<meshNormalMaterial />
</mesh>
<box3Helper args={[floatSensorBbox.current]} />
<mesh ref={debugRaySensorStart}>
<octahedronGeometry args={[0.1, 0]} />
<meshBasicMaterial color="yellow" wireframe />
</mesh>
<mesh ref={debugRaySensorEnd}>
<octahedronGeometry args={[0.1, 0]} />
<meshBasicMaterial color="yellow" wireframe />
</mesh>
<mesh ref={lookDirRef} scale={[1, 0.5, 4]}>
<octahedronGeometry args={[0.1, 0]} />
<meshNormalMaterial />
</mesh>
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
<mesh ref={standPointRef}>
<octahedronGeometry args={[0.12, 0]} />
<meshBasicMaterial color="red" opacity={0.2} transparent />
</mesh>
</group>
)}
</Suspense>
)
},
)
BVHEcctrl.displayName = 'BVHEcctrl'
export default BVHEcctrl
@@ -10,6 +10,7 @@ import {
ItemNode,
RoofSegmentNode,
type SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
sceneRegistry,
@@ -41,6 +42,7 @@ const ALLOWED_TYPES = [
'fence',
'slab',
'ceiling',
'spawn',
]
const DELETE_ONLY_TYPES: string[] = []
const HOLE_TYPES = ['slab', 'ceiling']
@@ -186,6 +188,7 @@ export function FloatingActionMenu() {
node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'spawn' ||
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
@@ -276,6 +279,8 @@ export function FloatingActionMenu() {
duplicate = StairNode.parse(duplicateInfo)
} else if (node.type === 'stair-segment') {
duplicate = StairSegmentNode.parse(duplicateInfo)
} else if (node.type === 'spawn') {
duplicate = SpawnNode.parse(duplicateInfo)
}
} catch (error) {
console.error('Failed to parse duplicate', error)
@@ -323,6 +328,7 @@ export function FloatingActionMenu() {
duplicate.type === 'window' ||
duplicate.type === 'door' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'spawn' ||
duplicate.type === 'stair-segment'
) {
setMovingNode(duplicate as any)
@@ -418,7 +424,10 @@ export function FloatingActionMenu() {
}
onDelete={handleDelete}
onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
node &&
node.type !== 'spawn' &&
!DELETE_ONLY_TYPES.includes(node.type) &&
!HOLE_TYPES.includes(node.type)
? handleDuplicate
: undefined
}
@@ -20,7 +20,6 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import {
@@ -940,9 +939,8 @@ export default function Editor({
presetsAdapter,
commandPaletteEmptyAction,
}: EditorProps) {
useKeyboard({ isVersionPreviewMode })
useAutoFrame()
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
@@ -953,7 +951,8 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -971,6 +970,39 @@ export default function Editor({
}
}, [projectId])
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
// Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => {
let cancelled = false
@@ -73,6 +73,7 @@ type SelectableNodeType =
| 'roof-segment'
| 'stair'
| 'stair-segment'
| 'spawn'
| 'window'
| 'door'
@@ -548,6 +549,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
],
@@ -598,7 +600,8 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
node.type === 'stair-segment' ||
node.type === 'spawn'
)
return true
if (node.type === 'item') {
@@ -661,6 +664,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'spawn' ||
node.type === 'window' ||
node.type === 'door'
) {
@@ -965,6 +969,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -1134,6 +1139,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
]
@@ -1227,6 +1233,7 @@ export const SelectionManager = () => {
node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'spawn' ||
node.type === 'window' ||
node.type === 'door'
) {
@@ -1279,6 +1286,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -7,6 +7,7 @@ import type {
RoofNode,
RoofSegmentNode,
SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
WallNode,
@@ -21,6 +22,7 @@ import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
@@ -86,7 +88,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</>
}
export const MoveTool: React.FC = () => {
export const MoveTool: React.FC<{
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
@@ -100,6 +104,8 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -309,8 +309,11 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
// Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -320,7 +323,7 @@ export const ceilingStrategy = {
},
cursorRotationY: 0,
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
@@ -338,12 +341,13 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
return {
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0,
nodeUpdate: null,
stopPropagation: true,
@@ -868,7 +868,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
lastRawPos.current.set(
event.localPosition[0],
event.localPosition[1],
event.localPosition[2],
)
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
@@ -0,0 +1,101 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type SpawnNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
export const MoveSpawnTool: React.FC<{
node: SpawnNode
onCommitted?: (nodeId: SpawnNode['id']) => void
}> = ({ node, onCommitted }) => {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setPreviewPosition(nextPosition)
useLiveTransforms.getState().set(node.id, {
position: [...nextPosition],
rotation: node.rotation,
})
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(node, event)
committed = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position: nextPosition })
onCommitted?.(node.id)
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useScene.temporal.getState().resume()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node, onCommitted])
return (
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
)
}
@@ -0,0 +1,130 @@
import '../../../three-types'
import {
emitter,
type GridEvent,
type LevelNode,
SpawnNode,
type SpawnNode as SpawnNodeType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const SPAWN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Spawn Point"
src="/icons/site.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const worldVector = new Vector3()
function getExistingSpawnIds() {
const nodes = useScene.getState().nodes
return Object.values(nodes)
.filter((node) => node.type === 'spawn')
.map((node) => node.id)
.sort()
}
function getLevelLocalSpawnPosition(
levelId: LevelNode['id'],
event: GridEvent,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
}
type SpawnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (spawnId: SpawnNodeType['id']) => void
}
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
if (existingSpawnId) {
useScene.getState().updateNode(existingSpawnId, {
parentId: currentLevelId,
position: nextPosition,
rotation: 0,
})
if (duplicateSpawnIds.length > 0) {
useScene.getState().deleteNodes(duplicateSpawnIds)
}
onPlaced?.(existingSpawnId)
} else {
const spawn = SpawnNode.parse({
name: 'Spawn Point',
position: nextPosition,
rotation: 0,
})
useScene.getState().createNode(spawn, currentLevelId)
onPlaced?.(spawn.id)
}
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#60a5fa"
height={2.2}
ref={cursorRef}
showTooltip
tooltipContent={SPAWN_ICON}
/>
)
}
@@ -21,6 +21,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { SpawnTool } from './spawn/spawn-tool'
import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
@@ -61,8 +62,10 @@ export const ToolManager: React.FC = () => {
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group
@@ -123,12 +126,15 @@ export const ToolManager: React.FC = () => {
const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
const handleSpawnSelected = (nodeId: `spawn_${string}`) => {
setSelection({ selectedIds: [nodeId] })
}
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{movingNode?.type === 'building' && <MoveTool />}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handleSpawnSelected} />}
{/* Building-local group: all other tools are relative to the selected building.
Cursor visuals set positions in building-local space; this group applies the
@@ -152,7 +158,12 @@ export const ToolManager: React.FC = () => {
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handleSpawnSelected} />
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
)}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</group>
</>
@@ -30,6 +30,7 @@ export const tools: ToolConfig[] = [
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
]
export function StructureTools() {
@@ -8,6 +8,7 @@ interface SliderControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
onCommit?: (value: number) => void
min?: number
max?: number
precision?: number
@@ -48,6 +49,7 @@ export function SliderControl({
label,
value,
onChange,
onCommit,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 0,
@@ -95,10 +97,11 @@ export function SliderControl({
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
onCommit?.(final)
}
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange])
}, [isEditing, step, clamp, onChange, onCommit])
// Arrow key support while hovered
useEffect(() => {
@@ -113,11 +116,12 @@ export function SliderControl({
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
onCommit?.(final)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange])
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
@@ -175,11 +179,13 @@ export function SliderControl({
onChange(originValue)
useScene.temporal.getState().resume()
onChange(finalVal)
onCommit?.(finalVal)
} else {
useScene.temporal.getState().resume()
onCommit?.(finalVal)
}
},
[onChange],
[onChange, onCommit],
)
const handleValueClick = useCallback(() => {
@@ -192,10 +198,12 @@ export function SliderControl({
if (Number.isNaN(numValue)) {
setInputValue(value.toFixed(precision))
} else {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
onChange(nextValue)
onCommit?.(nextValue)
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
}, [inputValue, onChange, onCommit, clamp, precision, value])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -8,11 +8,16 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { MoreVertical, Plus, Trash2 } from 'lucide-react'
import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from '../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { cn } from '../../lib/utils'
import { LevelDuplicateDialog } from './level-duplicate-dialog'
import {
Dialog,
DialogContent,
@@ -92,13 +97,16 @@ function LevelRow({
level,
isSelected,
onSelect,
onDuplicate,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void
}) {
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
return (
@@ -142,7 +150,29 @@ function LevelRow({
<MoreVertical className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}>
<PopoverContent align="start" className="w-44 p-1" side="right" sideOffset={8}>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onDuplicate()
}}
type="button"
>
<Copy className="h-3 w-3" />
Duplicate level
</button>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setDuplicateDialogOpen(true)
}}
type="button"
>
<Copy className="h-3 w-3" />
Duplicate with options...
</button>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => {
@@ -158,6 +188,15 @@ function LevelRow({
</Popover>
</div>
)}
<LevelDuplicateDialog
level={level}
onConfirm={(preset) => {
onDuplicate(preset)
setDuplicateDialogOpen(false)
}}
onOpenChange={setDuplicateDialogOpen}
open={duplicateDialogOpen}
/>
</div>
)
}
@@ -169,6 +208,7 @@ export function FloatingLevelSelector() {
const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection)
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
@@ -251,6 +291,33 @@ export function FloatingLevelSelector() {
setDeletingLevel(null)
}, [deletingLevel])
const handleDuplicateLevel = useCallback(
(level: LevelNode, preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes,
level,
levels,
preset,
})
if (shiftedLevels.length > 0) {
updateNodes(
shiftedLevels.map((shiftedLevel) => ({
id: shiftedLevel.id as AnyNodeId,
data: { level: shiftedLevel.level } as Partial<AnyNode>,
})),
)
}
createNodes(createOps)
setSelection({
buildingId: resolvedBuildingId ?? undefined,
levelId: newLevelId as LevelNode['id'],
})
},
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
)
if (levels.length === 0) return null
const reversedLevels = [...levels].reverse()
@@ -294,6 +361,7 @@ export function FloatingLevelSelector() {
<LevelRow
isSelected={isSelected}
level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection(
@@ -9,6 +9,7 @@ import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
@@ -17,6 +18,10 @@ export function HelperManager() {
return <ItemHelper showEsc />
}
if (mode === 'material-paint') {
return null
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
@@ -0,0 +1,115 @@
'use client'
import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './primitives/dialog'
const DUPLICATE_PRESETS: Array<{
id: LevelDuplicatePreset
label: string
description: string
}> = [
{
id: 'everything',
label: 'Everything',
description: 'Structure, materials, furniture, and references.',
},
{
id: 'structure',
label: 'Structure only',
description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.',
},
{
id: 'structure-materials',
label: 'Structure + materials',
description: 'Structure with the current material and finish assignments.',
},
{
id: 'structure-furniture',
label: 'Structure + furniture',
description: 'Structure, finishes, and placed items, without guide references.',
},
]
function getLevelLabel(level: LevelNode | null) {
if (!level) return 'this level'
return level.name || `Level ${level.level}`
}
export function LevelDuplicateDialog({
open,
level,
onConfirm,
onOpenChange,
}: {
open: boolean
level: LevelNode | null
onConfirm: (preset: LevelDuplicatePreset) => void
onOpenChange: (open: boolean) => void
}) {
const [preset, setPreset] = useState<LevelDuplicatePreset>('everything')
useEffect(() => {
if (open) {
setPreset('everything')
}
}, [open])
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Duplicate Level</DialogTitle>
<DialogDescription>
Choose what to copy from {getLevelLabel(level)}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
{DUPLICATE_PRESETS.map((option) => (
<button
className={cn(
'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
preset === option.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background hover:bg-accent/40',
)}
key={option.id}
onClick={() => setPreset(option.id)}
type="button"
>
<div className="font-medium text-sm">{option.label}</div>
<div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
</button>
))}
</div>
<DialogFooter>
<button
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
onClick={() => onOpenChange(false)}
type="button"
>
Cancel
</button>
<button
className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
onClick={() => onConfirm(preset)}
type="button"
>
Duplicate
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
'use client'
import useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper'
@@ -77,67 +78,41 @@ export function PaintPanel() {
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Roughness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.roughness.toFixed(2)}
</span>
<div className="space-y-1">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Surface
</label>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
<SliderControl
label="Roughness"
max={1}
min={0}
onChange={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
step={0.01}
value={currentProps.roughness}
/>
<SliderControl
label="Metalness"
max={1}
min={0}
onChange={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
step={0.01}
value={currentProps.metalness}
/>
<SliderControl
label="Opacity"
max={1}
min={0}
onChange={(opacity) =>
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}
precision={2}
step={0.01}
value={currentProps.opacity}
/>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<div className="space-y-2">
@@ -34,6 +34,7 @@ import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
import { SlabPanel } from './slab-panel'
import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
@@ -231,5 +232,34 @@ export function PanelManager() {
}
// Show appropriate panel based on selected node type
return panelForType(selectedNodeType)
if (selectedNodeType) {
switch (selectedNodeType) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
return null
}
@@ -0,0 +1,155 @@
'use client'
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function SpawnPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
)
const [draftRotation, setDraftRotation] = useState<number | null>(null)
useEffect(() => {
if (!(node && node.type === 'spawn')) {
setDraftRotation(null)
return
}
setDraftRotation(node.rotation)
useLiveTransforms.getState().clear(node.id)
}, [node?.id, node?.rotation, node?.type])
const handleUpdate = useCallback(
(updates: Partial<SpawnNode>) => {
if (!(selectedId && node)) return
updateNode(selectedId as AnyNode['id'], updates)
},
[node, selectedId, updateNode],
)
const handleRotationChange = useCallback(
(degrees: number) => {
if (!(node && selectedId)) return
const nextRotation = (degrees * Math.PI) / 180
setDraftRotation(nextRotation)
useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
position: [...node.position],
rotation: nextRotation,
})
},
[node, selectedId],
)
const commitRotation = useCallback(
(degrees: number) => {
if (!(node && selectedId)) return
const nextRotation = (degrees * Math.PI) / 180
useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
setDraftRotation(nextRotation)
if (Math.abs(nextRotation - node.rotation) > 1e-6) {
updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
}
},
[node, selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
if (!(node && node.type === 'spawn' && selectedId)) return null
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
<PanelWrapper icon="/icons/spawn-point.svg" onClose={handleClose} title="Spawn Point" width={300}>
<PanelSection title="Position">
<SliderControl
label="X"
max={node.position[0] + 2}
min={node.position[0] - 2}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={node.position[1] + 2}
min={node.position[1] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={node.position[2] + 2}
min={node.position[2] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
</PanelSection>
<PanelSection title="Facing">
<SliderControl
label="Yaw"
max={storedRotationDegrees + 90}
min={storedRotationDegrees - 90}
onChange={handleRotationChange}
onCommit={commitRotation}
precision={0}
step={1}
unit="°"
value={rotationDegrees}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -262,7 +262,7 @@ export function StairPanel() {
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<MetricControl
<SliderControl
label="Opening Offset"
max={0.5}
min={0}
@@ -308,7 +308,7 @@ export function StairPanel() {
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry">
<MetricControl
<SliderControl
label="Width"
max={10}
min={0.4}
@@ -318,7 +318,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.width ?? 1) * 100) / 100}
/>
<MetricControl
<SliderControl
label="Rise"
max={10}
min={0.2}
@@ -328,7 +328,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/>
<MetricControl
<SliderControl
label="Steps"
max={32}
min={2}
@@ -346,7 +346,7 @@ export function StairPanel() {
/>
)}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<MetricControl
<SliderControl
label="Thickness"
max={1}
min={0.02}
@@ -357,7 +357,7 @@ export function StairPanel() {
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
<MetricControl
<SliderControl
label="Inner Radius"
max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -385,7 +385,7 @@ export function StairPanel() {
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<MetricControl
<SliderControl
label="Top Landing"
max={5}
min={0.3}
@@ -1,4 +1,4 @@
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Building2, Plus } from 'lucide-react'
import { memo, useState } from 'react'
@@ -12,7 +12,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface BuildingTreeNodeProps {
nodeId: AnyNodeId
nodeId: BuildingNode['id']
depth: number
isLast?: boolean
}
@@ -3,9 +3,9 @@ import {
type AnyNodeId,
type BuildingNode,
emitter,
type GuideNode,
GuideNode,
LevelNode,
type ScanNode,
ScanNode,
type SiteNode,
useScene,
type ZoneNode,
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import {
Camera,
ChevronDown,
Copy,
Loader2,
MoreHorizontal,
Pencil,
@@ -33,9 +34,16 @@ import {
} from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication'
import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode } from './tree-node'
import { TreeNodeDragProvider } from './tree-node-drag'
@@ -361,7 +369,7 @@ const ReferenceItem = memo(function ReferenceItem({
<InlineRenameInput
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
isEditing={isEditing}
node={refNode}
nodeId={refNode.id}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
@@ -578,6 +586,7 @@ const LevelReferences = memo(function LevelReferences({
const LevelItem = memo(function LevelItem({
level,
levels,
selectedLevelId,
setSelection,
updateNode,
@@ -587,6 +596,7 @@ const LevelItem = memo(function LevelItem({
onDeleteAsset,
}: {
level: LevelNode
levels: LevelNode[]
selectedLevelId: string | null
setSelection: (selection: any) => void
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
@@ -596,11 +606,22 @@ const LevelItem = memo(function LevelItem({
onDeleteAsset?: (projectId: string, url: string) => void
}) {
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const createNodes = useScene((s) => s.createNodes)
const updateNodes = useScene((s) => s.updateNodes)
const itemRef = useRef<HTMLDivElement>(null)
const isSelected = selectedLevelId === level.id
const canDeleteLevel = level.level !== 0
const [isExpanded, setIsExpanded] = useState(isSelected)
const buildingId =
typeof level.parentId === 'string' && level.parentId.startsWith('building_')
? (level.parentId as BuildingNode['id'])
: undefined
const selectLevel = (levelId: LevelNode['id']) => {
setSelection(buildingId ? { buildingId, levelId } : { levelId })
}
useEffect(() => {
setIsExpanded(isSelected)
@@ -613,13 +634,34 @@ const LevelItem = memo(function LevelItem({
}, [isSelected])
const handleSelect = () => {
setSelection({ levelId: level.id })
selectLevel(level.id)
}
const handleDoubleClick = () => {
focusTreeNode(level.id)
}
const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes,
level,
levels,
preset,
})
if (shiftedLevels.length > 0) {
updateNodes(
shiftedLevels.map((shiftedLevel) => ({
id: shiftedLevel.id as AnyNodeId,
data: { level: shiftedLevel.level } as Partial<AnyNode>,
})),
)
}
createNodes(createOps)
selectLevel(newLevelId as LevelNode['id'])
setDuplicateDialogOpen(false)
}
return (
<div className="relative flex flex-col">
<div
@@ -661,7 +703,7 @@ const LevelItem = memo(function LevelItem({
if (isSelected) {
setIsExpanded(!isExpanded)
} else {
setSelection({ levelId: level.id })
selectLevel(level.id)
}
}}
>
@@ -685,7 +727,7 @@ const LevelItem = memo(function LevelItem({
<InlineRenameInput
defaultName={`Level ${level.level}`}
isEditing={isEditing}
node={level}
nodeId={level.id}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
@@ -770,7 +812,23 @@ const LevelItem = memo(function LevelItem({
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-40 p-1" side="right">
<PopoverContent align="start" className="w-48 p-1" side="right">
<button
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
onClick={() => handleDuplicateLevel()}
title="Duplicate level"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</button>
<button
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
onClick={() => setDuplicateDialogOpen(true)}
title="Duplicate level with options"
>
<Copy className="h-3.5 w-3.5" />
Duplicate with options...
</button>
<button
className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canDeleteLevel}
@@ -802,6 +860,12 @@ const LevelItem = memo(function LevelItem({
</motion.div>
)}
</AnimatePresence>
<LevelDuplicateDialog
level={level}
onConfirm={handleDuplicateLevel}
onOpenChange={setDuplicateDialogOpen}
open={duplicateDialogOpen}
/>
</div>
)
})
@@ -844,7 +908,7 @@ const LevelsSection = memo(function LevelsSection({
parentId: building.id,
})
createNode(newLevel, building.id)
setSelection({ levelId: newLevel.id })
setSelection({ buildingId: building.id, levelId: newLevel.id })
}
return (
@@ -879,6 +943,7 @@ const LevelsSection = memo(function LevelsSection({
isLast={index === levels.length - 1}
key={level.id}
level={level}
levels={levels}
onDeleteAsset={onDeleteAsset}
onUploadAsset={onUploadAsset}
projectId={projectId}
@@ -1107,7 +1172,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
<InlineRenameInput
defaultName={defaultName}
isEditing={isEditing}
node={zone}
nodeId={zone.id}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
@@ -1,4 +1,4 @@
import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
import { type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react'
import { memo, useCallback, useState } from 'react'
@@ -8,7 +8,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface LevelTreeNodeProps {
nodeId: AnyNodeId
nodeId: LevelNode['id']
depth: number
isLast?: boolean
}
@@ -0,0 +1,82 @@
'use client'
import { type SpawnNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface SpawnTreeNodeProps {
nodeId: SpawnNode['id']
depth: number
isLast?: boolean
}
export const SpawnTreeNode = memo(function SpawnTreeNode({
nodeId,
depth,
isLast,
}: SpawnTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain"
height={14}
src="/icons/spawn-point.svg"
width={14}
/>
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={isVisible}
label={
<InlineRenameInput
defaultName="Spawn Point"
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
)
})
@@ -62,6 +62,7 @@ import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node'
import { RoofTreeNode } from './roof-tree-node'
import { SlabTreeNode } from './slab-tree-node'
import { SpawnTreeNode } from './spawn-tree-node'
import { StairTreeNode } from './stair-tree-node'
import { WallTreeNode } from './wall-tree-node'
import { WindowTreeNode } from './window-tree-node'
@@ -80,13 +81,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
switch (nodeType) {
case 'building':
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'slab':
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'spawn':
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'fence':
@@ -102,7 +105,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
case 'window':
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'zone':
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
default:
return null
}
@@ -1,4 +1,4 @@
import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
import { useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useCallback, useState } from 'react'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
@@ -7,7 +7,7 @@ import { focusTreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ZoneTreeNodeProps {
nodeId: AnyNodeId
nodeId: ZoneNode['id']
depth: number
isLast?: boolean
}
@@ -44,7 +44,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
depth={depth}
expanded={false}
hasChildren={false}
icon={<ColorDot color={color} onChange={(c) => updateNode(nodeId, { color: c })} />}
icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
@@ -78,8 +78,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1]
area -= polygon[j]?.[0] * polygon[i]?.[1]
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
}
return Math.abs(area) / 2
+12 -5
View File
@@ -12,8 +12,18 @@ export const markToolCancelConsumed = () => {
_toolCancelConsumed = true
}
export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
export const useKeyboard = ({
isVersionPreviewMode = false,
disabled = false,
}: {
isVersionPreviewMode?: boolean
disabled?: boolean
} = {}) => {
useEffect(() => {
if (disabled) {
return
}
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -21,9 +31,6 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
}
if (e.key === 'Escape') {
// If in walkthrough mode, let WalkthroughControls handle ESC
if (useViewer.getState().walkthroughMode) return
e.preventDefault()
_toolCancelConsumed = false
emitter.emit('tool:cancel')
@@ -220,7 +227,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isVersionPreviewMode])
}, [disabled, isVersionPreviewMode])
return null
}
@@ -0,0 +1,72 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
SpawnNode,
WallNode,
} from '@pascal-app/core/schema'
import { buildLevelDuplicateCreateOps } from './level-duplication'
describe('buildLevelDuplicateCreateOps', () => {
test('parents a duplicated bootstrap level back to its building', () => {
const level = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level.id] })
const wall = WallNode.parse({
parentId: level.id,
start: [0, 0],
end: [4, 0],
})
const sourceLevel = { ...level, children: [wall.id] } satisfies LevelNode
const nodes = {
[building.id]: building,
[sourceLevel.id]: sourceLevel,
[wall.id]: wall,
} as Record<AnyNodeId, AnyNode>
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
nodes,
level: sourceLevel,
levels: [sourceLevel],
preset: 'everything',
})
const levelCreateOp = createOps.find((op) => op.node.id === newLevelId)
expect(sourceLevel.parentId).toBeNull()
expect(levelCreateOp?.parentId).toBe(building.id)
})
test('does not copy spawn points from the source level', () => {
const building = BuildingNode.parse({})
const spawn = SpawnNode.parse({ parentId: 'level_source' })
const level = LevelNode.parse({
id: 'level_source',
level: 0,
parentId: building.id,
children: [spawn.id],
})
const nodes = {
[building.id]: { ...building, children: [level.id] },
[level.id]: level,
[spawn.id]: spawn,
} as Record<AnyNodeId, AnyNode>
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
nodes,
level,
levels: [level],
preset: 'everything',
})
const copiedLevel = createOps.find((op) => op.node.id === newLevelId)?.node as
| LevelNode
| undefined
expect(createOps.some((op) => op.node.type === 'spawn')).toBe(false)
expect(copiedLevel?.children).toEqual([])
})
})
@@ -0,0 +1,153 @@
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema'
export type LevelDuplicatePreset =
| 'everything'
| 'structure'
| 'structure-materials'
| 'structure-furniture'
const NON_DUPLICABLE_NODE_TYPES = new Set<AnyNode['type']>(['scan', 'guide', 'spawn'])
const STRUCTURAL_NODE_TYPES = new Set<AnyNode['type']>([
'level',
'wall',
'fence',
'zone',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
])
function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
if (NON_DUPLICABLE_NODE_TYPES.has(node.type)) return false
if (preset === 'everything') return true
if (preset === 'structure-furniture') return true
if (preset === 'structure' || preset === 'structure-materials') {
return STRUCTURAL_NODE_TYPES.has(node.type)
}
return true
}
function stripMaterials(node: AnyNode): AnyNode {
const next = { ...node } as Record<string, unknown>
switch (node.type) {
case 'wall':
delete next.material
delete next.materialPreset
delete next.interiorMaterial
delete next.interiorMaterialPreset
delete next.exteriorMaterial
delete next.exteriorMaterialPreset
break
case 'slab':
case 'ceiling':
case 'fence':
case 'roof-segment':
case 'stair-segment':
case 'window':
case 'door':
delete next.material
delete next.materialPreset
break
case 'roof':
delete next.material
delete next.materialPreset
delete next.topMaterial
delete next.topMaterialPreset
delete next.edgeMaterial
delete next.edgeMaterialPreset
delete next.wallMaterial
delete next.wallMaterialPreset
break
case 'stair':
delete next.material
delete next.materialPreset
delete next.railingMaterial
delete next.railingMaterialPreset
delete next.treadMaterial
delete next.treadMaterialPreset
delete next.sideMaterial
delete next.sideMaterialPreset
break
}
return next as AnyNode
}
function findLevelBuildingId(nodes: Record<AnyNodeId, AnyNode>, levelId: AnyNodeId) {
for (const node of Object.values(nodes)) {
if (node.type !== 'building' || !('children' in node) || !Array.isArray(node.children)) {
continue
}
if ((node.children as AnyNodeId[]).includes(levelId)) {
return node.id as AnyNodeId
}
}
return undefined
}
export function buildLevelDuplicateCreateOps({
nodes,
level,
levels,
preset,
}: {
nodes: Record<AnyNodeId, AnyNode>
level: LevelNode
levels: LevelNode[]
preset: LevelDuplicatePreset
}) {
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
const parentBuildingId =
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
const nextLevelNumber = level.level + 1
const shiftedLevels = levels
.filter((entry) => entry.id !== level.id && entry.level >= nextLevelNumber)
.map((entry) => ({
id: entry.id,
level: entry.level + 1,
}))
const filteredNodes = clonedNodes
.filter((node) => shouldKeepNode(node, preset))
.map((node) => (preset === 'structure' ? stripMaterials(node) : node))
const keptIds = new Set(filteredNodes.map((node) => node.id))
const cleanedNodes = filteredNodes.map((node) => {
if (!('children' in node) || !Array.isArray(node.children)) {
return node
}
return {
...node,
children: node.children.filter((childId) => keptIds.has(childId as AnyNodeId)),
} as AnyNode
})
return {
createOps: cleanedNodes.map((node) => ({
node:
node.id === newLevelId
? ({
...node,
level: nextLevelNumber,
} as AnyNode)
: node,
parentId:
node.id === newLevelId
? parentBuildingId
: ((node.parentId as AnyNodeId | null) ?? undefined),
})),
newLevelId,
shiftedLevels,
}
}
+4 -3
View File
@@ -11,6 +11,7 @@ import {
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type SpawnNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type Space,
@@ -59,6 +60,7 @@ export type StructureTool =
| 'stair'
| 'item'
| 'zone'
| 'spawn'
| 'window'
| 'door'
@@ -136,6 +138,7 @@ type EditorState = {
| WallNode
| RoofNode
| RoofSegmentNode
| SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
@@ -151,6 +154,7 @@ type EditorState = {
| WallNode
| RoofNode
| RoofSegmentNode
| SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
@@ -676,8 +680,6 @@ const useEditor = create<EditorState>()(
setFirstPersonMode: (enabled) => {
if (enabled) {
const currentViewMode = get().viewMode
useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up')
set({
isFirstPersonMode: true,
_viewModeBeforeFirstPerson: currentViewMode,
@@ -687,7 +689,6 @@ const useEditor = create<EditorState>()(
tool: null,
catalogCategory: null,
})
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
const prevMode = get()._viewModeBeforeFirstPerson
set({
@@ -401,6 +401,27 @@ describe('SceneBridge', () => {
})
describe('setScene / exportJSON / loadJSON', () => {
test('setScene prunes duplicated levels that were accidentally saved as roots', () => {
const level0 = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level0.id] })
const site = SiteNode.parse({ children: [building] })
const orphanLevel = LevelNode.parse({ level: 1, children: [] })
bridge.setScene(
{
[site.id]: site,
[building.id]: building,
[level0.id]: level0,
[orphanLevel.id]: orphanLevel,
} as any,
[site.id, orphanLevel.id] as any,
)
expect(bridge.getRootNodeIds()).toEqual([site.id])
expect(bridge.getNode(orphanLevel.id)).toBeNull()
expect(bridge.findNodes({ type: 'level' }).map((node) => node.id)).toEqual([level0.id])
})
test('exportJSON returns the scene shape', () => {
const exp = bridge.exportJSON()
expect(typeof exp.nodes).toBe('object')
@@ -13,6 +13,7 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { WallRenderer } from './wall/wall-renderer'
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'spawn' && <SpawnRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'fence' && <FenceRenderer node={node} />}
{node.type === 'door' && <DoorRenderer node={node} />}
@@ -0,0 +1,68 @@
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type { Group } from 'three'
import { Color, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
const SPAWN_COLOR = new Color('#22c55e')
export const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode)
useRegistry(node.id, 'spawn', ref)
const materialProps = useMemo(
() => ({
color: SPAWN_COLOR,
emissive: SPAWN_COLOR,
emissiveIntensity: 0.08,
metalness: 0.03,
roughness: 0.42,
}),
[],
)
const arrowShape = useMemo(() => {
const shape = new Shape()
// Positive local Y becomes negative world Z after the -90deg X rotation below,
// so this tip points "forward" for the player/spawn direction.
shape.moveTo(0, 0.24)
shape.lineTo(-0.18, -0.14)
shape.lineTo(0.18, -0.14)
shape.closePath()
return shape
}, [])
return (
<group
position={liveTransform?.position ?? node.position}
ref={ref}
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
visible={!walkthroughMode}
>
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.1, -0.52]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<shapeGeometry args={[arrowShape]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.36, 0]} {...handlers}>
<cylinderGeometry args={[0.08, 0.08, 0.54, 24]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.68, 0]} {...handlers}>
<sphereGeometry args={[0.16, 24, 16]} />
<meshStandardMaterial {...materialProps} />
</mesh>
</group>
)
}
@@ -21,6 +21,8 @@ import {
type SiteNode,
type SlabEvent,
type SlabNode,
type SpawnEvent,
type SpawnNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
@@ -44,6 +46,7 @@ type NodeConfig = {
level: { node: LevelNode; event: LevelEvent }
zone: { node: ZoneNode; event: ZoneEvent }
slab: { node: SlabNode; event: SlabEvent }
spawn: { node: SpawnNode; event: SpawnEvent }
ceiling: { node: CeilingNode; event: CeilingEvent }
roof: { node: RoofNode; event: RoofEvent }
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }