Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
AGENTS.md
|
||||||
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/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
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.cursor/rules/viewer-isolation.mdc
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
description: How to create and maintain project rules
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Creating Rules
|
||||||
|
|
||||||
|
Rules live in two places and are kept in sync via symlinks:
|
||||||
|
|
||||||
|
- `.cursor/rules/<rule-name>.mdc` — source of truth (Cursor format)
|
||||||
|
- `.claude/rules/<rule-name>.md` — symlink pointing to the cursor file
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
**1. Write the rule in `.cursor/rules/`**
|
||||||
|
|
||||||
|
```
|
||||||
|
.cursor/rules/my-rule.mdc
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create a symlink in `.claude/rules/`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s ../../.cursor/rules/my-rule.mdc .claude/rules/my-rule.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The `../../` prefix is required because the symlink lives two levels deep.
|
||||||
|
|
||||||
|
**3. Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la .claude/rules/my-rule.md
|
||||||
|
# → .claude/rules/my-rule.md -> ../../.cursor/rules/my-rule.mdc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule File Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
description: One-line summary of what this rule covers
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rule Title
|
||||||
|
|
||||||
|
Short intro paragraph.
|
||||||
|
|
||||||
|
## Section
|
||||||
|
|
||||||
|
Concrete guidance with examples.
|
||||||
|
```
|
||||||
|
|
||||||
|
- Set `alwaysApply: true` only for rules that apply to every file in the project.
|
||||||
|
- Use `globs` to scope a rule to specific paths (e.g. `packages/viewer/**`).
|
||||||
|
|
||||||
|
## Good Practices
|
||||||
|
|
||||||
|
- Keep rules under 500 lines. Split large rules into smaller focused files.
|
||||||
|
- Include concrete examples or reference real files with `@filename`.
|
||||||
|
- Add a rule when the same mistake has been made more than once — not preemptively.
|
||||||
|
- Prefer showing a correct example over listing prohibitions.
|
||||||
|
|
||||||
|
## Existing Rules
|
||||||
|
|
||||||
|
| Rule | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `creating-rules` | This file — how to add rules |
|
||||||
|
| `renderers` | Node renderer pattern in `packages/viewer` |
|
||||||
|
| `systems` | Core and viewer systems architecture |
|
||||||
|
| `tools` | Editor tools structure in `apps/editor` |
|
||||||
|
| `viewer-isolation` | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||||
|
| `scene-registry` | Global node ID → Object3D map and `useRegistry` |
|
||||||
|
| `selection-managers` | Two-layer selection (viewer + editor), events, outliner |
|
||||||
|
| `events` | Typed event bus — emitting and listening to node and grid events |
|
||||||
|
| `node-schemas` | Zod schema pattern for node types, createNode, updateNode |
|
||||||
|
| `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools |
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
description: Typed event bus — emitting and listening to node and grid events
|
||||||
|
globs: packages/core/src/events/**,packages/viewer/**,apps/editor/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Events
|
||||||
|
|
||||||
|
The event bus (`emitter`) is a global `mitt` instance typed with `EditorEvents`. It decouples renderers (which emit) from selection managers and tools (which listen).
|
||||||
|
|
||||||
|
**Source**: @packages/core/src/events/bus.ts
|
||||||
|
|
||||||
|
## Event Key Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<nodeType>:<suffix>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example keys: `wall:click`, `item:enter`, `door:double-click`, `grid:pointerdown`
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
`wall` `item` `site` `building` `level` `zone` `slab` `ceiling` `roof` `window` `door`
|
||||||
|
|
||||||
|
### Suffixes
|
||||||
|
```ts
|
||||||
|
'click' | 'move' | 'enter' | 'leave' | 'pointerdown' | 'pointerup' | 'context-menu' | 'double-click'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `grid:*` events fire when the user interacts with empty space (no node hit). They are **not** emitted by a mesh — `useGridEvents(gridY)` (@apps/editor/hooks/use-grid-events.ts) manually raycasts against a ground plane and calls `emitter.emit('grid:click', …)`. Mount it in any tool or editor component that needs empty-space interactions.
|
||||||
|
|
||||||
|
## NodeEvent Shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface NodeEvent<T extends AnyNode = AnyNode> {
|
||||||
|
node: T // typed node that triggered the event
|
||||||
|
position: [number, number, number] // world-space hit position
|
||||||
|
localPosition: [number, number, number] // object-local hit position
|
||||||
|
normal?: [number, number, number] // face normal, if available
|
||||||
|
stopPropagation: () => void
|
||||||
|
nativeEvent: ThreeEvent<PointerEvent>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Grid events only carry `position` and `nativeEvent` (no `node`).
|
||||||
|
|
||||||
|
## Emitting
|
||||||
|
|
||||||
|
Renderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// packages/viewer/src/hooks/use-node-events.ts
|
||||||
|
const events = useNodeEvents(node, 'wall')
|
||||||
|
return <mesh ref={ref} {...events} />
|
||||||
|
```
|
||||||
|
|
||||||
|
`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits `wall:click`, `wall:enter`, etc. It suppresses events while the camera is dragging.
|
||||||
|
|
||||||
|
## Listening
|
||||||
|
|
||||||
|
Listen in a `useEffect`. Always clean up with `emitter.off` using the **same function reference**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Single event
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: WallEvent) => { /* … */ }
|
||||||
|
emitter.on('wall:click', handler)
|
||||||
|
return () => emitter.off('wall:click', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Multiple node types, same handler
|
||||||
|
useEffect(() => {
|
||||||
|
const types = ['wall', 'slab', 'door'] as const
|
||||||
|
const handler = (e: NodeEvent) => { /* … */ }
|
||||||
|
types.forEach(t => emitter.on(`${t}:click`, handler as any))
|
||||||
|
return () => types.forEach(t => emitter.off(`${t}:click`, handler as any))
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
See @apps/editor/components/editor/selection-manager.tsx for a full multi-type listener example.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Renderers only emit, never listen.** Listening belongs in selection managers, tools, or systems.
|
||||||
|
- **Always clean up.** Forgetting `emitter.off` causes duplicate handlers and memory leaks.
|
||||||
|
- **Use the same function reference** for `on` and `off`. Anonymous functions inside `useEffect` are fine as long as the ref is captured in the same scope.
|
||||||
|
- **Don't use emitter for state.** It's for one-shot interaction events. Persistent state goes in `useScene`, `useViewer`, or `useEditor`.
|
||||||
|
- **`stopPropagation`** prevents the event from being handled by overlapping listeners (e.g. a door on a wall). Call it when a handler should be the final consumer.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
description: Node type definitions, Zod schema pattern, and how to create nodes in the scene
|
||||||
|
globs: packages/core/src/schema/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node Schemas
|
||||||
|
|
||||||
|
All node types are defined as Zod schemas in `packages/core/src/schema/nodes/`. Each schema extends `BaseNode` and exports both the schema and its inferred TypeScript type.
|
||||||
|
|
||||||
|
**Sources**: @packages/core/src/schema/base.ts, @packages/core/src/schema/nodes/
|
||||||
|
|
||||||
|
## BaseNode
|
||||||
|
|
||||||
|
Every node shares these fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
object: 'node' // always literal 'node'
|
||||||
|
id: string // typed ID e.g. "wall_abc123"
|
||||||
|
type: string // node type discriminator e.g. "wall"
|
||||||
|
name?: string // optional display name
|
||||||
|
parentId: string | null // parent node ID; null = root
|
||||||
|
visible: boolean // defaults to true
|
||||||
|
metadata: Record<string, unknown> // arbitrary JSON, defaults to {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Defining a New Node Type
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// packages/core/src/schema/nodes/my-node.ts
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, objectId, nodeType } from '../base'
|
||||||
|
|
||||||
|
export const MyNode = BaseNode.extend({
|
||||||
|
id: objectId('my-node'), // generates IDs like "my-node_abc123"
|
||||||
|
type: nodeType('my-node'), // sets literal type discriminator
|
||||||
|
// add node-specific fields:
|
||||||
|
width: z.number().default(1),
|
||||||
|
label: z.string().optional(),
|
||||||
|
}).describe('My node — one-line description of what it represents')
|
||||||
|
|
||||||
|
export type MyNode = z.infer<typeof MyNode>
|
||||||
|
export type MyNodeId = MyNode['id']
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `MyNode` to the `AnyNode` union in `packages/core/src/schema/types.ts`.
|
||||||
|
|
||||||
|
## Creating Nodes in Tools
|
||||||
|
|
||||||
|
Always use `.parse()` to validate and generate a proper typed ID. Never construct a plain object manually.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { WallNode } from '@pascal-app/core'
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
|
||||||
|
// 1. Parse validates and fills defaults (including auto-generated id)
|
||||||
|
const wall = WallNode.parse({ name: 'Wall 1', start: [0, 0], end: [5, 0] })
|
||||||
|
|
||||||
|
// 2. createNode(node, parentId?) inserts it into the scene
|
||||||
|
const { createNode } = useScene.getState()
|
||||||
|
createNode(wall, levelId)
|
||||||
|
```
|
||||||
|
|
||||||
|
For batch creation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { createNodes } = useScene.getState()
|
||||||
|
createNodes([
|
||||||
|
{ node: WallNode.parse({ start: [0, 0], end: [5, 0] }), parentId: levelId },
|
||||||
|
{ node: WallNode.parse({ start: [5, 0], end: [5, 4] }), parentId: levelId },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Nodes
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { updateNode } = useScene.getState()
|
||||||
|
updateNode(wall.id, { height: 2.8 }) // partial update, merges with existing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real Examples
|
||||||
|
|
||||||
|
- **Simple geometry node**: @packages/core/src/schema/nodes/wall.ts — `start`, `end`, `thickness`, `height`
|
||||||
|
- **Polygon node**: @packages/core/src/schema/nodes/slab.ts — `polygon: [number, number][]`, `holes`
|
||||||
|
- **Positioned node**: @packages/core/src/schema/nodes/item.ts — `position`, `rotation`, `scale`, `asset`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Always use `.parse()`** — it generates the correct ID prefix and fills defaults. `WallNode.parse({...})` not `{ type: 'wall', id: '...' }`.
|
||||||
|
- **Never hardcode IDs.** Let `objectId('type')` generate them.
|
||||||
|
- **Add new node types to `AnyNode`** in `types.ts` or they won't be accepted by the store.
|
||||||
|
- **Keep schemas in `packages/core`**, not in the viewer or editor — the schema is shared by all packages.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
description: Node renderer pattern in packages/viewer
|
||||||
|
globs: packages/viewer/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Renderers
|
||||||
|
|
||||||
|
Renderers live in `packages/viewer/src/components/renderers/`. Each renderer is responsible for one node type's Three.js geometry and materials — nothing else.
|
||||||
|
|
||||||
|
## Dispatch Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
<SceneRenderer> — iterates rootNodeIds from useScene
|
||||||
|
└─ <NodeRenderer> — switches on node.type, renders the matching component
|
||||||
|
└─ <WallRenderer> — (or SlabRenderer, DoorRenderer, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
See @packages/viewer/src/components/renderers/scene-renderer.tsx and @packages/viewer/src/components/renderers/node-renderer.tsx.
|
||||||
|
|
||||||
|
## Renderer Responsibilities
|
||||||
|
|
||||||
|
A renderer **should**:
|
||||||
|
- Read its node from `useScene` via the node's ID
|
||||||
|
- Register its mesh(es) with `useRegistry()` so other systems can look them up
|
||||||
|
- Subscribe to pointer events via `useNodeEvents()`
|
||||||
|
- Render geometry and apply materials based on node properties
|
||||||
|
|
||||||
|
A renderer **must not**:
|
||||||
|
- Run geometry generation logic (that belongs in a System)
|
||||||
|
- Import anything from `apps/editor`
|
||||||
|
- Manage selection state directly (use `useViewer` for read, emit events for write)
|
||||||
|
- Perform expensive per-frame calculations in the component body
|
||||||
|
|
||||||
|
## Example — Minimal Renderer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// packages/viewer/src/components/renderers/my-node/index.tsx
|
||||||
|
import { useRegistry } from '@pascal-app/core'
|
||||||
|
import { useNodeEvents } from '../../hooks/use-node-events'
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export function MyNodeRenderer({ node }: { node: MyNode }) {
|
||||||
|
const ref = useRef<Mesh>(null!)
|
||||||
|
useRegistry(node.id, 'my-node', ref) // 3 args: id, type, ref — no return value
|
||||||
|
const events = useNodeEvents(node, 'my-node')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ref} {...events}>
|
||||||
|
<boxGeometry args={[node.width, node.height, node.depth]} />
|
||||||
|
<meshStandardMaterial color={node.color} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a New Node Type
|
||||||
|
|
||||||
|
1. Create `packages/viewer/src/components/renderers/<type>/index.tsx`
|
||||||
|
2. Add a case to `NodeRenderer` in `node-renderer.tsx`
|
||||||
|
3. Add the corresponding system in `packages/core/src/systems/` if the node needs derived geometry
|
||||||
|
4. Export from `packages/viewer/src/index.ts` if needed externally
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- Use `useMemo` for geometry that depends on node properties — avoid recreating on every render.
|
||||||
|
- For complex cutout or boolean geometry, delegate to a System (e.g. `WallCutout`).
|
||||||
|
- Register one mesh per node ID; if a renderer spawns multiple meshes, use a group ref or pick the primary one for registry.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
description: Scene registry pattern — mapping node IDs to live THREE.Object3D instances
|
||||||
|
globs: packages/core/src/hooks/scene-registry/**,packages/viewer/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scene Registry
|
||||||
|
|
||||||
|
The scene registry is a global, mutable map that links node IDs to their live `THREE.Object3D` instances. It avoids tree traversal and lets systems and selection managers do O(1) lookups.
|
||||||
|
|
||||||
|
**Source**: @packages/core/src/hooks/scene-registry/scene-registry.ts
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const sceneRegistry = {
|
||||||
|
nodes: new Map<string, THREE.Object3D>(), // id → Object3D
|
||||||
|
byType: {
|
||||||
|
wall: new Set<string>(),
|
||||||
|
slab: new Set<string>(),
|
||||||
|
item: new Set<string>(),
|
||||||
|
// … one Set per node type
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nodes` is the primary lookup. `byType` lets systems iterate all objects of one type without scanning the whole map.
|
||||||
|
|
||||||
|
## Registering in a Renderer
|
||||||
|
|
||||||
|
Every renderer must call `useRegistry` with a `ref` to its root mesh or group. Registration is synchronous (`useLayoutEffect`) so it's available before the first paint.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRegistry } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export function WallRenderer({ node }: { node: WallNode }) {
|
||||||
|
const ref = useRef<Mesh>(null!)
|
||||||
|
useRegistry(node.id, 'wall', ref) // ← required in every renderer
|
||||||
|
|
||||||
|
return <mesh ref={ref} … />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook handles both registration on mount and cleanup on unmount automatically.
|
||||||
|
|
||||||
|
## Looking Up Objects
|
||||||
|
|
||||||
|
Anywhere outside the renderer — in systems, selection managers, export logic:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Single lookup
|
||||||
|
const obj = sceneRegistry.nodes.get(nodeId)
|
||||||
|
if (obj) { /* use obj */ }
|
||||||
|
|
||||||
|
// Iterate all walls
|
||||||
|
for (const id of sceneRegistry.byType.wall) {
|
||||||
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **One registration per node ID.** If a renderer spawns multiple meshes, register the outermost group (the one that represents the node).
|
||||||
|
- **Never hold a stale reference.** Always read from `sceneRegistry.nodes.get(id)` at the time you need it — don't cache the result across frames.
|
||||||
|
- **Don't mutate the registry manually.** Only `useRegistry` should add/remove entries. Systems and selection managers are read-only consumers.
|
||||||
|
- **Core systems must not use the registry.** They work with plain node data. Only viewer systems and selection managers may do Three.js object lookups.
|
||||||
|
|
||||||
|
## Outliner Sync
|
||||||
|
|
||||||
|
The `outliner` in `useViewer` holds live `Object3D[]` arrays used by the post-processing outline pass. Selection managers sync them imperatively for performance (array mutation rather than new allocations):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
outliner.selectedObjects.length = 0
|
||||||
|
for (const id of selection.selectedIds) {
|
||||||
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
|
if (obj) outliner.selectedObjects.push(obj)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See @packages/viewer/src/components/viewer/selection-manager.tsx for the full sync pattern.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
description: Selection managers — two-layer architecture for viewer and editor selection
|
||||||
|
globs: packages/viewer/src/components/viewer/selection-manager.tsx,apps/editor/components/editor/selection-manager.tsx
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Selection Managers
|
||||||
|
|
||||||
|
There are two selection managers. They are separate components, not the same component configured differently.
|
||||||
|
|
||||||
|
| Component | Location | Knows about |
|
||||||
|
|---|---|---|
|
||||||
|
| `SelectionManager` | `packages/viewer/src/components/viewer/selection-manager.tsx` | Viewer state only |
|
||||||
|
| `SelectionManager` (editor) | `apps/editor/components/editor/selection-manager.tsx` | Phase, mode, tool state |
|
||||||
|
|
||||||
|
The viewer's manager is the default. The editor mounts its own manager as a child of `<Viewer>`, overriding the default behaviour via the viewer-isolation pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Selection Works
|
||||||
|
|
||||||
|
**Event flow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
useNodeEvents(node, type) on a renderer mesh
|
||||||
|
→ emitter.emit('wall:click', NodeEvent)
|
||||||
|
→ SelectionManager listens via emitter.on(…)
|
||||||
|
→ calls useViewer.setSelection(…)
|
||||||
|
→ outliner sync re-runs → Three.js outline updates
|
||||||
|
```
|
||||||
|
|
||||||
|
`useNodeEvents` returns R3F pointer handlers. Spread them onto the mesh:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const events = useNodeEvents(node, 'wall')
|
||||||
|
return <mesh ref={ref} {...events} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are suppressed during camera drag (`useViewer.getState().cameraDragging`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Viewer Selection Manager
|
||||||
|
|
||||||
|
Hierarchical path: **Building → Level → Zone → Elements**
|
||||||
|
|
||||||
|
At each level, only the next tier is selectable. Clicking outside deselects. The path is stored in `useViewer`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SelectionPath = {
|
||||||
|
buildingId: string | null
|
||||||
|
levelId: string | null
|
||||||
|
zoneId: string | null
|
||||||
|
selectedIds: string[] // walls, items, slabs, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything.
|
||||||
|
|
||||||
|
Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Editor Selection Manager
|
||||||
|
|
||||||
|
Extends selection with phase awareness from `useEditor`. The viewer's `SelectionManager` is **not** mounted in the editor; this one takes its place (injected as a child of `<Viewer>`).
|
||||||
|
|
||||||
|
```
|
||||||
|
phase: 'site' → selectable: buildings
|
||||||
|
phase: 'structure' → selectable: walls, zones, slabs, ceilings, roofs, doors, windows
|
||||||
|
structureLayer: 'zones' → only zones
|
||||||
|
structureLayer: 'elements' → all structure types
|
||||||
|
phase: 'furnish' → selectable: furniture items only
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Never add selection logic to renderers.** Renderers spread `useNodeEvents` events and stop there. All selection decisions live in the selection manager.
|
||||||
|
- **Never add editor phase logic to the viewer's SelectionManager.** Phase, mode, and tool awareness belong exclusively in the editor's selection manager.
|
||||||
|
- **`useViewer` is the single source of truth for selection state.** Both managers read and write through `setSelection` / `resetSelection`. Nothing else should mutate `selection` directly.
|
||||||
|
- **Outliner arrays are mutated in-place** (not replaced) for performance. Don't assign new arrays to `outliner.selectedObjects` or `outliner.hoveredObjects`.
|
||||||
|
- **Hover is a separate scalar** (`hoveredId: string | null`), not part of `selectedIds`. Update it via `setHoveredId`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Selectability to a New Node Type
|
||||||
|
|
||||||
|
1. Add the type to `SelectableNodeType` in the viewer store / selection manager.
|
||||||
|
2. Make sure its renderer calls `useNodeEvents(node, type)` and spreads the handlers.
|
||||||
|
3. Add a case to whichever selection strategy needs it (viewer hierarchy level or editor phase).
|
||||||
|
4. Ensure `useRegistry` is called in the renderer so the outliner can highlight it.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
description: Placement validation for tools — canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling
|
||||||
|
globs: apps/editor/components/tools/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spatial Queries
|
||||||
|
|
||||||
|
`useSpatialQuery()` validates whether an item can be placed at a given position without overlapping existing items. Every placement tool must call it before committing a node to the scene.
|
||||||
|
|
||||||
|
**Source**: @packages/core/src/hooks/spatial-grid/use-spatial-query.ts
|
||||||
|
|
||||||
|
## Hook
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||||
|
```
|
||||||
|
|
||||||
|
All three methods return `{ valid: boolean; conflictIds: string[] }`.
|
||||||
|
`canPlaceOnWall` additionally returns `adjustedY: number` (snapped height).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## canPlaceOnFloor
|
||||||
|
|
||||||
|
```ts
|
||||||
|
canPlaceOnFloor(
|
||||||
|
levelId: string,
|
||||||
|
position: [number, number, number],
|
||||||
|
dimensions: [number, number, number], // scaled width/height/depth
|
||||||
|
rotation: [number, number, number],
|
||||||
|
ignoreIds?: string[], // pass [draftItem.id] to exclude self
|
||||||
|
): { valid: boolean; conflictIds: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in a tool:**
|
||||||
|
```ts
|
||||||
|
const pos: [number, number, number] = [x, 0, z]
|
||||||
|
const { valid } = canPlaceOnFloor(levelId, pos, getScaledDimensions(item), item.rotation, [item.id])
|
||||||
|
if (valid) createNode(item, levelId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## canPlaceOnWall
|
||||||
|
|
||||||
|
```ts
|
||||||
|
canPlaceOnWall(
|
||||||
|
levelId: string,
|
||||||
|
wallId: string,
|
||||||
|
localX: number, // distance along wall from start
|
||||||
|
localY: number, // height from floor
|
||||||
|
dimensions: [number, number, number],
|
||||||
|
attachType: 'wall' | 'wall-side', // 'wall' needs clearance both sides; 'wall-side' only one
|
||||||
|
side?: 'front' | 'back',
|
||||||
|
ignoreIds?: string[],
|
||||||
|
): { valid: boolean; conflictIds: string[]; adjustedY: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
`adjustedY` contains the snapped Y so items sit flush on the slab — always use it instead of the raw `localY`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { valid, adjustedY } = canPlaceOnWall(levelId, wallId, x, y, dims, 'wall', undefined, [item.id])
|
||||||
|
if (valid) updateNode(item.id, { wallT: x, wallY: adjustedY })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## canPlaceOnCeiling
|
||||||
|
|
||||||
|
```ts
|
||||||
|
canPlaceOnCeiling(
|
||||||
|
ceilingId: string,
|
||||||
|
position: [number, number, number],
|
||||||
|
dimensions: [number, number, number],
|
||||||
|
rotation: [number, number, number],
|
||||||
|
ignoreIds?: string[],
|
||||||
|
): { valid: boolean; conflictIds: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slab Elevation
|
||||||
|
|
||||||
|
When items rest on a slab (not flat ground), use these to get the correct Y:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { spatialGridManager } from '@pascal-app/core'
|
||||||
|
|
||||||
|
// Y at a single point
|
||||||
|
const y = spatialGridManager.getSlabElevationAt(levelId, x, z)
|
||||||
|
|
||||||
|
// Y considering the item's full footprint (highest slab point under item)
|
||||||
|
const y = spatialGridManager.getSlabElevationForItem(levelId, position, dimensions, rotation)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Always pass `[item.id]` in `ignoreIds`** when validating a draft item that already exists in the scene — otherwise it collides with itself.
|
||||||
|
- **Use `adjustedY` from `canPlaceOnWall`** — don't use the raw cursor Y for wall-mounted items.
|
||||||
|
- **Use `getScaledDimensions(item)`** (@packages/core/src/schema/nodes/item.ts) to account for item scale, not the raw `asset.dimensions`.
|
||||||
|
- Validate on every pointer move for live feedback (highlight ghost red/green). Only `createNode` / `updateNode` on pointer up or click.
|
||||||
|
|
||||||
|
See @apps/editor/components/tools/item/use-placement-coordinator.tsx for a full implementation.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
description: Core and viewer systems architecture
|
||||||
|
globs: packages/core/src/systems/**,packages/viewer/src/systems/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Systems
|
||||||
|
|
||||||
|
Systems own business logic, geometry generation, and constraints. They run in the Three.js frame loop and are never rendered directly.
|
||||||
|
|
||||||
|
## Two Kinds of Systems
|
||||||
|
|
||||||
|
### Core Systems — `packages/core/src/systems/`
|
||||||
|
|
||||||
|
Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, compute derived values (geometry, constraints), and write results back.
|
||||||
|
|
||||||
|
| System | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `WallSystem` | Wall mitering, corner joints |
|
||||||
|
| `SlabSystem` | Polygon-based floor/roof generation |
|
||||||
|
| `CeilingSystem` | Polygon-based ceiling generation |
|
||||||
|
| `RoofSystem` | Pitched roof shape |
|
||||||
|
| `DoorSystem` | Placement constraints on walls |
|
||||||
|
| `WindowSystem` | Placement constraints on walls |
|
||||||
|
| `ItemSystem` | Item transforms, collision |
|
||||||
|
|
||||||
|
### Viewer Systems — `packages/viewer/src/systems/`
|
||||||
|
|
||||||
|
Access Three.js objects (via `useRegistry`) and manage rendering side-effects.
|
||||||
|
|
||||||
|
| System | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `LevelSystem` | Stacked / exploded / solo / manual level positions |
|
||||||
|
| `WallCutout` | Cuts door/window holes in wall geometry |
|
||||||
|
| `ZoneSystem` | Zone display and label placement |
|
||||||
|
| `InteractiveSystem` | Item toggles and sliders in the scene |
|
||||||
|
| `GuideSystem` | Temporary helper geometry |
|
||||||
|
| `ScanSystem` | Point cloud rendering |
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
Systems are React components that render nothing (`return null`) and use `useFrame` for per-frame logic.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// packages/core/src/systems/my-system.tsx
|
||||||
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { useScene } from '../store/use-scene'
|
||||||
|
|
||||||
|
export function MySystem() {
|
||||||
|
const nodes = useScene(s => s.nodes)
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
// compute and write back derived state
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Core and viewer systems are mounted inside `<Viewer>` alongside renderers. See @packages/viewer/src/components/viewer/index.tsx for the mount order.
|
||||||
|
|
||||||
|
**Systems are a customization point.** Any consumer of `<Viewer>` — the editor app, an embed, a read-only preview — can inject its own systems as children. This is how editor-specific behaviour (space detection, tool feedback) is added without touching the viewer package.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Core systems must not import Three.js** — they work with plain data.
|
||||||
|
- **Viewer systems must not contain business logic** — delegate to core if the rule is domain-level.
|
||||||
|
- **Never duplicate logic** between a system and a renderer — if the renderer needs it, the system should compute and store it, and the renderer reads the result.
|
||||||
|
- Systems should be **idempotent**: given the same nodes, they produce the same output.
|
||||||
|
- Mark nodes as `dirty` in the scene store to signal that a system should re-run. Avoid running expensive logic every frame without a dirty check.
|
||||||
|
|
||||||
|
## Adding a New System
|
||||||
|
|
||||||
|
1. Decide the scope:
|
||||||
|
- **Domain logic** → `packages/core/src/systems/`
|
||||||
|
- **Viewer rendering side-effect** → `packages/viewer/src/systems/` — mount in `packages/viewer/src/components/viewer/index.tsx`
|
||||||
|
- **Editor-specific or integration-specific** → keep it in the consuming app (e.g. `apps/editor/components/systems/`) and inject it as a child of `<Viewer>`
|
||||||
|
|
||||||
|
2. Create `<name>-system.tsx` in the appropriate directory.
|
||||||
|
|
||||||
|
3. Mount it in the right place:
|
||||||
|
- Viewer-internal systems go in `packages/viewer/src/components/viewer/index.tsx`
|
||||||
|
- App-specific systems are injected as children from outside:
|
||||||
|
```tsx
|
||||||
|
// apps/editor — editor injects its own systems without modifying the viewer
|
||||||
|
<Viewer>
|
||||||
|
<MyEditorSystem />
|
||||||
|
<ToolManager />
|
||||||
|
</Viewer>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Mount order matters.** Most viewer systems run *after* renderers in the JSX tree — they consume `sceneRegistry` data that renderers populate on mount. Only place a system before renderers if it explicitly does not read the registry.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
description: Editor tools structure in apps/editor
|
||||||
|
globs: apps/editor/components/tools/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
Tools are React components that capture user input (pointer, keyboard) and translate it into `useScene` mutations. They live exclusively in `apps/editor/components/tools/`.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
`ToolManager` reads `useEditor` (phase + mode + tool) and mounts the active tool component. When the tool changes, the old component unmounts, cleaning up any transient state.
|
||||||
|
|
||||||
|
See @apps/editor/components/tools/tool-manager.tsx.
|
||||||
|
|
||||||
|
## Tool Categories by Phase
|
||||||
|
|
||||||
|
**Site**
|
||||||
|
- `site-boundary-editor` — draw/edit property boundary polygon
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
- `wall-tool` — draw walls segment by segment
|
||||||
|
- `slab-tool` + `slab-boundary-editor` + `slab-hole-editor`
|
||||||
|
- `ceiling-tool` + `ceiling-boundary-editor` + `ceiling-hole-editor`
|
||||||
|
- `roof-tool`
|
||||||
|
- `door-tool` + `door-move-tool`
|
||||||
|
- `window-tool` + `window-move-tool`
|
||||||
|
- `item-tool` + `item-move-tool`
|
||||||
|
- `zone-tool` + `zone-boundary-editor`
|
||||||
|
|
||||||
|
**Furnish**
|
||||||
|
- `item-tool` — place furniture
|
||||||
|
|
||||||
|
**Shared utilities**
|
||||||
|
- `polygon-editor` — reusable boundary/hole editing logic
|
||||||
|
- `cursor-sphere` — 3D cursor visualisation
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// apps/editor/components/tools/my-tool/index.tsx
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useEditor } from '../../store/use-editor'
|
||||||
|
|
||||||
|
export function MyTool() {
|
||||||
|
const createNode = useScene(s => s.createNode)
|
||||||
|
const setTool = useEditor(s => s.setTool)
|
||||||
|
|
||||||
|
// Pointer handlers mutate the scene store directly.
|
||||||
|
// No local geometry — use a renderer for any preview mesh.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh onPointerDown={handleDown} onPointerMove={handleMove}>
|
||||||
|
{/* ghost / preview geometry only */}
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Tools only mutate `useScene`** — they do not call Three.js APIs directly.
|
||||||
|
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
||||||
|
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
||||||
|
- **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts.
|
||||||
|
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only.
|
||||||
|
- Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`.
|
||||||
|
|
||||||
|
## Adding a New Tool
|
||||||
|
|
||||||
|
1. Create `apps/editor/components/tools/<name>/index.tsx`.
|
||||||
|
2. Register the tool in `ToolManager` under the correct phase and mode.
|
||||||
|
3. Add the tool identifier to the `useEditor` tool union type.
|
||||||
|
4. If the tool requires new node types, add schema + renderer + system first.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
description: Viewer must be editor-agnostic — controlled from outside via props and children
|
||||||
|
globs: packages/viewer/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Viewer Isolation
|
||||||
|
|
||||||
|
`@pascal-app/viewer` is a standalone 3D canvas library. It must never know about editor-specific features, UI state, or tools. This keeps it usable in the read-only `/viewer/[id]` route and in any future embedding context.
|
||||||
|
|
||||||
|
## The Rule
|
||||||
|
|
||||||
|
> The viewer is controlled from outside. It exposes control points (props, callbacks, children). It never reaches into `apps/editor`.
|
||||||
|
|
||||||
|
## Forbidden in `packages/viewer`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Never import from the editor app
|
||||||
|
import { useEditor } from '@/store/use-editor'
|
||||||
|
import { ToolManager } from '@/components/tools/tool-manager'
|
||||||
|
|
||||||
|
// ❌ Never reference editor-specific concepts
|
||||||
|
if (isEditorMode) { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Correct Pattern — Pass Control from Outside
|
||||||
|
|
||||||
|
The editor mounts the viewer and passes what it needs:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// apps/editor/components/editor-canvas.tsx ✅
|
||||||
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
|
import { useEditor } from '../../store/use-editor'
|
||||||
|
|
||||||
|
export function EditorCanvas() {
|
||||||
|
const { selection } = useViewer()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Viewer
|
||||||
|
theme="light"
|
||||||
|
onSelect={(id) => useViewer.getState().setSelection(id)}
|
||||||
|
onExport={handleExport}
|
||||||
|
>
|
||||||
|
{/* Editor injects tools as children — viewer renders them inside the canvas */}
|
||||||
|
<ToolManager />
|
||||||
|
</Viewer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The viewer accepts `children` and renders them inside the R3F canvas. This is the extension point for tools, overlays, and editor-specific systems.
|
||||||
|
|
||||||
|
## Viewer's Own State (`useViewer`)
|
||||||
|
|
||||||
|
The viewer store contains **only presentation state**:
|
||||||
|
|
||||||
|
- `selection` — which nodes are highlighted
|
||||||
|
- `cameraMode` — perspective / orthographic
|
||||||
|
- `levelMode` — stacked / exploded / solo / manual
|
||||||
|
- `wallMode` — up / cutaway / down
|
||||||
|
- `theme` — light / dark
|
||||||
|
- Display toggles: `showScans`, `showGuides`, `showGrid`
|
||||||
|
|
||||||
|
If a piece of state is only meaningful inside the editor (e.g. active tool, phase, edit mode) — it belongs in `useEditor`, not `useViewer`.
|
||||||
|
|
||||||
|
## Nested Viewer for Editor-Specific Features
|
||||||
|
|
||||||
|
When an editor feature needs to live "inside" the canvas but must not pollute the viewer package, inject it as a child:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Editor-specific overlay injected as child
|
||||||
|
<Viewer>
|
||||||
|
<SelectionBoxOverlay /> {/* editor only */}
|
||||||
|
<SnapIndicator /> {/* editor only */}
|
||||||
|
<ToolManager /> {/* editor only */}
|
||||||
|
</Viewer>
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern lets the viewer stay ignorant of these components while they still have access to the R3F context.
|
||||||
|
|
||||||
|
## Checklist Before Adding Code to `packages/viewer`
|
||||||
|
|
||||||
|
- [ ] Does this feature make sense in the read-only viewer route?
|
||||||
|
- [ ] Does it reference `useEditor`, tool state, or phase/mode?
|
||||||
|
- [ ] Could it be passed in as a prop or child instead?
|
||||||
|
|
||||||
|
If any answer is "editor-specific", keep it in `apps/editor` and inject it via children or props.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Pascal Editor V2 — Architecture
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Monorepo managed with Turborepo. Packages are shared libraries; apps are deployable applications.
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
editor/ # Main Next.js app (editor + public routes)
|
||||||
|
packages/
|
||||||
|
core/ # Scene schema, state, systems, spatial logic
|
||||||
|
viewer/ # 3D canvas component (React Three Fiber)
|
||||||
|
auth/ # Authentication (better-auth + Supabase)
|
||||||
|
db/ # Database layer (Drizzle ORM + Supabase)
|
||||||
|
ui/ # Shared React UI components
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## packages/core
|
||||||
|
|
||||||
|
Central library — no UI, no rendering. Everything else depends on it.
|
||||||
|
|
||||||
|
- **schema/** — TypeScript types for all node types (`Wall`, `Slab`, `Door`, `Item`, etc.)
|
||||||
|
- **store/** — Zustand scene store (`useScene`) with undo/redo via Zundo
|
||||||
|
- **systems/** — Per-element business logic: geometry generation, constraints (`WallSystem`, `SlabSystem`, `DoorSystem`, …)
|
||||||
|
- **events/** — Typed event bus for node changes
|
||||||
|
- **hooks/** — `useRegistry` (node ID → THREE.Object3D), `useSpatialGrid` (2D spatial index)
|
||||||
|
- **lib/** — Space detection, asset storage, polygon utilities
|
||||||
|
|
||||||
|
Node storage is a flat dictionary (`nodes: Record<id, AnyNode>`). Systems are pure logic that runs in the render loop; they read nodes and write back derived geometry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## packages/viewer
|
||||||
|
|
||||||
|
3D canvas component — presentation only, no editor concerns.
|
||||||
|
|
||||||
|
- **components/viewer/** — Root `<Viewer>` canvas, camera, lights, post-processing, selection manager
|
||||||
|
- **components/renderers/** — One renderer per node type (`WallRenderer`, `SlabRenderer`, …), dispatched by `NodeRenderer` → `SceneRenderer`
|
||||||
|
- **systems/** — Viewer-specific systems: `LevelSystem` (stacked/exploded/solo), `WallCutout`, `ZoneSystem`, `InteractiveSystem`
|
||||||
|
- **store/** — `useViewer`: selection path, camera mode, level mode, wall mode, theme, display toggles
|
||||||
|
|
||||||
|
The viewer accepts external props and callbacks (`onSelect`, `onExport`, children) to expose control points. It must not import anything from `apps/editor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## apps/editor
|
||||||
|
|
||||||
|
Next.js 16 app. Composes `@pascal-app/viewer` and `@pascal-app/core` into a full editing experience.
|
||||||
|
|
||||||
|
- **app/editor/[projectId]/** — Main editor route
|
||||||
|
- **app/viewer/[id]/** — Read-only preview route
|
||||||
|
- **store/use-editor.tsx** — `useEditor`: phase (`site | structure | furnish`), mode (`select | edit | delete | build`), active tool
|
||||||
|
- **components/tools/** — One component per tool, coordinated by `ToolManager`
|
||||||
|
- **components/systems/** — Editor-side systems that integrate with viewer (e.g. space detection for cutaway)
|
||||||
|
- **components/editor/** — Camera controls, export, menus, panels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User input (pointer/keyboard)
|
||||||
|
→ Tool component (apps/editor/components/tools/)
|
||||||
|
→ useScene mutations
|
||||||
|
→ Core systems recompute geometry
|
||||||
|
→ Renderers re-render THREE meshes
|
||||||
|
→ useViewer updates selection/hover
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **Flat nodes** — All scene nodes live in a single flat record; hierarchy is expressed via `parentId`.
|
||||||
|
- **System/renderer split** — Systems own logic; renderers own geometry and material. Never mix.
|
||||||
|
- **Viewer isolation** — `@pascal-app/viewer` must never import from `apps/editor`. Editor-specific behaviour (tools, systems, selection) is injected as children or props.
|
||||||
|
- **Registry pattern** — `useRegistry()` maps node IDs to live THREE objects without tree traversal.
|
||||||
|
- **Spatial grid** — 2D grid for fast wall/zone neighbourhood queries; avoid brute-force iteration.
|
||||||
|
- **Node creation** — Always use `NodeType.parse({…})` then `createNode(node, parentId)`. Never construct raw node objects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| 3D | Three.js (WebGPU), React Three Fiber |
|
||||||
|
| Framework | Next.js 16, React 19 |
|
||||||
|
| State | Zustand + Zundo |
|
||||||
|
| UI | Radix UI, Tailwind CSS 4 |
|
||||||
|
| Database | Supabase PostgreSQL + Drizzle ORM |
|
||||||
|
| Auth | better-auth |
|
||||||
|
| Tooling | Biome, TypeScript 5.9, Turborepo |
|
||||||
Reference in New Issue
Block a user