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