From 025a9b5d289fc25377082f37bd633224f556a6fb Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Apr 2026 11:09:51 +0530 Subject: [PATCH 1/8] Add duplicate options to level menus --- .../tools/item/placement-strategies.ts | 16 ++- .../tools/item/use-placement-coordinator.tsx | 6 +- .../components/ui/floating-level-selector.tsx | 61 ++++++++- .../components/ui/helpers/helper-manager.tsx | 5 + .../components/ui/level-duplicate-dialog.tsx | 115 ++++++++++++++++ .../src/components/ui/panels/paint-panel.tsx | 95 +++++-------- .../ui/sidebar/panels/site-panel/index.tsx | 52 +++++++- packages/editor/src/lib/level-duplication.ts | 126 ++++++++++++++++++ 8 files changed, 404 insertions(+), 72 deletions(-) create mode 100644 packages/editor/src/components/ui/level-duplicate-dialog.tsx create mode 100644 packages/editor/src/lib/level-duplication.ts diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 9d89ddf25..f20f8745e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -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, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index e1fb7442e..64bdcae21 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -614,7 +614,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 diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index f83380c27..d1353d45d 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -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({ - + + + + ))} + + + + + + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index ffa8ec9d0..dabd70bcf 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -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() { -
-
- - - {currentProps.roughness.toFixed(2)} - -
- updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} - step={0.01} - type="range" - value={currentProps.roughness} - /> -
- -
-
- - - {currentProps.metalness.toFixed(2)} - -
- updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} - step={0.01} - type="range" - value={currentProps.metalness} - /> -
- -
-
- - - {currentProps.opacity.toFixed(2)} - +
+ +
+ updateCustomMaterial({ roughness })} + precision={2} + step={0.01} + value={currentProps.roughness} + /> + updateCustomMaterial({ metalness })} + precision={2} + step={0.01} + value={currentProps.metalness} + /> + + updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) + } + precision={2} + step={0.01} + value={currentProps.opacity} + />
- { - const opacity = Number.parseFloat(e.target.value) - updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) - }} - step={0.01} - type="range" - value={currentProps.opacity} - />
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index b88c6a602..c1eac3139 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -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, @@ -32,9 +33,14 @@ import { PopoverTrigger, } from './../../../../../components/ui/primitives/popover' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' +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' @@ -558,6 +564,7 @@ const LevelReferences = memo(function LevelReferences({ const LevelItem = memo(function LevelItem({ level, + levels, selectedLevelId, setSelection, updateNode, @@ -567,6 +574,7 @@ const LevelItem = memo(function LevelItem({ onDeleteAsset, }: { level: LevelNode + levels: LevelNode[] selectedLevelId: string | null setSelection: (selection: any) => void updateNode: (id: AnyNodeId, updates: Partial) => void @@ -576,7 +584,9 @@ 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 itemRef = useRef(null) const isSelected = selectedLevelId === level.id const canDeleteLevel = level.level !== 0 @@ -600,6 +610,19 @@ const LevelItem = memo(function LevelItem({ focusTreeNode(level.id) } + const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => { + const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ + nodes: useScene.getState().nodes, + level, + levels, + preset, + }) + + createNodes(createOps) + setSelection({ levelId: newLevelId }) + setDuplicateDialogOpen(false) + } + return (
- + + +
) }) @@ -859,6 +904,7 @@ const LevelsSection = memo(function LevelsSection({ isLast={index === levels.length - 1} key={level.id} level={level} + levels={levels} onDeleteAsset={onDeleteAsset} onUploadAsset={onUploadAsset} projectId={projectId} diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts new file mode 100644 index 000000000..8f0f10409 --- /dev/null +++ b/packages/editor/src/lib/level-duplication.ts @@ -0,0 +1,126 @@ +import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core' +import { cloneLevelSubtree } from '@pascal-app/core' + +export type LevelDuplicatePreset = + | 'everything' + | 'structure' + | 'structure-materials' + | 'structure-furniture' + +const REFERENCE_NODE_TYPES = new Set(['scan', 'guide']) +const STRUCTURAL_NODE_TYPES = new Set([ + 'level', + 'wall', + 'fence', + 'zone', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'window', + 'door', +]) + +function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { + if (preset === 'everything') return true + if (preset === 'structure-furniture') return !REFERENCE_NODE_TYPES.has(node.type) + 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 + + 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 +} + +export function buildLevelDuplicateCreateOps({ + nodes, + level, + levels, + preset, +}: { + nodes: Record + level: LevelNode + levels: LevelNode[] + preset: LevelDuplicatePreset +}) { + const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id) + const nextLevelNumber = Math.max(...levels.map((entry) => entry.level), -1) + 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.parentId as AnyNodeId | undefined, + })), + newLevelId, + } +} From bead8796d247f230ae4409f99538df0e79e49203 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Apr 2026 14:26:24 +0530 Subject: [PATCH 2/8] Add spawn node support and refine stair and door cutouts --- packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/door.ts | 2 +- packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/nodes/spawn.ts | 11 + packages/core/src/schema/types.ts | 2 + .../core/src/systems/door/door-system.tsx | 46 +- .../src/systems/stair/stair-opening-sync.ts | 198 ++++- packages/editor/package.json | 1 + .../editor/custom-camera-controls.tsx | 8 +- .../editor/first-person-controls.tsx | 386 +++++---- .../first-person/build-collider-world.ts | 262 ++++++ .../editor/first-person/bvh-ecctrl.tsx | 795 ++++++++++++++++++ .../editor/floating-action-menu.tsx | 11 +- .../editor/src/components/editor/index.tsx | 5 +- .../components/editor/selection-manager.tsx | 10 +- .../src/components/tools/item/move-tool.tsx | 3 + .../tools/spawn/move-spawn-tool.tsx | 99 +++ .../src/components/tools/spawn/spawn-tool.tsx | 126 +++ .../src/components/tools/tool-manager.tsx | 2 + .../ui/action-menu/structure-tools.tsx | 1 + .../components/ui/controls/slider-control.tsx | 18 +- .../components/ui/floating-level-selector.tsx | 5 +- .../components/ui/panels/panel-manager.tsx | 3 + .../src/components/ui/panels/spawn-panel.tsx | 155 ++++ .../panels/site-panel/building-tree-node.tsx | 4 +- .../ui/sidebar/panels/site-panel/index.tsx | 6 +- .../panels/site-panel/level-tree-node.tsx | 4 +- .../panels/site-panel/spawn-tree-node.tsx | 82 ++ .../sidebar/panels/site-panel/tree-node.tsx | 9 +- .../panels/site-panel/zone-tree-node.tsx | 13 +- packages/editor/src/hooks/use-keyboard.ts | 17 +- packages/editor/src/store/use-editor.tsx | 23 + .../components/renderers/node-renderer.tsx | 2 + .../renderers/spawn/spawn-renderer.tsx | 80 ++ packages/viewer/src/hooks/use-node-events.ts | 3 + public/icons/spawn-point.svg | 7 + 39 files changed, 2157 insertions(+), 250 deletions(-) create mode 100644 packages/core/src/schema/nodes/spawn.ts create mode 100644 packages/editor/src/components/editor/first-person/build-collider-world.ts create mode 100644 packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx create mode 100644 packages/editor/src/components/tools/spawn/move-spawn-tool.tsx create mode 100644 packages/editor/src/components/tools/spawn/spawn-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/spawn-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx create mode 100644 public/icons/spawn-point.svg diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6f61b2f70..9aca04740 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -12,6 +12,7 @@ import type { RoofSegmentNode, SiteNode, SlabNode, + SpawnNode, StairNode, StairSegmentNode, WallNode, @@ -53,6 +54,7 @@ export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent +export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent @@ -144,6 +146,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> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index c44e7909d..ec727481e 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -18,6 +18,7 @@ export const sceneRegistry = { fence: new Set(), item: new Set(), slab: new Set(), + spawn: new Set(), zone: new Set(), roof: new Set(), 'roof-segment': new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ef4e5450..fc01f3be6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { RoofSegmentEvent, SiteEvent, SlabEvent, + SpawnEvent, StairEvent, StairSegmentEvent, WallEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1383216df..a86f1a716 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -50,6 +50,7 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' +export { SpawnNode } from './nodes/spawn' export { getEffectiveStairSurfaceMaterial, StairNode, diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 9d8ad95d3..519ac6a40 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -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 `) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 4761161c0..60c4e2582 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -7,6 +7,7 @@ import { GuideNode } from './guide' 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' @@ -26,6 +27,7 @@ export const LevelNode = BaseNode.extend({ StairNode.shape.id, ScanNode.shape.id, GuideNode.shape.id, + SpawnNode.shape.id, ]), ) .default([]), diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts new file mode 100644 index 000000000..521d3f810 --- /dev/null +++ b/packages/core/src/schema/nodes/spawn.ts @@ -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 diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1b17a6b05..00e07fa19 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -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, ]) diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 24dacaa19..b9b5c9e9d 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -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 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 3cf828f8d..41fdddca3 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,4 +1,12 @@ -import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +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' @@ -27,9 +35,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)) @@ -58,7 +67,8 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[] if (left.length !== right.length) return false return left.every( (entry, index) => - entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null), + entry.source === right[index]?.source && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), ) } @@ -178,7 +188,10 @@ function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { return (stair.children ?? []) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) - .filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) } function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D { @@ -186,7 +199,10 @@ function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Poi return [stair.position[0] + worldX, stair.position[2] + worldZ] } -function getStraightStairLayouts(stair: StairNode, nodes: Record): StraightStairLayout[] { +function getStraightStairLayouts( + stair: StairNode, + nodes: Record, +): StraightStairLayout[] { const segments = resolveStraightSegments(stair, nodes) const transforms = computeSegmentTransforms(segments) @@ -204,7 +220,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record toWorldPlanPoint(stair, x, z)) + return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => + toWorldPlanPoint(stair, x, z), + ) } function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) { - const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1)) + const treadDepth = Math.max( + 0.2, + segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1), + ) return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8)) } @@ -289,12 +313,16 @@ function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect { function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { if (rects.length === 0) return [] - const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) - const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) + const xs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) + const zs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) if (xs.length < 2 || zs.length < 2) return [] const occupied = new Set() @@ -367,24 +395,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[] = [] @@ -392,19 +435,56 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { for (let index = 0; index <= segmentCount; index++) { const t = index / segmentCount - const angle = startAngle + (endAngle - startAngle) * t - outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius)) + const angle = startAngle + sweep * t + outerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), + ) } for (let index = segmentCount; index >= 0; index--) { const t = index / segmentCount - const angle = startAngle + (endAngle - startAngle) * t - innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius)) + const angle = startAngle + sweep * t + innerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), + ) } 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 @@ -440,7 +520,11 @@ function getStraightOpeningPolygonsForSurface( if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) { const openingDepth = getStraightFlightOpeningDepth(stair, segment) const flightRect = getAxisAlignedRectFromPolygon( - getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length), + getStraightSegmentLocalSlicePolygon( + layout, + Math.max(0, segment.length - openingDepth), + segment.length, + ), ) if (flightRect) openingRects.push(expandRect(flightRect, openingOffset)) } @@ -452,7 +536,9 @@ function getStraightOpeningPolygonsForSurface( } const landingRects: AxisAlignedRect[] = [] - const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length)) + const landingRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length), + ) if (landingRect) landingRects.push(expandRect(landingRect, openingOffset)) const previous = layouts[index - 1] if (previous?.segment.segmentType === 'stair') { @@ -503,7 +589,7 @@ function getStairOpeningPolygons( } if (stair.stairType === 'curved') { - return [getCurvedOpeningPolygon(stair)] + return [getCurvedOpeningPolygon(stair, targetElevation)] } if (stair.stairType === 'spiral') { @@ -556,10 +642,18 @@ function getTargetCeilingElevationForStair( return ceiling.height ?? DEFAULT_WALL_HEIGHT } - return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0) + return ( + (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - + (stair.position[1] ?? 0) + ) } -function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record) { +function shouldApplyStairToSlab( + stair: StairNode, + slabLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -578,7 +672,11 @@ function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Re return slabLevel > minLevel && slabLevel <= maxLevel } -function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record) { +function shouldApplyStairToCeiling( + stair: StairNode, + ceilingLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -598,16 +696,22 @@ function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nod } export function syncAutoStairOpenings(nodes: Record) { - const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false) + const stairs = Object.values(nodes).filter( + (node): node is StairNode => node.type === 'stair' && node.visible !== false, + ) const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') - const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) const updates: Array<{ id: AnyNodeId; data: Partial }> = [] for (const slab of slabs) { const slabLevelId = resolveLevelId(slab, nodes) const existingHoles = slab.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -637,7 +741,10 @@ export function syncAutoStairOpenings(nodes: Record) { const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: slab.id, data: { @@ -652,7 +759,9 @@ export function syncAutoStairOpenings(nodes: Record) { const ceilingLevelId = resolveLevelId(ceiling, nodes) const existingHoles = ceiling.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -682,7 +791,10 @@ export function syncAutoStairOpenings(nodes: Record) { const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: ceiling.id, data: { diff --git a/packages/editor/package.json b/packages/editor/package.json index 0b0eb3c30..dbe2e5d04 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -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" }, diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0bee75838..8224220c4 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,7 +1,7 @@ 'use client' import { type CameraControlEvent, emitter, 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' @@ -22,7 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 export const CustomCameraControls = () => { const controls = useRef(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 @@ -365,8 +365,8 @@ export const CustomCameraControls = () => { useViewer.getState().setCameraDragging(false) }, []) - if (walkthroughMode) { - return + if (isFirstPersonMode) { + return null } return ( diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b90bcdf35..b6365ce90 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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' - -// 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 - -// 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') +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' + +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'] }, +] + +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['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>(new Set()) + const selectedLevelId = useViewer((state) => state.selection.levelId) + const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) + const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) - const isLockedRef = useRef(false) - const initializedRef = useRef(false) + const [world, setWorld] = useState(null) + + const placedSpawn = useMemo(() => { + if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null + + const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id) + if (spawnObject) { + spawnObject.updateWorldMatrix(true, false) + spawnObject.getWorldPosition(spawnWorldPosition) + spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ') + + return { + position: [ + spawnWorldPosition.x, + spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT, + spawnWorldPosition.z, + ], + yaw: spawnWorldEuler.y, + } + } + + return { + position: [ + placedSpawnNode.position[0], + placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT, + placedSpawnNode.position[2], + ], + yaw: placedSpawnNode.rotation, + } + }, [placedSpawnNode]) - // Initialize camera for first-person view: start at center of scene, on the ground useEffect(() => { - if (initializedRef.current) return - initializedRef.current = true + const nextWorld = buildFirstPersonColliderWorldFromRegistry() + if (!nextWorld) { + setWorld(null) + return + } - // 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 + setWorld(nextWorld) + + return () => { + nextWorld.dispose() + setWorld(null) + } }, [camera]) - // Pointer lock and event handlers + 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 - const requestLock = () => { - if (!isLockedRef.current) { - canvas.requestPointerLock() - } + yawRef.current -= e.movementX * LOOK_SENSITIVITY + pitchRef.current = Math.max( + -(Math.PI / 2 - 0.05), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), + ) } - const handlePointerLockChange = () => { - isLockedRef.current = document.pointerLockElement === canvas + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + if (!canvas.contains(target)) return + if (document.pointerLockElement !== canvas) { + canvas.requestPointerLock?.() + } } - const handleMouseMove = (e: MouseEvent) => { - if (!isLockedRef.current) return + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('click', handleClick) - yawRef.current -= e.movementX * MOUSE_SENSITIVITY - pitchRef.current -= e.movementY * MOUSE_SENSITIVITY - // Clamp pitch to prevent flipping (almost straight up/down) - pitchRef.current = Math.max( - -Math.PI / 2 + 0.05, - Math.min(Math.PI / 2 - 0.05, pitchRef.current), - ) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('click', handleClick) + if (document.pointerLockElement === canvas) { + document.exitPointerLock() + } } + }, [gl]) - const handleKeyDown = (e: KeyboardEvent) => { - // Skip if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return - } + useEffect(() => { + const canvas = gl.domElement - 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) + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return } - // 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 - - 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) - } + if (!controllerRef.current?.group) return + + 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) + }) - // 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 - } + 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]) - // Clamp Y so camera never goes below ground level + eye height - if (camera.position.y < MIN_Y) { - camera.position.y = MIN_Y - } + const spawnYaw = useMemo(() => { + if (!world) return 0 + return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + }, [camera, placedSpawn, world]) - // Apply look rotation - _euler.set(pitchRef.current, yawRef.current, 0, 'YXZ') - camera.quaternion.setFromEuler(_euler) - }) + if (!world) { + return null + } - return null + return ( + <> + {controllerPosition && ( + + + + )} + + ) } /** @@ -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 */} -
-
-
-
+ {isLocked && ( +
+
+
+
+
-
+ )} - {/* Exit button — top-right */}
- {/* Controls hint — bottom-center */} -
-
- -
- - -
- -
- Click to look around + {!hasPlacedSpawn && ( +
+
+ Place a Spawn Point from the Build tab to control where walkthrough starts. +
-
+ )} + + {isLocked && ( +
+
+ +
+ + +
+ Click to look around +
+
+ )} ) } function ControlHint({ label, keys }: { label: string; keys: string[] }) { return ( -
+
{label} -
+
{keys.map((key) => ( ) } + +function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { + return ( +
+ + {label} + + + {keyLabel} + +
+ ) +} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts new file mode 100644 index 000000000..d6a6e82ea --- /dev/null +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -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() + + 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, + registeredObjectIds: Map, + registeredNodeTypes: Map, +): 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() + const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredObjectIds = new Map() + + 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, + } +} diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx new file mode 100644 index 000000000..2a3e1e795 --- /dev/null +++ b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx @@ -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 { + 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( + ( + { + 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(null) + const characterColliderRef = useRef(null) + const characterModelRef = useRef(null) + const debugLineStart = useRef(null) + const debugLineEnd = useRef(null) + const debugRaySensorStart = useRef(null) + const debugRaySensorEnd = useRef(null) + const standPointRef = useRef(null) + const lookDirRef = useRef(null) + const inputDirRef = useRef(null) + const moveDirRef = useRef(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('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 ( + + + {debug && ( + + + + + )} + + {children} + + + + {debug && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) + }, +) + +BVHEcctrl.displayName = 'BVHEcctrl' + +export default BVHEcctrl diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a3ab3da86..ab3a3bc83 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -10,6 +10,7 @@ import { RoofNode, RoofSegmentNode, type SlabNode, + SpawnNode, StairNode, StairSegmentNode, sceneRegistry, @@ -39,6 +40,7 @@ const ALLOWED_TYPES = [ 'fence', 'slab', 'ceiling', + 'spawn', ] const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] @@ -184,6 +186,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' || @@ -266,6 +269,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) @@ -358,6 +363,7 @@ export function FloatingActionMenu() { duplicate.type === 'door' || duplicate.type === 'roof' || duplicate.type === 'roof-segment' || + duplicate.type === 'spawn' || duplicate.type === 'stair-segment' ) { setMovingNode(duplicate as any) @@ -453,7 +459,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 } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index a7591d905..cebe767f1 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -939,7 +939,9 @@ export default function Editor({ presetsAdapter, commandPaletteEmptyAction, }: EditorProps) { - useKeyboard({ isVersionPreviewMode }) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + + useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) const { isLoadingSceneRef } = useAutoSave({ onSave, @@ -951,7 +953,6 @@ 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 sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 884fc80df..b42ee1c8e 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -73,6 +73,7 @@ type SelectableNodeType = | 'roof-segment' | 'stair' | 'stair-segment' + | 'spawn' | 'window' | 'door' @@ -548,6 +549,7 @@ const SELECTION_STRATEGIES: Record = { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', ], @@ -598,7 +600,8 @@ const SELECTION_STRATEGIES: Record = { 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', diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 8a8736923..018e69337 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -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' @@ -100,6 +102,7 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return + if (movingNode.type === 'spawn') return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx new file mode 100644 index 000000000..386d3f822 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx @@ -0,0 +1,99 @@ +import '../../../three-types' + +import { + emitter, + type GridEvent, + sceneRegistry, + type SpawnNode, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +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 }> = ({ node }) => { + 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 }) + useViewer.getState().setSelection({ selectedIds: [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]) + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx new file mode 100644 index 000000000..c68cfa101 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -0,0 +1,126 @@ +import '../../../three-types' + +import { + emitter, + type GridEvent, + type LevelNode, + sceneRegistry, + SpawnNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +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 + Spawn Point +) + +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)] +} + +export const SpawnTool: React.FC = () => { + const currentLevelId = useViewer((state) => state.selection.levelId) + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(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) + } + useViewer.getState().setSelection({ selectedIds: [existingSpawnId] }) + } else { + const spawn = SpawnNode.parse({ + name: 'Spawn Point', + position: nextPosition, + rotation: 0, + }) + useScene.getState().createNode(spawn, currentLevelId) + useViewer.getState().setSelection({ selectedIds: [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]) + + if (!currentLevelId) return null + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 1a4e13e0a..d6e461d37 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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' @@ -43,6 +44,7 @@ const tools: Record>> = { door: DoorTool, item: ItemTool, zone: ZoneTool, + spawn: SpawnTool, window: WindowTool, }, furnish: { diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index d0cf44b56..219378852 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -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() { diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 937f637db..26dfe55d0 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -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 @@ -39,6 +40,7 @@ export function SliderControl({ label, value, onChange, + onCommit, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, precision = 0, @@ -76,10 +78,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(() => { @@ -94,11 +97,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) => { @@ -140,11 +144,13 @@ export function SliderControl({ onChange(startValue) useScene.temporal.getState().resume() onChange(finalVal) + onCommit?.(finalVal) } else { useScene.temporal.getState().resume() + onCommit?.(finalVal) } }, - [onChange], + [onChange, onCommit], ) const handleValueClick = useCallback(() => { @@ -157,10 +163,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) => { diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index d1353d45d..67487ceec 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -302,7 +302,10 @@ export function FloatingLevelSelector() { createNodes(createOps) - setSelection({ buildingId: resolvedBuildingId ?? undefined, levelId: newLevelId }) + setSelection({ + buildingId: resolvedBuildingId ?? undefined, + levelId: newLevelId as LevelNode['id'], + }) }, [createNodes, levels, resolvedBuildingId, setSelection], ) diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index cc20f364b..1ded905fa 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -12,6 +12,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' @@ -60,6 +61,8 @@ export function PanelManager() { return case 'slab': return + case 'spawn': + return case 'ceiling': return case 'wall': diff --git a/packages/editor/src/components/ui/panels/spawn-panel.tsx b/packages/editor/src/components/ui/panels/spawn-panel.tsx new file mode 100644 index 000000000..59b9cdc76 --- /dev/null +++ b/packages/editor/src/components/ui/panels/spawn-panel.tsx @@ -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(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) => { + 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 ( + + + handleUpdate({ position: [value, node.position[1], node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[0] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], value, node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[1] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], node.position[1], value] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[2] * 100) / 100} + /> + + + + + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 35a6980d5..fdbae4e90 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -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 } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index c1eac3139..8fb3d5c0c 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -366,7 +366,7 @@ const ReferenceItem = memo(function ReferenceItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -688,7 +688,7 @@ const LevelItem = memo(function LevelItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -1133,7 +1133,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 7e390870c..2054788e4 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -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 } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx new file mode 100644 index 000000000..80c815ecc --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx @@ -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 ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index dff2ba2aa..4a4d91942 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -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 + return case 'ceiling': return case 'level': - return + return case 'slab': return + case 'spawn': + return case 'wall': return case 'fence': @@ -102,7 +105,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr case 'window': return case 'zone': - return + return default: return null } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index c1afbc7d7..eb578441e 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -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={ updateNode(nodeId, { color: c })} />} + icon={ 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 diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d0eecc695..328116ad7 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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 } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f6d0488b6..0cd2691a7 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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' @@ -132,6 +134,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -147,6 +150,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -200,6 +204,7 @@ type EditorState = { // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null + _levelIdBeforeFirstPerson: LevelNode['id'] | null setFirstPersonMode: (enabled: boolean) => void // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean @@ -632,14 +637,18 @@ const useEditor = create()( setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, + _levelIdBeforeFirstPerson: null as LevelNode['id'] | null, setFirstPersonMode: (enabled) => { if (enabled) { const currentViewMode = get().viewMode + const currentLevelId = useViewer.getState().selection.levelId useViewer.getState().setCameraMode('perspective') useViewer.getState().setWallMode('up') + useViewer.getState().setWalkthroughMode(true) set({ isFirstPersonMode: true, _viewModeBeforeFirstPerson: currentViewMode, + _levelIdBeforeFirstPerson: currentLevelId, viewMode: '3d', isFloorplanOpen: false, mode: 'select', @@ -649,11 +658,25 @@ const useEditor = create()( useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { const prevMode = get()._viewModeBeforeFirstPerson + const prevLevelId = get()._levelIdBeforeFirstPerson + useViewer.getState().setWalkthroughMode(false) set({ isFirstPersonMode: false, _viewModeBeforeFirstPerson: null, + _levelIdBeforeFirstPerson: null, ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), }) + + if (prevLevelId) { + const prevLevelNode = useScene.getState().nodes[prevLevelId] + if (prevLevelNode?.type === 'level') { + useViewer.getState().setSelection({ + levelId: prevLevelId, + zoneId: null, + selectedIds: [], + }) + } + } } }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 827f68934..80eb5b6ac 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -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' && } {node.type === 'item' && } {node.type === 'slab' && } + {node.type === 'spawn' && } {node.type === 'wall' && } {node.type === 'fence' && } {node.type === 'door' && } diff --git a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx new file mode 100644 index 000000000..61032d42c --- /dev/null +++ b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx @@ -0,0 +1,80 @@ +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' + +// Mirrors the current first-person controller capsule in the editor package: +// colliderCapsuleArgs={[0.25, 0.8, 4, 8]} with the controller center 0.8m above the floor. +const PLAYER_CAPSULE_RADIUS = 0.25 +const PLAYER_CAPSULE_LENGTH = 0.8 +const PLAYER_CAPSULE_CENTER_Y = 0.8 + +const SPAWN_COLOR = new Color('#22c55e') + +export const SpawnRenderer = ({ node }: { node: SpawnNode }) => { + const ref = useRef(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 ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 133ba34be..d0b39b537 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -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 } diff --git a/public/icons/spawn-point.svg b/public/icons/spawn-point.svg new file mode 100644 index 000000000..d0bc52eb6 --- /dev/null +++ b/public/icons/spawn-point.svg @@ -0,0 +1,7 @@ + + + + + + + From 15daeaace6dc34f254391e0dd0c53c87d94a9c7e Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Apr 2026 14:50:51 +0530 Subject: [PATCH 3/8] Standardize stair controls on slider inputs --- bun.lock | 1 + .../core/src/systems/stair/stair-opening-sync.ts | 8 -------- .../src/components/tools/spawn/spawn-tool.tsx | 2 +- .../src/components/ui/panels/stair-panel.tsx | 14 +++++++------- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 95fd2c690..fc2f7c2fe 100644 --- a/bun.lock +++ b/bun.lock @@ -104,6 +104,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", }, diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index e4489631b..2e478cd26 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -9,14 +9,6 @@ import type { } from '../../schema' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { - AnyNode, - AnyNodeId, - CeilingNode, - SlabNode, - StairNode, - StairSegmentNode, -} from '../../schema' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' type Point2D = [number, number] diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx index c68cfa101..3bd1da3e0 100644 --- a/packages/editor/src/components/tools/spawn/spawn-tool.tsx +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -20,7 +20,7 @@ const SPAWN_ICON = ( // eslint-disable-next-line @next/next/no-img-element Spawn Point ) diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index 362c42aa1..9ee03abbb 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -294,7 +294,7 @@ export function StairPanel() { /> {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - - - - )} {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( - )} - {(node.topLandingMode ?? 'none') === 'integrated' && ( - Date: Wed, 29 Apr 2026 10:16:56 +0530 Subject: [PATCH 4/8] Use slider controls for stair geometry settings --- .codex/rules/creating-rules.md | 1 + .codex/rules/events.md | 1 + .codex/rules/layers.md | 1 + .codex/rules/node-schemas.md | 1 + .codex/rules/renderers.md | 1 + .codex/rules/scene-registry.md | 1 + .codex/rules/selection-managers.md | 1 + .codex/rules/spatial-queries.md | 1 + .codex/rules/systems.md | 1 + .codex/rules/tools.md | 1 + .codex/rules/viewer-isolation.md | 1 + .codex/skills/review-architecture/SKILL.md | 119 +++++++++++++++++++++ AGENTS.md | 49 +++++++++ 13 files changed, 179 insertions(+) create mode 120000 .codex/rules/creating-rules.md create mode 120000 .codex/rules/events.md create mode 120000 .codex/rules/layers.md create mode 120000 .codex/rules/node-schemas.md create mode 120000 .codex/rules/renderers.md create mode 120000 .codex/rules/scene-registry.md create mode 120000 .codex/rules/selection-managers.md create mode 120000 .codex/rules/spatial-queries.md create mode 120000 .codex/rules/systems.md create mode 120000 .codex/rules/tools.md create mode 120000 .codex/rules/viewer-isolation.md create mode 100644 .codex/skills/review-architecture/SKILL.md create mode 100644 AGENTS.md diff --git a/.codex/rules/creating-rules.md b/.codex/rules/creating-rules.md new file mode 120000 index 000000000..6fb51cc9e --- /dev/null +++ b/.codex/rules/creating-rules.md @@ -0,0 +1 @@ +../../.cursor/rules/creating-rules.mdc \ No newline at end of file diff --git a/.codex/rules/events.md b/.codex/rules/events.md new file mode 120000 index 000000000..72ba2224b --- /dev/null +++ b/.codex/rules/events.md @@ -0,0 +1 @@ +../../.cursor/rules/events.mdc \ No newline at end of file diff --git a/.codex/rules/layers.md b/.codex/rules/layers.md new file mode 120000 index 000000000..de51970de --- /dev/null +++ b/.codex/rules/layers.md @@ -0,0 +1 @@ +../../.cursor/rules/layers.mdc \ No newline at end of file diff --git a/.codex/rules/node-schemas.md b/.codex/rules/node-schemas.md new file mode 120000 index 000000000..93ceffa03 --- /dev/null +++ b/.codex/rules/node-schemas.md @@ -0,0 +1 @@ +../../.cursor/rules/node-schemas.mdc \ No newline at end of file diff --git a/.codex/rules/renderers.md b/.codex/rules/renderers.md new file mode 120000 index 000000000..a7bb563a3 --- /dev/null +++ b/.codex/rules/renderers.md @@ -0,0 +1 @@ +../../.cursor/rules/renderers.mdc \ No newline at end of file diff --git a/.codex/rules/scene-registry.md b/.codex/rules/scene-registry.md new file mode 120000 index 000000000..017d4c150 --- /dev/null +++ b/.codex/rules/scene-registry.md @@ -0,0 +1 @@ +../../.cursor/rules/scene-registry.mdc \ No newline at end of file diff --git a/.codex/rules/selection-managers.md b/.codex/rules/selection-managers.md new file mode 120000 index 000000000..3c2d1d93b --- /dev/null +++ b/.codex/rules/selection-managers.md @@ -0,0 +1 @@ +../../.cursor/rules/selection-managers.mdc \ No newline at end of file diff --git a/.codex/rules/spatial-queries.md b/.codex/rules/spatial-queries.md new file mode 120000 index 000000000..c33455227 --- /dev/null +++ b/.codex/rules/spatial-queries.md @@ -0,0 +1 @@ +../../.cursor/rules/spatial-queries.mdc \ No newline at end of file diff --git a/.codex/rules/systems.md b/.codex/rules/systems.md new file mode 120000 index 000000000..b20dbdc3d --- /dev/null +++ b/.codex/rules/systems.md @@ -0,0 +1 @@ +../../.cursor/rules/systems.mdc \ No newline at end of file diff --git a/.codex/rules/tools.md b/.codex/rules/tools.md new file mode 120000 index 000000000..9c7547e54 --- /dev/null +++ b/.codex/rules/tools.md @@ -0,0 +1 @@ +../../.cursor/rules/tools.mdc \ No newline at end of file diff --git a/.codex/rules/viewer-isolation.md b/.codex/rules/viewer-isolation.md new file mode 120000 index 000000000..f484584ba --- /dev/null +++ b/.codex/rules/viewer-isolation.md @@ -0,0 +1 @@ +../../.cursor/rules/viewer-isolation.mdc \ No newline at end of file diff --git a/.codex/skills/review-architecture/SKILL.md b/.codex/skills/review-architecture/SKILL.md new file mode 100644 index 000000000..6d3958cdd --- /dev/null +++ b/.codex/skills/review-architecture/SKILL.md @@ -0,0 +1,119 @@ +--- +name: review-architecture +description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob +--- + +Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch. + +## 1. Load the rules (required — do not skip) + +Read these before reviewing any diff. They are the source of truth, not your training data: + +- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do +- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions +- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` +- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic +- `.codex/rules/layers.md` +- `.codex/rules/selection-managers.md` +- `.codex/rules/scene-registry.md` +- `.codex/rules/spatial-queries.md` +- `.codex/rules/node-schemas.md` +- `.codex/rules/events.md` + +Only the first four are required on every review; read the rest when the diff touches their subject area. + +## 2. Fetch the diff + +```bash +# If the user gave a PR URL or number: +gh pr diff + +# If reviewing the current branch: +git diff main...HEAD +``` + +Also list changed files so you can map each to the relevant rule: + +```bash +gh pr view --json files --jq '.files[].path' +# or +git diff --name-only main...HEAD +``` + +## 3. Layer classification — do this BEFORE the checklist + +For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly. + +### The three layers and what they own + +**`packages/core` — domain data + pure logic.** +Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.). + +**`packages/viewer` — the 3D canvas, shippable standalone.** +Owns: ``, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything). + +**`apps/editor` (and editor-scoped packages) — the editing experience.** +Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `` via children and props, never the reverse. + +### Five triggers that mean "this is probably editor" + +1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`. +2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else. +3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core. +4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix. +5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer. + +Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms. + +## 4. Review checklist + +### A. Layer boundaries +- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode. +- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`. +- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not. +- Renderers contain no geometry generation or domain logic — that belongs in a system. +- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.codex/rules/tools.md`. No business logic, no imports from `packages/viewer`. + +### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`) +- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition. +- Derived values belong in selectors or systems, not in the store body. +- No cross-store coupling: a store's action should not call another store's actions inside itself. +- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`. + +### C. Selector performance +- Top-level components (pages, layouts, providers, `` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation. +- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards. +- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up. + +### D. Separation of concerns +- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers). +- Editor-only overlays and systems are injected as children of ``, not added inside the viewer package. +- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`. + +## 5. Output format + +Group findings by severity: + +- **Blocker** — violates a rule in `.codex/rules` or breaks a layer boundary. Must be fixed before merge. +- **Suggestion** — likely problem, worth discussing. Not a hard block. +- **Nit** — minor, optional. + +For each finding, include: + +1. File and line: `path/to/file.ts:42` +2. The offending snippet (short — 1–5 lines) +3. The rule it violates, linked to the rule file (e.g. `.codex/rules/viewer-isolation.md`) +4. A concrete proposed fix + +Skip formatting, import ordering, and anything CI already covers. + +If the PR fully complies, say so explicitly — do not invent nits to appear thorough. + +## 6. Final summary + +End with: + +- Blocker count, suggestion count, nit count +- One-sentence verdict: ready to merge / needs changes / needs discussion +- If blockers exist, list the files the author should open first diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c0467aa56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Pascal Agent Instructions + +This repository uses shared architecture rules for AI assistants. Treat the rule files as the source of truth for architecture-sensitive work. + +## Required Rule Sources + +The canonical rules live in `.cursor/rules/*.mdc`. + +Claude-compatible paths are exposed in `.claude/rules/*.md`. +Codex-compatible paths are exposed in `.codex/rules/*.md`. + +Both should point to the same Cursor rule sources so Claude and Codex review the exact same rules. + +## Architecture Rules + +Read the relevant rules before making or reviewing changes in these areas: + +- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do +- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions +- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` +- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic +- `.codex/rules/layers.md` +- `.codex/rules/selection-managers.md` +- `.codex/rules/scene-registry.md` +- `.codex/rules/spatial-queries.md` +- `.codex/rules/node-schemas.md` +- `.codex/rules/events.md` + +For architecture reviews, the first four are always required. Read the remaining rules when the diff touches their subject area. + +## Layer Boundaries + +`packages/core` owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview. + +`packages/viewer` owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary. + +`apps/editor` owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `` via props and children. + +## Review Expectations + +When reviewing architecture changes: + +1. Classify every new file, type, store field, and exported helper as core, viewer, or editor before writing findings. +2. Lead with layer-boundary blockers. +3. Check hook hygiene for `useEditor`, `useScene`, and `useViewer`. +4. Check selector performance for broad subscriptions and selectors that allocate fresh references. +5. Skip formatting and import ordering unless they hide a real behavior or architecture issue. + +Use `.codex/skills/review-architecture/SKILL.md` when the user asks Codex to review a PR, audit a branch, or check architecture compliance. From e688792c348943ca6a84687b1cfddb249c17c77e Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 10:35:34 +0530 Subject: [PATCH 5/8] Refactor first-person and spawn selection handling --- packages/core/src/events/bus.ts | 8 ++--- .../editor/custom-camera-controls.tsx | 8 ++++- .../editor/src/components/editor/index.tsx | 36 ++++++++++++++++++- .../src/components/tools/item/move-tool.tsx | 7 ++-- .../tools/spawn/move-spawn-tool.tsx | 12 ++++--- .../src/components/tools/spawn/spawn-tool.tsx | 18 ++++++---- .../src/components/tools/tool-manager.tsx | 15 ++++++-- packages/editor/src/store/use-editor.tsx | 22 ------------ 8 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7774fe0e3..6cb4215e7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' -import type { Object3D } from 'three' import mitt from 'mitt' +import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, @@ -104,10 +104,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] diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 5ac03fed3..4d968001a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,5 +1,11 @@ 'use client' -import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { + type CameraControlEvent, + type CameraControlFitSceneEvent, + emitter, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5bd2b173c..e01263370 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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 { @@ -952,6 +951,8 @@ export default function Editor({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const isPreviewMode = useEditor((s) => s.isPreviewMode) + const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) + const wasFirstPersonModeRef = useRef(isFirstPersonMode) const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) @@ -969,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 diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 018e69337..b6c1397aa 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -88,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 @@ -102,7 +104,8 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return - if (movingNode.type === 'spawn') return + if (movingNode.type === 'spawn') + return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx index 386d3f822..6eef48a62 100644 --- a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx +++ b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx @@ -3,12 +3,11 @@ import '../../../three-types' import { emitter, type GridEvent, - sceneRegistry, type SpawnNode, + sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import { Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -35,7 +34,10 @@ function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] } -export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { +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(() => { @@ -66,7 +68,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { committed = true useScene.temporal.getState().resume() useScene.getState().updateNode(node.id, { position: nextPosition }) - useViewer.getState().setSelection({ selectedIds: [node.id] }) + onCommitted?.(node.id) useLiveTransforms.getState().clear(node.id) sfxEmitter.emit('sfx:item-place') exitMoveMode() @@ -91,7 +93,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { useScene.temporal.getState().resume() } } - }, [exitMoveMode, node]) + }, [exitMoveMode, node, onCommitted]) return ( diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx index 3bd1da3e0..30d294ee3 100644 --- a/packages/editor/src/components/tools/spawn/spawn-tool.tsx +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -4,11 +4,11 @@ import { emitter, type GridEvent, type LevelNode, - sceneRegistry, SpawnNode, + type SpawnNode as SpawnNodeType, + sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { Vector3 } from 'three' @@ -56,8 +56,12 @@ function getLevelLocalSpawnPosition( return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] } -export const SpawnTool: React.FC = () => { - const currentLevelId = useViewer((state) => state.selection.levelId) +type SpawnToolProps = { + currentLevelId: LevelNode['id'] | null + onPlaced?: (spawnId: SpawnNodeType['id']) => void +} + +export const SpawnTool: React.FC = ({ currentLevelId, onPlaced }) => { const [, setCursorPosition] = useState<[number, number, number] | null>(null) const cursorRef = useRef(null) @@ -87,7 +91,7 @@ export const SpawnTool: React.FC = () => { if (duplicateSpawnIds.length > 0) { useScene.getState().deleteNodes(duplicateSpawnIds) } - useViewer.getState().setSelection({ selectedIds: [existingSpawnId] }) + onPlaced?.(existingSpawnId) } else { const spawn = SpawnNode.parse({ name: 'Spawn Point', @@ -95,7 +99,7 @@ export const SpawnTool: React.FC = () => { rotation: 0, }) useScene.getState().createNode(spawn, currentLevelId) - useViewer.getState().setSelection({ selectedIds: [spawn.id] }) + onPlaced?.(spawn.id) } sfxEmitter.emit('sfx:structure-build') @@ -110,7 +114,7 @@ export const SpawnTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) } - }, [currentLevelId]) + }, [currentLevelId, onPlaced]) if (!currentLevelId) return null diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d6e461d37..0938a254e 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -44,7 +44,6 @@ const tools: Record>> = { door: DoorTool, item: ItemTool, zone: ZoneTool, - spawn: SpawnTool, window: WindowTool, }, furnish: { @@ -63,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 @@ -125,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 && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -154,7 +158,12 @@ export const ToolManager: React.FC = () => { {movingFenceEndpoint && } {curvingWall && } {curvingFence && } - {movingNode && movingNode.type !== 'building' && } + {movingNode && movingNode.type !== 'building' && ( + + )} + {!movingNode && showBuildTool && tool === 'spawn' && ( + + )} {!movingNode && BuildToolComponent && } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0cd2691a7..867f7c16e 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -204,7 +204,6 @@ type EditorState = { // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null - _levelIdBeforeFirstPerson: LevelNode['id'] | null setFirstPersonMode: (enabled: boolean) => void // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean @@ -637,46 +636,25 @@ const useEditor = create()( setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, - _levelIdBeforeFirstPerson: null as LevelNode['id'] | null, setFirstPersonMode: (enabled) => { if (enabled) { const currentViewMode = get().viewMode - const currentLevelId = useViewer.getState().selection.levelId - useViewer.getState().setCameraMode('perspective') - useViewer.getState().setWallMode('up') - useViewer.getState().setWalkthroughMode(true) set({ isFirstPersonMode: true, _viewModeBeforeFirstPerson: currentViewMode, - _levelIdBeforeFirstPerson: currentLevelId, viewMode: '3d', isFloorplanOpen: false, mode: 'select', tool: null, catalogCategory: null, }) - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { const prevMode = get()._viewModeBeforeFirstPerson - const prevLevelId = get()._levelIdBeforeFirstPerson - useViewer.getState().setWalkthroughMode(false) set({ isFirstPersonMode: false, _viewModeBeforeFirstPerson: null, - _levelIdBeforeFirstPerson: null, ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), }) - - if (prevLevelId) { - const prevLevelNode = useScene.getState().nodes[prevLevelId] - if (prevLevelNode?.type === 'level') { - useViewer.getState().setSelection({ - levelId: prevLevelId, - zoneId: null, - selectedIds: [], - }) - } - } } }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, From e7610846352bd79381fc9dce25be12d2d8d93125 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 12:53:40 +0530 Subject: [PATCH 6/8] Normalize root scene nodes and fix level duplication --- .../core/src/store/actions/node-actions.ts | 12 +-- packages/core/src/store/use-scene.ts | 75 ++++++++++++++++++- .../components/ui/floating-level-selector.tsx | 12 ++- .../ui/sidebar/panels/site-panel/index.tsx | 27 +++++-- .../editor/src/lib/level-duplication.test.ts | 41 ++++++++++ packages/editor/src/lib/level-duplication.ts | 34 ++++++++- packages/mcp/src/bridge/scene-bridge.test.ts | 21 ++++++ 7 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 packages/editor/src/lib/level-duplication.test.ts diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index a855871d6..d9e50b79b 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -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) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index d4aa2c451..51e1caa9f 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -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): Record { return patchedNodes as Record } +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, + 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, + rootNodeIds: AnyNodeId[], +): Set { + const reachable = new Set() + const stack = [...rootNodeIds] + const childIdsByParentId = new Map() + + 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 @@ -450,9 +511,19 @@ const useScene: UseSceneStore = create()( } } + 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(), collections: {}, }) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 67487ceec..a11ff7be5 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -293,13 +293,21 @@ export function FloatingLevelSelector() { const handleDuplicateLevel = useCallback( (level: LevelNode, preset: LevelDuplicatePreset = 'everything') => { - const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ + 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, + })), + ) + } createNodes(createOps) setSelection({ @@ -307,7 +315,7 @@ export function FloatingLevelSelector() { levelId: newLevelId as LevelNode['id'], }) }, - [createNodes, levels, resolvedBuildingId, setSelection], + [createNodes, levels, resolvedBuildingId, setSelection, updateNodes], ) if (levels.length === 0) return null diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 8fb3d5c0c..24742881a 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -587,10 +587,19 @@ const LevelItem = memo(function LevelItem({ 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(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) @@ -603,7 +612,7 @@ const LevelItem = memo(function LevelItem({ }, [isSelected]) const handleSelect = () => { - setSelection({ levelId: level.id }) + selectLevel(level.id) } const handleDoubleClick = () => { @@ -611,15 +620,23 @@ const LevelItem = memo(function LevelItem({ } const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => { - const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ + 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, + })), + ) + } createNodes(createOps) - setSelection({ levelId: newLevelId }) + selectLevel(newLevelId as LevelNode['id']) setDuplicateDialogOpen(false) } @@ -664,7 +681,7 @@ const LevelItem = memo(function LevelItem({ if (isSelected) { setIsExpanded(!isExpanded) } else { - setSelection({ levelId: level.id }) + selectLevel(level.id) } }} > @@ -869,7 +886,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 ( diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts new file mode 100644 index 000000000..539e21645 --- /dev/null +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -0,0 +1,41 @@ +// @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, + 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 + + 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) + }) +}) diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts index 8f0f10409..a243bd7d4 100644 --- a/packages/editor/src/lib/level-duplication.ts +++ b/packages/editor/src/lib/level-duplication.ts @@ -1,5 +1,5 @@ -import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core' -import { cloneLevelSubtree } from '@pascal-app/core' +import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema' export type LevelDuplicatePreset = | 'everything' @@ -79,6 +79,20 @@ function stripMaterials(node: AnyNode): AnyNode { return next as AnyNode } +function findLevelBuildingId(nodes: Record, 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, @@ -91,7 +105,15 @@ export function buildLevelDuplicateCreateOps({ preset: LevelDuplicatePreset }) { const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id) - const nextLevelNumber = Math.max(...levels.map((entry) => entry.level), -1) + 1 + 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)) @@ -119,8 +141,12 @@ export function buildLevelDuplicateCreateOps({ level: nextLevelNumber, } as AnyNode) : node, - parentId: node.parentId as AnyNodeId | undefined, + parentId: + node.id === newLevelId + ? parentBuildingId + : ((node.parentId as AnyNodeId | null) ?? undefined), })), newLevelId, + shiftedLevels, } } diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts index 087be39ca..94ffa58dd 100644 --- a/packages/mcp/src/bridge/scene-bridge.test.ts +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -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') From 8c10b9280d36cb409e07f8dfb12abf58ad39ada0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 13:09:26 +0530 Subject: [PATCH 7/8] Exclude spawn points from level duplication --- .../editor/src/lib/level-duplication.test.ts | 31 +++++++++++++++++++ packages/editor/src/lib/level-duplication.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts index 539e21645..a8e57ef2a 100644 --- a/packages/editor/src/lib/level-duplication.test.ts +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -6,6 +6,7 @@ import { type AnyNodeId, BuildingNode, LevelNode, + SpawnNode, WallNode, } from '@pascal-app/core/schema' import { buildLevelDuplicateCreateOps } from './level-duplication' @@ -38,4 +39,34 @@ describe('buildLevelDuplicateCreateOps', () => { 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 + + 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([]) + }) }) diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts index a243bd7d4..277e650f2 100644 --- a/packages/editor/src/lib/level-duplication.ts +++ b/packages/editor/src/lib/level-duplication.ts @@ -7,7 +7,7 @@ export type LevelDuplicatePreset = | 'structure-materials' | 'structure-furniture' -const REFERENCE_NODE_TYPES = new Set(['scan', 'guide']) +const NON_DUPLICABLE_NODE_TYPES = new Set(['scan', 'guide', 'spawn']) const STRUCTURAL_NODE_TYPES = new Set([ 'level', 'wall', @@ -24,8 +24,9 @@ const STRUCTURAL_NODE_TYPES = new Set([ ]) 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 !REFERENCE_NODE_TYPES.has(node.type) + if (preset === 'structure-furniture') return true if (preset === 'structure' || preset === 'structure-materials') { return STRUCTURAL_NODE_TYPES.has(node.type) } From a05573b60277ca7d83473412946f28db78e0f0b8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 13:25:20 +0530 Subject: [PATCH 8/8] Make spawn renderer editor-agnostic --- .../renderers/spawn/spawn-renderer.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx index 61032d42c..e7dec6a42 100644 --- a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx +++ b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx @@ -5,12 +5,6 @@ import { Color, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import useViewer from '../../../store/use-viewer' -// Mirrors the current first-person controller capsule in the editor package: -// colliderCapsuleArgs={[0.25, 0.8, 4, 8]} with the controller center 0.8m above the floor. -const PLAYER_CAPSULE_RADIUS = 0.25 -const PLAYER_CAPSULE_LENGTH = 0.8 -const PLAYER_CAPSULE_CENTER_Y = 0.8 - const SPAWN_COLOR = new Color('#22c55e') export const SpawnRenderer = ({ node }: { node: SpawnNode }) => { @@ -55,26 +49,20 @@ export const SpawnRenderer = ({ node }: { node: SpawnNode }) => { - + - - - - - + + + + - - - - - + + + + ) }