diff --git a/.codex/rules/creating-rules.md b/.codex/rules/creating-rules.md new file mode 120000 index 000000000..6fb51cc9e --- /dev/null +++ b/.codex/rules/creating-rules.md @@ -0,0 +1 @@ +../../.cursor/rules/creating-rules.mdc \ No newline at end of file diff --git a/.codex/rules/events.md b/.codex/rules/events.md new file mode 120000 index 000000000..72ba2224b --- /dev/null +++ b/.codex/rules/events.md @@ -0,0 +1 @@ +../../.cursor/rules/events.mdc \ No newline at end of file diff --git a/.codex/rules/layers.md b/.codex/rules/layers.md new file mode 120000 index 000000000..de51970de --- /dev/null +++ b/.codex/rules/layers.md @@ -0,0 +1 @@ +../../.cursor/rules/layers.mdc \ No newline at end of file diff --git a/.codex/rules/node-schemas.md b/.codex/rules/node-schemas.md new file mode 120000 index 000000000..93ceffa03 --- /dev/null +++ b/.codex/rules/node-schemas.md @@ -0,0 +1 @@ +../../.cursor/rules/node-schemas.mdc \ No newline at end of file diff --git a/.codex/rules/renderers.md b/.codex/rules/renderers.md new file mode 120000 index 000000000..a7bb563a3 --- /dev/null +++ b/.codex/rules/renderers.md @@ -0,0 +1 @@ +../../.cursor/rules/renderers.mdc \ No newline at end of file diff --git a/.codex/rules/scene-registry.md b/.codex/rules/scene-registry.md new file mode 120000 index 000000000..017d4c150 --- /dev/null +++ b/.codex/rules/scene-registry.md @@ -0,0 +1 @@ +../../.cursor/rules/scene-registry.mdc \ No newline at end of file diff --git a/.codex/rules/selection-managers.md b/.codex/rules/selection-managers.md new file mode 120000 index 000000000..3c2d1d93b --- /dev/null +++ b/.codex/rules/selection-managers.md @@ -0,0 +1 @@ +../../.cursor/rules/selection-managers.mdc \ No newline at end of file diff --git a/.codex/rules/spatial-queries.md b/.codex/rules/spatial-queries.md new file mode 120000 index 000000000..c33455227 --- /dev/null +++ b/.codex/rules/spatial-queries.md @@ -0,0 +1 @@ +../../.cursor/rules/spatial-queries.mdc \ No newline at end of file diff --git a/.codex/rules/systems.md b/.codex/rules/systems.md new file mode 120000 index 000000000..b20dbdc3d --- /dev/null +++ b/.codex/rules/systems.md @@ -0,0 +1 @@ +../../.cursor/rules/systems.mdc \ No newline at end of file diff --git a/.codex/rules/tools.md b/.codex/rules/tools.md new file mode 120000 index 000000000..9c7547e54 --- /dev/null +++ b/.codex/rules/tools.md @@ -0,0 +1 @@ +../../.cursor/rules/tools.mdc \ No newline at end of file diff --git a/.codex/rules/viewer-isolation.md b/.codex/rules/viewer-isolation.md new file mode 120000 index 000000000..f484584ba --- /dev/null +++ b/.codex/rules/viewer-isolation.md @@ -0,0 +1 @@ +../../.cursor/rules/viewer-isolation.mdc \ No newline at end of file diff --git a/.codex/skills/review-architecture/SKILL.md b/.codex/skills/review-architecture/SKILL.md new file mode 100644 index 000000000..6d3958cdd --- /dev/null +++ b/.codex/skills/review-architecture/SKILL.md @@ -0,0 +1,119 @@ +--- +name: review-architecture +description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob +--- + +Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch. + +## 1. Load the rules (required — do not skip) + +Read these before reviewing any diff. They are the source of truth, not your training data: + +- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do +- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions +- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` +- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic +- `.codex/rules/layers.md` +- `.codex/rules/selection-managers.md` +- `.codex/rules/scene-registry.md` +- `.codex/rules/spatial-queries.md` +- `.codex/rules/node-schemas.md` +- `.codex/rules/events.md` + +Only the first four are required on every review; read the rest when the diff touches their subject area. + +## 2. Fetch the diff + +```bash +# If the user gave a PR URL or number: +gh pr diff + +# If reviewing the current branch: +git diff main...HEAD +``` + +Also list changed files so you can map each to the relevant rule: + +```bash +gh pr view --json files --jq '.files[].path' +# or +git diff --name-only main...HEAD +``` + +## 3. Layer classification — do this BEFORE the checklist + +For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly. + +### The three layers and what they own + +**`packages/core` — domain data + pure logic.** +Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.). + +**`packages/viewer` — the 3D canvas, shippable standalone.** +Owns: ``, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything). + +**`apps/editor` (and editor-scoped packages) — the editing experience.** +Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `` via children and props, never the reverse. + +### Five triggers that mean "this is probably editor" + +1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`. +2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else. +3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core. +4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix. +5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer. + +Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms. + +## 4. Review checklist + +### A. Layer boundaries +- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode. +- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`. +- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not. +- Renderers contain no geometry generation or domain logic — that belongs in a system. +- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.codex/rules/tools.md`. No business logic, no imports from `packages/viewer`. + +### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`) +- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition. +- Derived values belong in selectors or systems, not in the store body. +- No cross-store coupling: a store's action should not call another store's actions inside itself. +- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`. + +### C. Selector performance +- Top-level components (pages, layouts, providers, `` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation. +- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards. +- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up. + +### D. Separation of concerns +- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers). +- Editor-only overlays and systems are injected as children of ``, not added inside the viewer package. +- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`. + +## 5. Output format + +Group findings by severity: + +- **Blocker** — violates a rule in `.codex/rules` or breaks a layer boundary. Must be fixed before merge. +- **Suggestion** — likely problem, worth discussing. Not a hard block. +- **Nit** — minor, optional. + +For each finding, include: + +1. File and line: `path/to/file.ts:42` +2. The offending snippet (short — 1–5 lines) +3. The rule it violates, linked to the rule file (e.g. `.codex/rules/viewer-isolation.md`) +4. A concrete proposed fix + +Skip formatting, import ordering, and anything CI already covers. + +If the PR fully complies, say so explicitly — do not invent nits to appear thorough. + +## 6. Final summary + +End with: + +- Blocker count, suggestion count, nit count +- One-sentence verdict: ready to merge / needs changes / needs discussion +- If blockers exist, list the files the author should open first diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c0467aa56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Pascal Agent Instructions + +This repository uses shared architecture rules for AI assistants. Treat the rule files as the source of truth for architecture-sensitive work. + +## Required Rule Sources + +The canonical rules live in `.cursor/rules/*.mdc`. + +Claude-compatible paths are exposed in `.claude/rules/*.md`. +Codex-compatible paths are exposed in `.codex/rules/*.md`. + +Both should point to the same Cursor rule sources so Claude and Codex review the exact same rules. + +## Architecture Rules + +Read the relevant rules before making or reviewing changes in these areas: + +- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do +- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions +- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` +- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic +- `.codex/rules/layers.md` +- `.codex/rules/selection-managers.md` +- `.codex/rules/scene-registry.md` +- `.codex/rules/spatial-queries.md` +- `.codex/rules/node-schemas.md` +- `.codex/rules/events.md` + +For architecture reviews, the first four are always required. Read the remaining rules when the diff touches their subject area. + +## Layer Boundaries + +`packages/core` owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview. + +`packages/viewer` owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary. + +`apps/editor` owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `` via props and children. + +## Review Expectations + +When reviewing architecture changes: + +1. Classify every new file, type, store field, and exported helper as core, viewer, or editor before writing findings. +2. Lead with layer-boundary blockers. +3. Check hook hygiene for `useEditor`, `useScene`, and `useViewer`. +4. Check selector performance for broad subscriptions and selectors that allocate fresh references. +5. Skip formatting and import ordering unless they hide a real behavior or architecture issue. + +Use `.codex/skills/review-architecture/SKILL.md` when the user asks Codex to review a PR, audit a branch, or check architecture compliance. diff --git a/bun.lock b/bun.lock index 95fd2c690..fc2f7c2fe 100644 --- a/bun.lock +++ b/bun.lock @@ -104,6 +104,7 @@ "motion": "^12.34.3", "nanoid": "^5.1.6", "tailwind-merge": "^3.5.0", + "three-mesh-bvh": "^0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11", }, diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index b313008b5..6cb4215e7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' -import type { Object3D } from 'three' import mitt from 'mitt' +import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, @@ -12,6 +12,7 @@ import type { RoofSegmentNode, SiteNode, SlabNode, + SpawnNode, StairNode, StairSegmentNode, WallNode, @@ -53,6 +54,7 @@ export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent +export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent @@ -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> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index c44e7909d..ec727481e 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -18,6 +18,7 @@ export const sceneRegistry = { fence: new Set(), item: new Set(), slab: new Set(), + spawn: new Set(), zone: new Set(), roof: new Set(), 'roof-segment': new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42ae25140..254d1fc2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export type { RoofSegmentEvent, SiteEvent, SlabEvent, + SpawnEvent, StairEvent, StairSegmentEvent, WallEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1383216df..a86f1a716 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -50,6 +50,7 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' +export { SpawnNode } from './nodes/spawn' export { getEffectiveStairSurfaceMaterial, StairNode, diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 9d8ad95d3..519ac6a40 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -77,7 +77,7 @@ export const DoorNode = BaseNode.extend({ }).describe(dedent`Door node - a parametric door placed on a wall - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) - segments: rows stacked top to bottom, each defining its own columnRatios - - type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed + - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed - hingesSide/swingDirection: which way the door opens - doorCloser/panicBar: commercial and emergency hardware options `) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index d351b77dd..c1d7f371d 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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([]), diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts new file mode 100644 index 000000000..521d3f810 --- /dev/null +++ b/packages/core/src/schema/nodes/spawn.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const SpawnNode = BaseNode.extend({ + id: objectId('spawn'), + type: nodeType('spawn'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), +}) + +export type SpawnNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1b17a6b05..00e07fa19 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -11,6 +11,7 @@ import { RoofSegmentNode } from './nodes/roof-segment' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' +import { SpawnNode } from './nodes/spawn' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' import { WallNode } from './nodes/wall' @@ -33,6 +34,7 @@ export const AnyNode = z.discriminatedUnion('type', [ StairSegmentNode, ScanNode, GuideNode, + SpawnNode, WindowNode, DoorNode, ]) diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index a855871d6..d9e50b79b 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -238,27 +238,29 @@ export const createNodesAction = ( const nextRootIds = [...state.rootNodeIds] for (const { node, parentId } of ops) { + const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null + // 1. Assign parentId to the child (Safe because BaseNode has parentId) const newNode = { ...node, - parentId: parentId ?? null, + parentId: effectiveParentId, } nextNodes[newNode.id] = newNode // 2. Update the Parent's children list - if (parentId && nextNodes[parentId]) { - const parent = nextNodes[parentId] + if (effectiveParentId && nextNodes[effectiveParentId]) { + const parent = nextNodes[effectiveParentId] // Type Guard: Check if the parent node is a container that supports children if ('children' in parent && Array.isArray(parent.children)) { - nextNodes[parentId] = { + nextNodes[effectiveParentId] = { ...parent, // Use Set to prevent duplicate IDs if createNode is called twice children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here } } - } else if (!parentId) { + } else if (!effectiveParentId) { // 3. Handle Root nodes if (!nextRootIds.includes(newNode.id)) { nextRootIds.push(newNode.id) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index d4aa2c451..51e1caa9f 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -11,8 +11,8 @@ import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import type { AnyNode, AnyNodeId } from '../schema/types' -import { resetSceneHistoryPauseDepth } from './history-control' import * as nodeActions from './actions/node-actions' +import { resetSceneHistoryPauseDepth } from './history-control' function getFiniteNumber(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback @@ -349,6 +349,67 @@ function migrateNodes(nodes: Record): Record { return patchedNodes as Record } +function getNodeChildIds(node: AnyNode): AnyNodeId[] { + if (!('children' in node) || !Array.isArray(node.children)) { + return [] + } + + return (node.children as unknown[]) + .map((child) => { + if (typeof child === 'string') return child + if (child && typeof child === 'object' && 'id' in child && typeof child.id === 'string') { + return child.id + } + return null + }) + .filter((id): id is AnyNodeId => typeof id === 'string') +} + +function normalizeRootNodeIds( + nodes: Record, + rootNodeIds: AnyNodeId[], +): AnyNodeId[] { + const existingRootIds = rootNodeIds.filter((id) => Boolean(nodes[id])) + const siteRootIds = existingRootIds.filter((id) => nodes[id]?.type === 'site') + + if (siteRootIds.length > 0) { + return siteRootIds + } + + return existingRootIds.filter((id) => nodes[id]?.parentId === null) +} + +function collectReachableNodeIds( + nodes: Record, + rootNodeIds: AnyNodeId[], +): Set { + const reachable = new Set() + const stack = [...rootNodeIds] + const childIdsByParentId = new Map() + + for (const node of Object.values(nodes)) { + if (!node.parentId) continue + const parentId = node.parentId as AnyNodeId + const children = childIdsByParentId.get(parentId) ?? [] + children.push(node.id as AnyNodeId) + childIdsByParentId.set(parentId, children) + } + + while (stack.length > 0) { + const id = stack.pop() + if (!id || reachable.has(id)) continue + + const node = nodes[id] + if (!node) continue + + reachable.add(id) + stack.push(...getNodeChildIds(node)) + stack.push(...(childIdsByParentId.get(id) ?? [])) + } + + return reachable +} + export type SceneState = { // 1. The Data: A flat dictionary of all nodes nodes: Record @@ -450,9 +511,19 @@ const useScene: UseSceneStore = create()( } } + const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) + const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) + if (normalizedRootNodeIds.length > 0) { + for (const node of Object.values(cleanedNodes)) { + if (reachableNodeIds.has(node.id as AnyNodeId)) continue + console.warn('[Scene] Removing unreachable node', node.id) + delete cleanedNodes[node.id] + } + } + set({ nodes: cleanedNodes, - rootNodeIds, + rootNodeIds: normalizedRootNodeIds, dirtyNodes: new Set(), collections: {}, }) diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 24dacaa19..b9b5c9e9d 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -86,6 +86,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { contentPadding, hingesSide, } = node + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') // Leaf occupies the full opening (no bottom frame bar — door opens to floor) const leafW = width - 2 * frameThickness @@ -146,13 +147,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── const cpX = contentPadding[0] const cpY = contentPadding[1] - if (cpY > 0) { + if (hasLeafContent && cpY > 0) { // Top strip addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } - if (cpX > 0) { + if (hasLeafContent && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) @@ -188,20 +189,22 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // Column dividers within this segment - cx = -contentW / 2 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! - addBox( - mesh, - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) - cx += seg.dividerThickness + if (seg.type !== 'empty') { + cx = -contentW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addBox( + mesh, + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + cx += seg.dividerThickness + } } // Segment content per column @@ -225,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) } } else { - // 'empty' — opaque backing, no detail - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + // 'empty' leaves the opening unfilled } } @@ -234,7 +236,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Handle ── - if (handle) { + if (hasLeafContent && handle) { // Convert from floor-based height to mesh-center-based Y const handleY = handleHeight - height / 2 // Handle grip sits on the front face (+Z) of the leaf @@ -250,7 +252,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Door closer (commercial hardware at top) ── - if (doorCloser) { + if (hasLeafContent && doorCloser) { const closerY = leafCenterY + leafH / 2 - 0.04 // Body addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) @@ -268,13 +270,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Panic bar ── - if (panicBar) { + if (hasLeafContent && panicBar) { const barY = panicBarHeight - height / 2 addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) } // ── Hinges (3 knuckle-style hinges on the hinge side) ── - { + if (hasLeafContent) { const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 const hingeZ = 0 // centered in leaf depth const hingeH = 0.1 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 36dd6c159..2e478cd26 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -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') { diff --git a/packages/editor/package.json b/packages/editor/package.json index 0b0eb3c30..dbe2e5d04 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -46,6 +46,7 @@ "motion": "^12.34.3", "nanoid": "^5.1.6", "tailwind-merge": "^3.5.0", + "three-mesh-bvh": "^0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index ba10a4063..daee14df6 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -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(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 + if (isFirstPersonMode) { + return null } return ( diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b90bcdf35..b6365ce90 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,102 +1,153 @@ 'use client' +import '../../three-types' +import { KeyboardControls } from '@react-three/drei' +import { sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Euler, Vector3 } from 'three' import useEditor from '../../store/use-editor' - -// Average human eye height in meters -const EYE_HEIGHT = 1.65 -// Movement speed in meters per second -const MOVE_SPEED = 5 -// Sprint multiplier when holding Shift -const SPRINT_MULTIPLIER = 2 -// Vertical float speed in meters per second -const VERTICAL_SPEED = 3 -// Mouse look sensitivity -const MOUSE_SENSITIVITY = 0.002 -// Min Y position (eye height above ground) -const MIN_Y = EYE_HEIGHT - -// Reusable vectors to avoid allocations in the render loop -const _forward = new Vector3() -const _right = new Vector3() -const _moveVector = new Vector3() -const _euler = new Euler(0, 0, 0, 'YXZ') +import BVHEcctrl from './first-person/bvh-ecctrl' +import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import { + buildFirstPersonColliderWorldFromRegistry, + deriveFirstPersonSpawn, + FIRST_PERSON_SPAWN_EYE_HEIGHT, + type FirstPersonColliderWorld, + type FirstPersonSpawn, +} from './first-person/build-collider-world' + +const CAMERA_EYE_OFFSET = 0.45 +const LOOK_SENSITIVITY = 0.002 +const CONTROLLER_CENTER_FROM_EYE = 0.85 +const keyboardMap = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] + +const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const spawnWorldPosition = new Vector3() +const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') + +const resolvePlacedSpawnNode = ( + nodes: ReturnType['nodes'], + _levelId: string | null, + ) => { + const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') + if (candidates.length === 0) return null + + return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null +} export const FirstPersonControls = () => { const { camera, gl } = useThree() - const keysRef = useRef>(new Set()) + const selectedLevelId = useViewer((state) => state.selection.levelId) + const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) + const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) - const isLockedRef = useRef(false) - const initializedRef = useRef(false) + const [world, setWorld] = useState(null) + + const placedSpawn = useMemo(() => { + if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null + + const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id) + if (spawnObject) { + spawnObject.updateWorldMatrix(true, false) + spawnObject.getWorldPosition(spawnWorldPosition) + spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ') + + return { + position: [ + spawnWorldPosition.x, + spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT, + spawnWorldPosition.z, + ], + yaw: spawnWorldEuler.y, + } + } + + return { + position: [ + placedSpawnNode.position[0], + placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT, + placedSpawnNode.position[2], + ], + yaw: placedSpawnNode.rotation, + } + }, [placedSpawnNode]) - // Initialize camera for first-person view: start at center of scene, on the ground useEffect(() => { - if (initializedRef.current) return - initializedRef.current = true + const nextWorld = buildFirstPersonColliderWorldFromRegistry() + if (!nextWorld) { + setWorld(null) + return + } - // Place camera at the origin (center of grid) at eye height, looking along +X - camera.position.set(0, EYE_HEIGHT, 0) - yawRef.current = 0 - pitchRef.current = 0 + setWorld(nextWorld) + + return () => { + nextWorld.dispose() + setWorld(null) + } }, [camera]) - // Pointer lock and event handlers + useEffect(() => { + if (!world) return + yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + pitchRef.current = 0 + }, [camera, placedSpawn, world]) + useEffect(() => { const canvas = gl.domElement + const handleMouseMove = (e: MouseEvent) => { + if (document.pointerLockElement !== canvas) return - const requestLock = () => { - if (!isLockedRef.current) { - canvas.requestPointerLock() - } + yawRef.current -= e.movementX * LOOK_SENSITIVITY + pitchRef.current = Math.max( + -(Math.PI / 2 - 0.05), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), + ) } - const handlePointerLockChange = () => { - isLockedRef.current = document.pointerLockElement === canvas + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + if (!canvas.contains(target)) return + if (document.pointerLockElement !== canvas) { + canvas.requestPointerLock?.() + } } - const handleMouseMove = (e: MouseEvent) => { - if (!isLockedRef.current) return + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('click', handleClick) - yawRef.current -= e.movementX * MOUSE_SENSITIVITY - pitchRef.current -= e.movementY * MOUSE_SENSITIVITY - // Clamp pitch to prevent flipping (almost straight up/down) - pitchRef.current = Math.max( - -Math.PI / 2 + 0.05, - Math.min(Math.PI / 2 - 0.05, pitchRef.current), - ) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('click', handleClick) + if (document.pointerLockElement === canvas) { + document.exitPointerLock() + } } + }, [gl]) - const handleKeyDown = (e: KeyboardEvent) => { - // Skip if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return - } + useEffect(() => { + const canvas = gl.domElement - const code = e.code - - // Movement keys - if ( - code === 'KeyW' || - code === 'KeyA' || - code === 'KeyS' || - code === 'KeyD' || - code === 'KeyQ' || - code === 'KeyE' || - code === 'ShiftLeft' || - code === 'ShiftRight' - ) { - e.preventDefault() - e.stopPropagation() - keysRef.current.add(code) + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return } - // ESC exits first-person mode - if (code === 'Escape') { - e.preventDefault() - e.stopPropagation() + if (event.code === 'Escape') { + event.preventDefault() + event.stopPropagation() if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -104,75 +155,73 @@ export const FirstPersonControls = () => { } } - const handleKeyUp = (e: KeyboardEvent) => { - keysRef.current.delete(e.code) - } - - canvas.addEventListener('click', requestLock) - document.addEventListener('pointerlockchange', handlePointerLockChange) - document.addEventListener('mousemove', handleMouseMove) - // Use capture phase so we intercept movement keys before the global keyboard handler document.addEventListener('keydown', handleKeyDown, true) - document.addEventListener('keyup', handleKeyUp) - return () => { - canvas.removeEventListener('click', requestLock) - document.removeEventListener('pointerlockchange', handlePointerLockChange) - document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('keydown', handleKeyDown, true) - document.removeEventListener('keyup', handleKeyUp) - if (document.pointerLockElement === canvas) { - document.exitPointerLock() - } - keysRef.current.clear() } }, [gl]) - // Per-frame movement and camera rotation useFrame((_, delta) => { - // Clamp delta to avoid huge jumps (e.g. tab switching) - const dt = Math.min(delta, 0.1) - const keys = keysRef.current - - const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight') - const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1) - - // Calculate forward and right vectors on the XZ plane (ignore pitch for movement) - _forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current)) - _right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current)) - - _moveVector.set(0, 0, 0) - - if (keys.has('KeyW')) _moveVector.add(_forward) - if (keys.has('KeyS')) _moveVector.sub(_forward) - if (keys.has('KeyA')) _moveVector.sub(_right) - if (keys.has('KeyD')) _moveVector.add(_right) - - // Normalize diagonal movement so it's not faster - if (_moveVector.lengthSq() > 0) { - _moveVector.normalize().multiplyScalar(speed * dt) - camera.position.add(_moveVector) - } + if (!controllerRef.current?.group) return + + const group = controllerRef.current.group + group.rotation.y = 0 + camera.position.copy(group.position).add(cameraOffset) + cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') + camera.quaternion.setFromEuler(cameraEuler) + camera.updateMatrixWorld(true) + }) - // Vertical movement (Q = up, E = down) - if (keys.has('KeyQ')) { - camera.position.y += VERTICAL_SPEED * dt - } - if (keys.has('KeyE')) { - camera.position.y -= VERTICAL_SPEED * dt - } + const controllerPosition = useMemo(() => { + if (!world) return null + const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position + return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const + }, [camera, placedSpawn, world]) - // Clamp Y so camera never goes below ground level + eye height - if (camera.position.y < MIN_Y) { - camera.position.y = MIN_Y - } + const spawnYaw = useMemo(() => { + if (!world) return 0 + return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + }, [camera, placedSpawn, world]) - // Apply look rotation - _euler.set(pitchRef.current, yawRef.current, 0, 'YXZ') - camera.quaternion.setFromEuler(_euler) - }) + if (!world) { + return null + } - return null + return ( + <> + {controllerPosition && ( + + + + )} + + ) } /** @@ -180,6 +229,23 @@ export const FirstPersonControls = () => { * Rendered as a regular DOM overlay (not inside the Canvas). */ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { + const [isLocked, setIsLocked] = useState(false) + const hasPlacedSpawn = useScene((state) => + Object.values(state.nodes).some((node) => node.type === 'spawn'), + ) + + useEffect(() => { + const handlePointerLockChange = () => { + setIsLocked(document.pointerLockElement != null) + } + + handlePointerLockChange() + document.addEventListener('pointerlockchange', handlePointerLockChange) + return () => { + document.removeEventListener('pointerlockchange', handlePointerLockChange) + } + }, []) + const handleExit = useCallback(() => { if (document.pointerLockElement) { document.exitPointerLock() @@ -189,15 +255,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { return ( <> - {/* Crosshair */} -
-
-
-
+ {isLocked && ( +
+
+
+
+
-
+ )} - {/* Exit button — top-right */}
- {/* Controls hint — bottom-center */} -
-
- -
- - -
- -
- Click to look around + {!hasPlacedSpawn && ( +
+
+ Place a Spawn Point from the Build tab to control where walkthrough starts. +
-
+ )} + + {isLocked && ( +
+
+ +
+ + +
+ Click to look around +
+
+ )} ) } function ControlHint({ label, keys }: { label: string; keys: string[] }) { return ( -
+
{label} -
+
{keys.map((key) => ( ) } + +function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { + return ( +
+ + {label} + + + {keyLabel} + +
+ ) +} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts new file mode 100644 index 000000000..d6a6e82ea --- /dev/null +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -0,0 +1,262 @@ +import { sceneRegistry, useScene } from '@pascal-app/core' +import { + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, +} from 'three-mesh-bvh' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import * as THREE from 'three' + +const COLLIDER_NODE_TYPES = [ + 'wall', + 'fence', + 'slab', + 'stair', + 'stair-segment', + 'roof', + 'roof-segment', + 'door', + 'item', +] as const + +const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) +const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() +const DOWN = new THREE.Vector3(0, -1, 0) +const UP = new THREE.Vector3(0, 1, 0) +const SPAWN_EYE_HEIGHT = 1.65 +const RAYCAST_CLEARANCE = 25 + +export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT + +export type FirstPersonColliderWorld = { + mesh: THREE.Mesh + bounds: THREE.Box3 | null + dispose: () => void +} + +export type FirstPersonSpawn = { + position: [number, number, number] + yaw: number +} + +type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] + +function isMesh(object: THREE.Object3D): object is THREE.Mesh { + return 'isMesh' in object && (object as THREE.Mesh).isMesh +} + +function cloneWorldGeometry(mesh: THREE.Mesh) { + const sourceGeometry = mesh.geometry + const position = sourceGeometry.getAttribute('position') + if (!position || position.count < 3) return null + + const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() + const cleanGeometry = new THREE.BufferGeometry() + cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) + + const normal = workingGeometry.getAttribute('normal') + if (normal) { + cleanGeometry.setAttribute('normal', normal.clone()) + } else { + cleanGeometry.computeVertexNormals() + } + + cleanGeometry.applyMatrix4(mesh.matrixWorld) + workingGeometry.dispose() + + const worldPosition = cleanGeometry.getAttribute('position') + if (!worldPosition || worldPosition.count < 3) { + cleanGeometry.dispose() + return null + } + + return cleanGeometry +} + +function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { + if (type !== 'door') return false + + const node = useScene.getState().nodes[nodeId] + if (!node || node.type !== 'door') return false + + if (!node.segments.length) return true + + return node.segments.every((segment) => segment.type === 'empty') +} + +function buildRegisteredNodeTypeLookup() { + const nodeTypes = new Map() + + for (const type of COLLIDER_NODE_TYPES) { + for (const nodeId of sceneRegistry.byType[type]) { + nodeTypes.set(nodeId, type) + } + } + + return nodeTypes +} + +function collectColliderGeometriesFromNode( + root: THREE.Object3D, + rootNodeId: string, + visitedMeshes: WeakSet, + registeredObjectIds: Map, + registeredNodeTypes: Map, +): THREE.BufferGeometry[] { + const geometries: THREE.BufferGeometry[] = [] + + const visit = (object: THREE.Object3D) => { + if (visitedMeshes.has(object)) return + visitedMeshes.add(object) + + if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) { + const geometry = cloneWorldGeometry(object) + if (geometry) { + geometries.push(geometry) + } + } + + for (const child of object.children) { + const childNodeId = registeredObjectIds.get(child) + if (childNodeId && childNodeId !== rootNodeId) { + const childType = registeredNodeTypes.get(childNodeId) + if (childType && COLLIDER_NODE_TYPES.includes(childType)) { + continue + } + } + + visit(child) + } + } + + visit(root) + + return geometries +} + +export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { + const geometries: THREE.BufferGeometry[] = [] + const visitedMeshes = new WeakSet() + const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredObjectIds = new Map() + + for (const [nodeId, object] of sceneRegistry.nodes) { + registeredObjectIds.set(object, nodeId) + } + + for (const type of COLLIDER_NODE_TYPES) { + for (const nodeId of sceneRegistry.byType[type]) { + if (shouldSkipColliderNode(nodeId, type)) continue + + const root = sceneRegistry.nodes.get(nodeId) + if (!root) continue + + root.updateMatrixWorld(true) + geometries.push( + ...collectColliderGeometriesFromNode( + root, + nodeId, + visitedMeshes, + registeredObjectIds, + registeredNodeTypes, + ), + ) + } + } + + if (geometries.length === 0) { + return null + } + + const mergedGeometry = mergeGeometries(geometries, false) + geometries.forEach((geometry) => geometry.dispose()) + + if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { + mergedGeometry?.dispose() + return null + } + + const bvhGeometry = mergedGeometry as THREE.BufferGeometry & { + computeBoundsTree?: typeof computeBoundsTree + disposeBoundsTree?: typeof disposeBoundsTree + } + + ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree + ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree + bvhGeometry.computeBoundsTree?.({ + maxLeafTris: 12, + strategy: 0, + } as never) + bvhGeometry.computeBoundingBox() + + const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL) + mesh.raycast = acceleratedRaycast + mesh.visible = true + mesh.userData = { + type: 'STATIC', + friction: 0.8, + restitution: 0.05, + excludeFloatHit: false, + excludeCollisionCheck: false, + } + mesh.updateMatrixWorld(true) + + return { + mesh, + bounds: bvhGeometry.boundingBox?.clone() ?? null, + dispose: () => { + bvhGeometry.disposeBoundsTree?.() + bvhGeometry.dispose() + }, + } +} + +export function deriveFirstPersonSpawn( + camera: THREE.Camera, + world: FirstPersonColliderWorld, +): FirstPersonSpawn { + const direction = new THREE.Vector3() + camera.getWorldDirection(direction) + direction.y = 0 + if (direction.lengthSq() < 1e-6) { + direction.set(0, 0, -1) + } else { + direction.normalize() + } + + const yaw = Math.atan2(-direction.x, -direction.z) + const raycaster = new THREE.Raycaster() + const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]] + + const boundsCenter = world.bounds?.getCenter(new THREE.Vector3()) + if (boundsCenter) { + candidates.push([boundsCenter.x, boundsCenter.z]) + } + + for (const [x, z] of candidates) { + const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE + raycaster.set(new THREE.Vector3(x, topY, z), DOWN) + const intersections = raycaster.intersectObject(world.mesh, false) + const hit = intersections.find((intersection) => { + if (!intersection.face) return true + const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld) + return normal.dot(UP) > 0.2 + }) + + if (hit) { + return { + position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z], + yaw, + } + } + } + + return { + position: [ + camera.position.x, + Math.max(camera.position.y, SPAWN_EYE_HEIGHT), + camera.position.z, + ], + yaw, + } +} diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx new file mode 100644 index 000000000..2a3e1e795 --- /dev/null +++ b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx @@ -0,0 +1,795 @@ +import '../../../three-types' +import { TransformControls, useKeyboardControls } from '@react-three/drei' +import { useFrame, useThree, type ThreeElements } from '@react-three/fiber' +import { + Suspense, + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' +import type { ReactNode } from 'react' +import * as THREE from 'three' +import { clamp } from 'three/src/math/MathUtils.js' + +export type MovementInput = { + forward?: boolean + backward?: boolean + leftward?: boolean + rightward?: boolean + joystick?: { x: number; y: number } + run?: boolean + jump?: boolean +} + +export type CharacterAnimationStatus = + | 'IDLE' + | 'WALK' + | 'RUN' + | 'JUMP_START' + | 'JUMP_IDLE' + | 'JUMP_FALL' + | 'JUMP_LAND' + +export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH' + +export interface BVHEcctrlApi { + group: THREE.Group | null + model: THREE.Group | null + resetLinVel: () => void + addLinVel: (v: THREE.Vector3) => void + setLinVel: (v: THREE.Vector3) => void + setMovement: (input: MovementInput) => void +} + +export interface EcctrlProps extends Omit { + children?: ReactNode + debug?: boolean + colliderMeshes?: THREE.Mesh[] + colliderCapsuleArgs?: [ + radius: number, + length: number, + capSegments: number, + radialSegments: number, + ] + paused?: boolean + delay?: number + gravity?: number + fallGravityFactor?: number + maxFallSpeed?: number + mass?: number + sleepTimeout?: number + slowMotionFactor?: number + turnSpeed?: number + maxWalkSpeed?: number + maxRunSpeed?: number + acceleration?: number + deceleration?: number + counterAccFactor?: number + airDragFactor?: number + jumpVel?: number + floatCheckType?: FloatCheckType + maxSlope?: number + floatHeight?: number + floatPullBackHeight?: number + floatSensorRadius?: number + floatSpringK?: number + floatDampingC?: number + collisionCheckIteration?: number + collisionPushBackDamping?: number + collisionPushBackThreshold?: number +} + +type CharacterStatus = { + position: THREE.Vector3 + linvel: THREE.Vector3 + quaternion: THREE.Quaternion + inputDir: THREE.Vector3 + movingDir: THREE.Vector3 + isOnGround: boolean + isOnMovingPlatform: boolean + animationStatus: CharacterAnimationStatus +} + +export const characterStatus: CharacterStatus = { + position: new THREE.Vector3(), + linvel: new THREE.Vector3(), + quaternion: new THREE.Quaternion(), + inputDir: new THREE.Vector3(), + movingDir: new THREE.Vector3(), + isOnGround: false, + isOnMovingPlatform: false, + animationStatus: 'IDLE', +} + +const BVHEcctrl = forwardRef( + ( + { + children, + debug = false, + colliderMeshes = [], + colliderCapsuleArgs = [0.3, 0.6, 4, 8], + paused = false, + delay = 1.5, + gravity = 9.81, + fallGravityFactor = 4, + maxFallSpeed = 50, + mass = 1, + sleepTimeout = 10, + slowMotionFactor = 1, + turnSpeed = 15, + maxWalkSpeed = 3, + maxRunSpeed = 5, + acceleration = 30, + deceleration = 20, + counterAccFactor = 0.5, + airDragFactor = 0.3, + jumpVel = 5, + floatCheckType = 'BOTH', + maxSlope = 1, + floatHeight = 0.2, + floatPullBackHeight = 0.25, + floatSensorRadius = 0.12, + floatSpringK = 600, + floatDampingC = 28, + collisionCheckIteration = 3, + collisionPushBackDamping = 0.1, + collisionPushBackThreshold = 0.05, + ...props + }, + ref, + ) => { + const { camera } = useThree() + const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs]) + const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs]) + const characterGroupRef = useRef(null) + const characterColliderRef = useRef(null) + const characterModelRef = useRef(null) + const debugLineStart = useRef(null) + const debugLineEnd = useRef(null) + const debugRaySensorStart = useRef(null) + const debugRaySensorEnd = useRef(null) + const standPointRef = useRef(null) + const lookDirRef = useRef(null) + const inputDirRef = useRef(null) + const moveDirRef = useRef(null) + const elapsedRef = useRef(0) + + function useIsInsideKeyboardControls() { + try { + return !!useKeyboardControls() + } catch { + return false + } + } + + const isInsideKeyboardControls = useIsInsideKeyboardControls() + const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null] + const presetKeys = { + forward: false, + backward: false, + leftward: false, + rightward: false, + jump: false, + run: false, + } + + const upAxis = useRef(new THREE.Vector3(0, 1, 0)) + const localUpAxis = useRef(new THREE.Vector3()) + const gravityDir = useRef(new THREE.Vector3(0, -1, 0)) + const currentLinVel = useRef(new THREE.Vector3()) + const currentLinVelOnPlane = useRef(new THREE.Vector3()) + const isFalling = useRef(false) + const idleTime = useRef(0) + const isSleeping = useRef(false) + const camProjDir = useRef(new THREE.Vector3()) + const camRightDir = useRef(new THREE.Vector3()) + const inputDir = useRef(new THREE.Vector3()) + const inputDirOnPlane = useRef(new THREE.Vector3()) + const movingDir = useRef(new THREE.Vector3()) + const deltaLinVel = useRef(new THREE.Vector3()) + const wantToMoveVel = useRef(new THREE.Vector3()) + const forwardState = useRef(false) + const backwardState = useRef(false) + const leftwardState = useRef(false) + const rightwardState = useRef(false) + const joystickState = useRef(new THREE.Vector2()) + const runState = useRef(false) + const jumpState = useRef(false) + const isOnGround = useRef(false) + const prevIsOnGround = useRef(false) + const prevAnimation = useRef('IDLE') + const characterModelTargetQuat = useRef(new THREE.Quaternion()) + const characterModelLookMatrix = useRef(new THREE.Matrix4()) + const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), []) + const contactDepth = useRef(0) + const contactNormal = useRef(new THREE.Vector3()) + const triContactPoint = useRef(new THREE.Vector3()) + const capsuleContactPoint = useRef(new THREE.Vector3()) + const totalDepth = useRef(0) + const triangleCount = useRef(0) + const accumulatedContactNormal = useRef(new THREE.Vector3()) + const accumulatedContactPoint = useRef(new THREE.Vector3()) + const absorbVel = useRef(new THREE.Vector3()) + const pushBackVel = useRef(new THREE.Vector3()) + const characterBbox = useRef(new THREE.Box3()) + const characterSegment = useRef(new THREE.Line3()) + const localCharacterBbox = useRef(new THREE.Box3()) + const localCharacterSegment = useRef(new THREE.Line3()) + const collideInvertMatrix = useRef(new THREE.Matrix4()) + const relativeCollideVel = useRef(new THREE.Vector3()) + const scaledContactRadiusVec = useRef(new THREE.Vector3()) + const deltaDist = useRef(new THREE.Vector3()) + const currSlopeAngle = useRef(0) + const localMinDistance = useRef(Infinity) + const localClosestPoint = useRef(new THREE.Vector3()) + const localHitNormal = useRef(new THREE.Vector3()) + const triNormal = useRef(new THREE.Vector3()) + const globalMinDistance = useRef(Infinity) + const globalClosestPoint = useRef(new THREE.Vector3()) + const triHitPoint = useRef(new THREE.Vector3()) + const segHitPoint = useRef(new THREE.Vector3()) + const floatHitNormal = useRef(new THREE.Vector3()) + const groundFriction = useRef(0.8) + const floatSensorBbox = useRef(new THREE.Box3()) + const floatSensorBboxExpendPoint = useRef(new THREE.Vector3()) + const floatSensorSegment = useRef(new THREE.Line3()) + const localFloatSensorBbox = useRef(new THREE.Box3()) + const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3()) + const localFloatSensorSegment = useRef(new THREE.Line3()) + const floatInvertMatrix = useRef(new THREE.Matrix4()) + const floatNormalInverseMatrix = useRef(new THREE.Matrix3()) + const floatNormalMatrix = useRef(new THREE.Matrix3()) + const floatRaycaster = useRef(new THREE.Raycaster()) + const relativeHitPoint = useRef(new THREE.Vector3()) + const totalPlatformDeltaPos = useRef(new THREE.Vector3()) + const isOnMovingPlatform = useRef(false) + const floatTempPos = useRef(new THREE.Vector3()) + const floatTempQuat = useRef(new THREE.Quaternion()) + const floatTempScale = useRef(new THREE.Vector3()) + const scaledFloatRadiusVec = useRef(new THREE.Vector3()) + const deltaHit = useRef(new THREE.Vector3()) + const rotationDeltaPos = useRef(new THREE.Vector3()) + const yawQuaternion = useRef(new THREE.Quaternion()) + const contactTempPos = useRef(new THREE.Vector3()) + const contactTempQuat = useRef(new THREE.Quaternion()) + const contactTempScale = useRef(new THREE.Vector3()) + + floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight + + const floatRaycastCandidates = useMemo( + () => + colliderMeshes.filter( + (mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh), + ), + [colliderMeshes], + ) + + const applyGravity = useCallback( + (delta: number) => { + gravityDir.current.copy(upAxis.current).negate() + const fallingSpeed = currentLinVel.current.dot(gravityDir.current) + isFalling.current = fallingSpeed > 0 + if (fallingSpeed < maxFallSpeed) { + currentLinVel.current.addScaledVector( + gravityDir.current, + gravity * (isFalling.current ? fallGravityFactor : 1) * delta, + ) + } + }, + [fallGravityFactor, gravity, maxFallSpeed], + ) + + const checkCharacterSleep = useCallback( + (jump: boolean, delta: number) => { + const moving = currentLinVel.current.lengthSq() > 1e-6 + const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6 + + if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) { + idleTime.current += delta + if (idleTime.current > sleepTimeout) isSleeping.current = true + } else { + idleTime.current = 0 + isSleeping.current = false + } + }, + [sleepTimeout], + ) + + const setInputDirection = useCallback( + (dir: { + forward?: boolean + backward?: boolean + leftward?: boolean + rightward?: boolean + joystick?: THREE.Vector2 + }) => { + inputDir.current.set(0, 0, 0) + + camera.getWorldDirection(camProjDir.current) + camProjDir.current.projectOnPlane(upAxis.current).normalize() + camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize() + + if (dir.joystick && dir.joystick.lengthSq() > 0) { + inputDir.current + .addScaledVector(camProjDir.current, dir.joystick.y) + .addScaledVector(camRightDir.current, dir.joystick.x) + } else { + if (dir.forward) inputDir.current.add(camProjDir.current) + if (dir.backward) inputDir.current.sub(camProjDir.current) + if (dir.leftward) inputDir.current.sub(camRightDir.current) + if (dir.rightward) inputDir.current.add(camRightDir.current) + } + + inputDir.current.normalize() + }, + [camera], + ) + + const handleCharacterMovement = useCallback( + (run: boolean, delta: number) => { + const friction = clamp(groundFriction.current, 0, 1) + + if (inputDir.current.lengthSq() > 0) { + if (characterModelRef.current) { + inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current) + characterModelLookMatrix.current.lookAt( + inputDirOnPlane.current, + characterOrigin, + upAxis.current, + ) + characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current) + characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed) + } + + const maxSpeed = run ? maxRunSpeed : maxWalkSpeed + wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed) + const dot = movingDir.current.dot(inputDir.current) + + deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current) + deltaLinVel.current.clampLength( + 0, + (dot <= 0 ? 1 + counterAccFactor : 1) * + acceleration * + friction * + delta * + (isOnGround.current ? 1 : airDragFactor), + ) + currentLinVel.current.add(deltaLinVel.current) + } else if (isOnGround.current) { + deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta) + currentLinVel.current.sub(deltaLinVel.current) + } + }, + [acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin], + ) + + const updateSegmentBBox = useCallback(() => { + if (!characterGroupRef.current) return + + characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position) + characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position) + + characterBbox.current + .makeEmpty() + .expandByPoint(characterSegment.current.start) + .expandByPoint(characterSegment.current.end) + .expandByScalar(capsuleRadius) + + floatSensorSegment.current.start.copy(characterSegment.current.end) + floatSensorSegment.current.end + .copy(floatSensorSegment.current.start) + .addScaledVector(gravityDir.current, floatHeight + capsuleRadius) + floatSensorBboxExpendPoint.current + .copy(floatSensorSegment.current.end) + .addScaledVector(gravityDir.current, floatPullBackHeight) + + floatSensorBbox.current + .makeEmpty() + .expandByPoint(floatSensorSegment.current.start) + .expandByPoint(floatSensorBboxExpendPoint.current) + .expandByScalar(floatSensorRadius) + }, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius]) + + const collisionCheck = useCallback( + (mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => { + if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return + + originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current) + collideInvertMatrix.current.copy(originMatrix).invert() + localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current) + + scaledContactRadiusVec.current.set( + capsuleRadius / contactTempScale.current.x, + capsuleRadius / contactTempScale.current.y, + capsuleRadius / contactTempScale.current.z, + ) + + localCharacterBbox.current + .makeEmpty() + .expandByPoint(localCharacterSegment.current.start) + .expandByPoint(localCharacterSegment.current.end) + localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1) + localCharacterBbox.current.max.add(scaledContactRadiusVec.current) + + contactDepth.current = 0 + contactNormal.current.set(0, 0, 0) + absorbVel.current.set(0, 0, 0) + pushBackVel.current.set(0, 0, 0) + totalDepth.current = 0 + triangleCount.current = 0 + accumulatedContactNormal.current.set(0, 0, 0) + accumulatedContactPoint.current.set(0, 0, 0) + + mesh.geometry.boundsTree.shapecast({ + intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current), + intersectsTriangle: (tri) => { + tri.closestPointToSegment( + localCharacterSegment.current, + triContactPoint.current, + capsuleContactPoint.current, + ) + + deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current) + deltaDist.current.divide(scaledContactRadiusVec.current) + + if (deltaDist.current.lengthSq() < 1) { + triContactPoint.current.applyMatrix4(originMatrix) + capsuleContactPoint.current.applyMatrix4(originMatrix) + + contactNormal.current + .copy(capsuleContactPoint.current) + .sub(triContactPoint.current) + .normalize() + contactDepth.current = + capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current) + + accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current) + accumulatedContactPoint.current.add(triContactPoint.current) + totalDepth.current += contactDepth.current + triangleCount.current += 1 + } + }, + }) + + if (triangleCount.current > 0) { + accumulatedContactNormal.current.normalize() + accumulatedContactPoint.current.divideScalar(triangleCount.current) + const avgDepth = totalDepth.current / triangleCount.current + relativeCollideVel.current.copy(currentLinVel.current) + const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current) + + if (intoSurfaceVel < 0) { + absorbVel.current + .copy(accumulatedContactNormal.current) + .multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05))) + currentLinVel.current.add(absorbVel.current) + } + + if (avgDepth > collisionPushBackThreshold) { + const correction = (collisionPushBackDamping / delta) * avgDepth + pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction) + currentLinVel.current.add(pushBackVel.current) + } + } + }, + [capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold], + ) + + const handleCollisionResponse = useCallback( + (meshes: THREE.Mesh[], delta: number) => { + if (meshes.length === 0) return + + for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) { + for (const mesh of meshes) { + collisionCheck(mesh, mesh.matrixWorld, delta) + } + } + }, + [collisionCheck, collisionCheckIteration], + ) + + const floatingCheck = useCallback( + (mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => { + if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return + + originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current) + floatInvertMatrix.current.copy(originMatrix).invert() + floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current) + floatNormalMatrix.current.getNormalMatrix(originMatrix) + + localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current) + localFloatSensorBboxExpendPoint.current + .copy(floatSensorBboxExpendPoint.current) + .applyMatrix4(floatInvertMatrix.current) + + scaledFloatRadiusVec.current.set( + floatSensorRadius / floatTempScale.current.x, + floatSensorRadius / floatTempScale.current.y, + floatSensorRadius / floatTempScale.current.z, + ) + + localFloatSensorBbox.current + .makeEmpty() + .expandByPoint(localFloatSensorSegment.current.start) + .expandByPoint(localFloatSensorBboxExpendPoint.current) + localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1) + localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current) + + localMinDistance.current = Infinity + localClosestPoint.current.set(Infinity, Infinity, Infinity) + + mesh.geometry.boundsTree.shapecast({ + intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current), + intersectsTriangle: (tri) => { + tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current) + localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize() + deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start) + deltaHit.current.divide(scaledFloatRadiusVec.current) + + const totalLengthSq = deltaHit.current.lengthSq() + const dot = deltaHit.current.dot(localUpAxis.current) + const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius) + const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot)) + + if (horizontalLength < 1 && verticalLength < 1) { + tri.getNormal(triNormal.current) + triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize() + triHitPoint.current.applyMatrix4(originMatrix) + + const slopeAngle = triNormal.current.angleTo(upAxis.current) + if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) { + localMinDistance.current = verticalLength + localClosestPoint.current.copy(triHitPoint.current) + localHitNormal.current.copy(triNormal.current) + } + } + }, + }) + + if (localMinDistance.current < globalMinDistance.current) { + globalMinDistance.current = localMinDistance.current + globalClosestPoint.current.copy(localClosestPoint.current) + floatHitNormal.current.copy(localHitNormal.current) + } + }, + [capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope], + ) + + const handleFloatingResponse = useCallback( + (meshes: THREE.Mesh[], jump: boolean, delta: number) => { + if (meshes.length === 0) return + + globalMinDistance.current = Infinity + globalClosestPoint.current.set(Infinity, Infinity, Infinity) + floatHitNormal.current.set(0, 1, 0) + isOnGround.current = false + totalPlatformDeltaPos.current.set(0, 0, 0) + isOnMovingPlatform.current = false + + if (floatCheckType !== 'RAYCAST') { + for (const mesh of meshes) { + floatingCheck(mesh, mesh.matrixWorld) + } + } + + if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) { + floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start) + floatRaycaster.current.ray.direction.copy(gravityDir.current) + const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false) + const hit = hits[0] + if (hit?.point) { + globalClosestPoint.current.copy(hit.point) + if (hit.face) { + floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize() + } + } + } + + if (globalClosestPoint.current.x === Infinity) return + + relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start) + const currentDistance = relativeHitPoint.current.length() + currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current) + + if (currentDistance < floatHeight + capsuleRadius) { + isOnGround.current = true + jump = false + } + + if (!jump) { + const displacement = floatHeight + capsuleRadius - currentDistance + const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current) + const springForce = displacement * floatSpringK + const dampingForce = -velocityOnHitNormal * floatDampingC + const totalForce = springForce + dampingForce - mass * gravity + + currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta) + } + }, + [capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass], + ) + + const updateCharacterWithPlatform = useCallback(() => { + if (!characterGroupRef.current) return + rotationDeltaPos.current.copy(totalPlatformDeltaPos.current) + characterGroupRef.current.position.add(rotationDeltaPos.current) + yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current) + }, [upAxis]) + + const updateCharacterAnimation = useCallback( + (run: boolean, jump: boolean): CharacterAnimationStatus => { + if (prevIsOnGround.current && jump) return 'JUMP_START' + if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE' + if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL' + if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND' + if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK' + return 'IDLE' + }, + [], + ) + + const updateCharacterStatus = useCallback( + (run: boolean, jump: boolean) => { + characterModelRef.current?.getWorldPosition(characterStatus.position) + characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion) + characterStatus.linvel.copy(currentLinVel.current) + characterStatus.inputDir.copy(inputDir.current) + characterStatus.movingDir.copy(movingDir.current) + characterStatus.isOnGround = isOnGround.current + characterStatus.isOnMovingPlatform = isOnMovingPlatform.current + characterStatus.animationStatus = updateCharacterAnimation(run, jump) + prevAnimation.current = characterStatus.animationStatus + }, + [updateCharacterAnimation], + ) + + const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), []) + const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), []) + const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), []) + const setMovement = useCallback((movement: MovementInput) => { + if (movement.forward !== undefined) forwardState.current = movement.forward + if (movement.backward !== undefined) backwardState.current = movement.backward + if (movement.leftward !== undefined) leftwardState.current = movement.leftward + if (movement.rightward !== undefined) rightwardState.current = movement.rightward + if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y) + if (movement.run !== undefined) runState.current = movement.run + if (movement.jump !== undefined) jumpState.current = movement.jump + }, []) + + useImperativeHandle( + ref, + () => ({ + get group() { + return characterGroupRef.current + }, + get model() { + return characterModelRef.current + }, + resetLinVel, + addLinVel, + setLinVel, + setMovement, + }), + [addLinVel, resetLinVel, setLinVel, setMovement], + ) + + const updateDebugger = useCallback(() => { + debugLineStart.current?.position.copy(characterSegment.current.start) + debugLineEnd.current?.position.copy(characterSegment.current.end) + debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start) + debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end) + standPointRef.current?.position.copy(globalClosestPoint.current) + if (characterGroupRef.current) { + lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7) + } + lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current)) + inputDirRef.current?.position.copy(characterSegment.current.end) + inputDirRef.current?.setDirection(inputDir.current) + inputDirRef.current?.setLength(inputDir.current.lengthSq()) + moveDirRef.current?.position.copy(characterSegment.current.end) + moveDirRef.current?.setDirection(currentLinVel.current) + moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed) + }, [characterSegment, maxWalkSpeed]) + + useFrame((_, delta) => { + elapsedRef.current += delta + if (paused || elapsedRef.current < delay) return + + const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor + const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys + const forward = forwardState.current || keys.forward + const backward = backwardState.current || keys.backward + const leftward = leftwardState.current || keys.leftward + const rightward = rightwardState.current || keys.rightward + const run = runState.current || keys.run + const jump = jumpState.current || keys.jump + + setInputDirection({ + forward, + backward, + leftward, + rightward, + joystick: joystickState.current, + }) + handleCharacterMovement(run, deltaTime) + if (jump && isOnGround.current) currentLinVel.current.y = jumpVel + movingDir.current.copy(currentLinVel.current).normalize() + currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current) + + checkCharacterSleep(jump, deltaTime) + if (!isSleeping.current) { + if (!isOnGround.current) applyGravity(deltaTime) + + updateSegmentBBox() + handleCollisionResponse(colliderMeshes, deltaTime) + handleFloatingResponse(colliderMeshes, jump, deltaTime) + updateCharacterWithPlatform() + + if (characterGroupRef.current) { + characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime) + } + + updateCharacterStatus(run, jump) + prevIsOnGround.current = isOnGround.current + } + + if (debug) updateDebugger() + }) + + return ( + + + {debug && ( + + + + + )} + + {children} + + + + {debug && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) + }, +) + +BVHEcctrl.displayName = 'BVHEcctrl' + +export default BVHEcctrl diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 6d5d36ed4..fec5d288e 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -10,6 +10,7 @@ import { 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 } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index de20291e9..893b63ec3 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -20,7 +20,6 @@ import { import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' -import { useAutoFrame } from '../../hooks/use-auto-frame' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { @@ -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 diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 884fc80df..b42ee1c8e 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -73,6 +73,7 @@ type SelectableNodeType = | 'roof-segment' | 'stair' | 'stair-segment' + | 'spawn' | 'window' | 'door' @@ -548,6 +549,7 @@ const SELECTION_STRATEGIES: Record = { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', ], @@ -598,7 +600,8 @@ const SELECTION_STRATEGIES: Record = { node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || - node.type === 'stair-segment' + node.type === 'stair-segment' || + node.type === 'spawn' ) return true if (node.type === 'item') { @@ -661,6 +664,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { node.type === 'roof-segment' || node.type === 'stair' || node.type === 'stair-segment' || + node.type === 'spawn' || node.type === 'window' || node.type === 'door' ) { @@ -965,6 +969,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', 'zone', @@ -1134,6 +1139,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', ] @@ -1227,6 +1233,7 @@ export const SelectionManager = () => { node.type === 'roof-segment' || node.type === 'stair' || node.type === 'stair-segment' || + node.type === 'spawn' || node.type === 'window' || node.type === 'door' ) { @@ -1279,6 +1286,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', 'zone', diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 8a8736923..b6c1397aa 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -7,6 +7,7 @@ import type { RoofNode, RoofSegmentNode, SlabNode, + SpawnNode, StairNode, StairSegmentNode, WallNode, @@ -21,6 +22,7 @@ import { MoveDoorTool } from '../door/move-door-tool' import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveSlabTool } from '../slab/move-slab-tool' +import { MoveSpawnTool } from '../spawn/move-spawn-tool' import { MoveWallTool } from '../wall/move-wall-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' @@ -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 if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return + if (movingNode.type === 'spawn') + return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 9d89ddf25..f20f8745e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -309,8 +309,11 @@ export const ceilingStrategy = { const rotY = ctx.draftItem?.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 - const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX) - const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ) + // Ceiling items are stored in ceiling-local coordinates, so snapping must + // use the ceiling hit's local position rather than world position. + const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z)) return { stateUpdate: { surface: 'ceiling', ceilingId: event.node.id }, @@ -320,7 +323,7 @@ export const ceilingStrategy = { }, cursorRotationY: 0, gridPosition: [x, -itemHeight, z], - cursorPosition: [x, event.position[1] - itemHeight, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], stopPropagation: true, } }, @@ -338,12 +341,13 @@ export const ceilingStrategy = { const rotY = ctx.draftItem.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 - const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX) - const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ) + const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z)) return { gridPosition: [x, -itemHeight, z], - cursorPosition: [x, event.position[1] - itemHeight, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: 0, nodeUpdate: null, stopPropagation: true, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 1fb6fe665..24beb46bf 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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 diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx new file mode 100644 index 000000000..6eef48a62 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx @@ -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 ( + + ) +} diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx new file mode 100644 index 000000000..30d294ee3 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -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 + Spawn Point +) + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const worldVector = new Vector3() + +function getExistingSpawnIds() { + const nodes = useScene.getState().nodes + return Object.values(nodes) + .filter((node) => node.type === 'spawn') + .map((node) => node.id) + .sort() +} + +function getLevelLocalSpawnPosition( + levelId: LevelNode['id'], + event: GridEvent, +): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) { + return [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + } + + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + + return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] +} + +type SpawnToolProps = { + currentLevelId: LevelNode['id'] | null + onPlaced?: (spawnId: SpawnNodeType['id']) => void +} + +export const SpawnTool: React.FC = ({ currentLevelId, onPlaced }) => { + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + 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 ( + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 1a4e13e0a..0938a254e 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -21,6 +21,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' +import { SpawnTool } from './spawn/spawn-tool' import { StairTool } from './stair/stair-tool' import { CurveWallTool } from './wall/curve-wall-tool' import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool' @@ -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 && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -152,7 +158,12 @@ export const ToolManager: React.FC = () => { {movingFenceEndpoint && } {curvingWall && } {curvingFence && } - {movingNode && movingNode.type !== 'building' && } + {movingNode && movingNode.type !== 'building' && ( + + )} + {!movingNode && showBuildTool && tool === 'spawn' && ( + + )} {!movingNode && BuildToolComponent && } diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index d0cf44b56..219378852 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -30,6 +30,7 @@ export const tools: ToolConfig[] = [ { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, + { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, ] export function StructureTools() { diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 0d03fc41f..e2bb41d86 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -8,6 +8,7 @@ interface SliderControlProps { label: React.ReactNode value: number onChange: (value: number) => void + onCommit?: (value: number) => void min?: number max?: number precision?: number @@ -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) => { @@ -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) => { diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index f83380c27..a11ff7be5 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -8,11 +8,16 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { MoreVertical, Plus, Trash2 } from 'lucide-react' +import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { + buildLevelDuplicateCreateOps, + type LevelDuplicatePreset, +} from '../../lib/level-duplication' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { cn } from '../../lib/utils' +import { LevelDuplicateDialog } from './level-duplicate-dialog' import { Dialog, DialogContent, @@ -92,13 +97,16 @@ function LevelRow({ level, isSelected, onSelect, + onDuplicate, onRequestDelete, }: { level: LevelNode isSelected: boolean onSelect: () => void + onDuplicate: (preset?: LevelDuplicatePreset) => void onRequestDelete: () => void }) { + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) return ( @@ -142,7 +150,29 @@ function LevelRow({ - + + +
)} + { + onDuplicate(preset) + setDuplicateDialogOpen(false) + }} + onOpenChange={setDuplicateDialogOpen} + open={duplicateDialogOpen} + />
) } @@ -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(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, + })), + ) + } + 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() { handleDuplicateLevel(level, preset)} onRequestDelete={() => setDeletingLevel(level)} onSelect={() => setSelection( diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index bd62e4107..71c465bec 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -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 } + if (mode === 'material-paint') { + return null + } + // Show appropriate helper based on current tool switch (tool) { case 'wall': diff --git a/packages/editor/src/components/ui/level-duplicate-dialog.tsx b/packages/editor/src/components/ui/level-duplicate-dialog.tsx new file mode 100644 index 000000000..8e5b5dd8c --- /dev/null +++ b/packages/editor/src/components/ui/level-duplicate-dialog.tsx @@ -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('everything') + + useEffect(() => { + if (open) { + setPreset('everything') + } + }, [open]) + + return ( + + + + Duplicate Level + + Choose what to copy from {getLevelLabel(level)}. + + + +
+ {DUPLICATE_PRESETS.map((option) => ( + + ))} +
+ + + + + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index ffa8ec9d0..dabd70bcf 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -1,6 +1,7 @@ 'use client' import useEditor from '../../../store/use-editor' +import { SliderControl } from '../controls/slider-control' import { Input } from '../primitives/input' import { PanelSection } from '../controls/panel-section' import { PanelWrapper } from './panel-wrapper' @@ -77,67 +78,41 @@ export function PaintPanel() {
-
-
- - - {currentProps.roughness.toFixed(2)} - -
- updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} - step={0.01} - type="range" - value={currentProps.roughness} - /> -
- -
-
- - - {currentProps.metalness.toFixed(2)} - -
- updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} - step={0.01} - type="range" - value={currentProps.metalness} - /> -
- -
-
- - - {currentProps.opacity.toFixed(2)} - +
+ +
+ updateCustomMaterial({ roughness })} + precision={2} + step={0.01} + value={currentProps.roughness} + /> + updateCustomMaterial({ metalness })} + precision={2} + step={0.01} + value={currentProps.metalness} + /> + + updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) + } + precision={2} + step={0.01} + value={currentProps.opacity} + />
- { - const opacity = Number.parseFloat(e.target.value) - updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) - }} - step={0.01} - type="range" - value={currentProps.opacity} - />
diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 6e0bfeb4d..d5faef6f0 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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 + case 'roof': + return + case 'roof-segment': + return + case 'stair': + return + case 'stair-segment': + return + case 'slab': + return + case 'spawn': + return + case 'ceiling': + return + case 'wall': + return + case 'fence': + return + case 'door': + return + case 'window': + return + } + } + + return null } diff --git a/packages/editor/src/components/ui/panels/spawn-panel.tsx b/packages/editor/src/components/ui/panels/spawn-panel.tsx new file mode 100644 index 000000000..59b9cdc76 --- /dev/null +++ b/packages/editor/src/components/ui/panels/spawn-panel.tsx @@ -0,0 +1,155 @@ +'use client' + +import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelSection } from '../controls/panel-section' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +export function SpawnPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const setSelection = useViewer((s) => s.setSelection) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined, + ) + const [draftRotation, setDraftRotation] = useState(null) + + useEffect(() => { + if (!(node && node.type === 'spawn')) { + setDraftRotation(null) + return + } + + setDraftRotation(node.rotation) + useLiveTransforms.getState().clear(node.id) + }, [node?.id, node?.rotation, node?.type]) + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!(selectedId && node)) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [node, selectedId, updateNode], + ) + + const handleRotationChange = useCallback( + (degrees: number) => { + if (!(node && selectedId)) return + const nextRotation = (degrees * Math.PI) / 180 + setDraftRotation(nextRotation) + useLiveTransforms.getState().set(selectedId as AnyNode['id'], { + position: [...node.position], + rotation: nextRotation, + }) + }, + [node, selectedId], + ) + + const commitRotation = useCallback( + (degrees: number) => { + if (!(node && selectedId)) return + const nextRotation = (degrees * Math.PI) / 180 + useLiveTransforms.getState().clear(selectedId as AnyNode['id']) + setDraftRotation(nextRotation) + if (Math.abs(nextRotation - node.rotation) > 1e-6) { + updateNode(selectedId as AnyNode['id'], { rotation: nextRotation }) + } + }, + [node, selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId) return + sfxEmitter.emit('sfx:structure-delete') + deleteNode(selectedId as AnyNode['id']) + setSelection({ selectedIds: [] }) + }, [deleteNode, selectedId, setSelection]) + + if (!(node && node.type === 'spawn' && selectedId)) return null + + const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI)) + const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI) + + return ( + + + handleUpdate({ position: [value, node.position[1], node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[0] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], value, node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[1] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], node.position[1], value] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[2] * 100) / 100} + /> + + + + + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index 466a7e0b3..ec7ee1ec8 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -262,7 +262,7 @@ export function StairPanel() { /> {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - - - - )} {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( - )} - {(node.topLandingMode ?? 'none') === 'integrated' && ( - 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) => 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(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, + })), + ) + } + createNodes(createOps) + selectLevel(newLevelId as LevelNode['id']) + setDuplicateDialogOpen(false) + } + return (
@@ -665,7 +705,7 @@ const LevelItem = memo(function LevelItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -750,7 +790,23 @@ const LevelItem = memo(function LevelItem({ - + + +
) }) @@ -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 setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 7e390870c..2054788e4 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core' +import { type LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Layers } from 'lucide-react' import { memo, useCallback, useState } from 'react' @@ -8,7 +8,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface LevelTreeNodeProps { - nodeId: AnyNodeId + nodeId: LevelNode['id'] depth: number isLast?: boolean } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx new file mode 100644 index 000000000..80c815ecc --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx @@ -0,0 +1,82 @@ +'use client' + +import { type SpawnNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface SpawnTreeNodeProps { + nodeId: SpawnNode['id'] + depth: number + isLast?: boolean +} + +export const SpawnTreeNode = memo(function SpawnTreeNode({ + nodeId, + depth, + isLast, +}: SpawnTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index dff2ba2aa..4a4d91942 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -62,6 +62,7 @@ import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' import { SlabTreeNode } from './slab-tree-node' +import { SpawnTreeNode } from './spawn-tree-node' import { StairTreeNode } from './stair-tree-node' import { WallTreeNode } from './wall-tree-node' import { WindowTreeNode } from './window-tree-node' @@ -80,13 +81,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr switch (nodeType) { case 'building': - return + return case 'ceiling': return case 'level': - return + return case 'slab': return + case 'spawn': + return case 'wall': return case 'fence': @@ -102,7 +105,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr case 'window': return case 'zone': - return + return default: return null } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index c1afbc7d7..eb578441e 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core' +import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { memo, useCallback, useState } from 'react' import { ColorDot } from './../../../../../components/ui/primitives/color-dot' @@ -7,7 +7,7 @@ import { focusTreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface ZoneTreeNodeProps { - nodeId: AnyNodeId + nodeId: ZoneNode['id'] depth: number isLast?: boolean } @@ -44,7 +44,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ depth={depth} expanded={false} hasChildren={false} - icon={ updateNode(nodeId, { color: c })} />} + icon={ updateNode(nodeId, { color: c })} />} isHovered={isHovered} isLast={isLast} isSelected={isSelected} @@ -78,8 +78,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d0eecc695..328116ad7 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -12,8 +12,18 @@ export const markToolCancelConsumed = () => { _toolCancelConsumed = true } -export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { +export const useKeyboard = ({ + isVersionPreviewMode = false, + disabled = false, +}: { + isVersionPreviewMode?: boolean + disabled?: boolean +} = {}) => { useEffect(() => { + if (disabled) { + return + } + const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -21,9 +31,6 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } if (e.key === 'Escape') { - // If in walkthrough mode, let WalkthroughControls handle ESC - if (useViewer.getState().walkthroughMode) return - e.preventDefault() _toolCancelConsumed = false emitter.emit('tool:cancel') @@ -220,7 +227,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isVersionPreviewMode]) + }, [disabled, isVersionPreviewMode]) return null } diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts new file mode 100644 index 000000000..a8e57ef2a --- /dev/null +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -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 + + 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 + + const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ + nodes, + level, + levels: [level], + preset: 'everything', + }) + + const copiedLevel = createOps.find((op) => op.node.id === newLevelId)?.node as + | LevelNode + | undefined + + expect(createOps.some((op) => op.node.type === 'spawn')).toBe(false) + expect(copiedLevel?.children).toEqual([]) + }) +}) diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts new file mode 100644 index 000000000..277e650f2 --- /dev/null +++ b/packages/editor/src/lib/level-duplication.ts @@ -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(['scan', 'guide', 'spawn']) +const STRUCTURAL_NODE_TYPES = new Set([ + 'level', + 'wall', + 'fence', + 'zone', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'window', + 'door', +]) + +function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { + if (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 + + 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, 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 + 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, + } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d25b33a59..fbce4311a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -11,6 +11,7 @@ import { type LevelNode, type RoofNode, type RoofSegmentNode, + type SpawnNode, type RoofSurfaceMaterialRole, type SlabNode, type Space, @@ -59,6 +60,7 @@ export type StructureTool = | 'stair' | 'item' | 'zone' + | 'spawn' | 'window' | 'door' @@ -132,6 +134,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -147,6 +150,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -639,8 +643,6 @@ const useEditor = create()( 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()( tool: null, catalogCategory: null, }) - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { const prevMode = get()._viewModeBeforeFirstPerson set({ diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts index 087be39ca..94ffa58dd 100644 --- a/packages/mcp/src/bridge/scene-bridge.test.ts +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -401,6 +401,27 @@ describe('SceneBridge', () => { }) describe('setScene / exportJSON / loadJSON', () => { + test('setScene prunes duplicated levels that were accidentally saved as roots', () => { + const level0 = LevelNode.parse({ level: 0, children: [] }) + const building = BuildingNode.parse({ children: [level0.id] }) + const site = SiteNode.parse({ children: [building] }) + const orphanLevel = LevelNode.parse({ level: 1, children: [] }) + + bridge.setScene( + { + [site.id]: site, + [building.id]: building, + [level0.id]: level0, + [orphanLevel.id]: orphanLevel, + } as any, + [site.id, orphanLevel.id] as any, + ) + + expect(bridge.getRootNodeIds()).toEqual([site.id]) + expect(bridge.getNode(orphanLevel.id)).toBeNull() + expect(bridge.findNodes({ type: 'level' }).map((node) => node.id)).toEqual([level0.id]) + }) + test('exportJSON returns the scene shape', () => { const exp = bridge.exportJSON() expect(typeof exp.nodes).toBe('object') diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 827f68934..80eb5b6ac 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -13,6 +13,7 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' +import { SpawnRenderer } from './spawn/spawn-renderer' import { StairRenderer } from './stair/stair-renderer' import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer' import { WallRenderer } from './wall/wall-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } + {node.type === 'spawn' && } {node.type === 'wall' && } {node.type === 'fence' && } {node.type === 'door' && } diff --git a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx new file mode 100644 index 000000000..e7dec6a42 --- /dev/null +++ b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx @@ -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(null!) + const handlers = useNodeEvents(node, 'spawn') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const walkthroughMode = useViewer((state) => state.walkthroughMode) + + useRegistry(node.id, 'spawn', ref) + + const materialProps = useMemo( + () => ({ + color: SPAWN_COLOR, + emissive: SPAWN_COLOR, + emissiveIntensity: 0.08, + metalness: 0.03, + roughness: 0.42, + }), + [], + ) + + const arrowShape = useMemo(() => { + const shape = new Shape() + // Positive local Y becomes negative world Z after the -90deg X rotation below, + // so this tip points "forward" for the player/spawn direction. + shape.moveTo(0, 0.24) + shape.lineTo(-0.18, -0.14) + shape.lineTo(0.18, -0.14) + shape.closePath() + return shape + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 133ba34be..d0b39b537 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -21,6 +21,8 @@ import { type SiteNode, type SlabEvent, type SlabNode, + type SpawnEvent, + type SpawnNode, type StairEvent, type StairNode, type StairSegmentEvent, @@ -44,6 +46,7 @@ type NodeConfig = { level: { node: LevelNode; event: LevelEvent } zone: { node: ZoneNode; event: ZoneEvent } slab: { node: SlabNode; event: SlabEvent } + spawn: { node: SpawnNode; event: SpawnEvent } ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } diff --git a/public/icons/spawn-point.svg b/public/icons/spawn-point.svg new file mode 100644 index 000000000..d0bc52eb6 --- /dev/null +++ b/public/icons/spawn-point.svg @@ -0,0 +1,7 @@ + + + + + + +