key skills
This commit is contained in:
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/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/systems.mdc
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cursor/rules/tools.mdc
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.cursor/rules/viewer-isolation.mdc
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
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 |
|
||||
@@ -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). See @apps/editor/hooks/use-grid-events.ts.
|
||||
|
||||
## 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,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({ id }: { id: MyNodeId }) {
|
||||
const node = useScene(s => s.nodes[id] as MyNode)
|
||||
const { ref } = useRegistry(id)
|
||||
const events = useNodeEvents(id)
|
||||
|
||||
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,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. If the system must run before renderers, place it earlier in the JSX tree.
|
||||
@@ -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,94 @@
|
||||
# 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 remain editor-agnostic. See `viewer-isolation` rule.
|
||||
- **Registry pattern** — `useRegistry()` maps node IDs to live THREE objects without tree traversal.
|
||||
- **Spatial grid** — 2D grid for fast wall/zone neighborhood queries; avoid brute-force iteration.
|
||||
|
||||
---
|
||||
|
||||
## 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