Merge pull request #281 from sudhir9297/feat/upgrade-and-bug-fix

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

After

Width:  |  Height:  |  Size: 346 B