Restructure agent config: unified .agents/skills + wiki/architecture

Replace per-tool rule trees (.cursor/rules, .claude/rules, .codex/rules)
with a single wiki/architecture/ source — 11 pages + README — readable
as plain markdown by any agent. Canonical skills live in .agents/skills/;
.claude/skills, .cursor/skills, .codex/skills are directory symlinks.

AGENTS.md is the entrypoint (rewritten as a lean overview, no per-tool
path lists). CLAUDE.md, GEMINI.md, and .github/copilot-instructions.md
all point to it.

Add open-pr skill (uses .github/pull_request_template.md as the source
of truth for the PR body) and remove the dangling .claude/CLAUDE.md
relative symlink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-11 09:51:25 -04:00
co-authored by Claude Opus 4.7
parent ca333dd912
commit cf07fcaa97
45 changed files with 271 additions and 343 deletions
-79
View File
@@ -1,79 +0,0 @@
---
description: How to create and maintain project rules
globs: .cursor/rules/**
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 |
| `layers` | Three.js layer constants, ownership, and rendering separation |
-87
View File
@@ -1,87 +0,0 @@
---
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.
-57
View File
@@ -1,57 +0,0 @@
---
description: Three.js layer conventions — which layer each object type lives on and why
globs: packages/viewer/**,apps/editor/**
alwaysApply: false
---
# Three.js Layers
Three.js `Layers` control which objects each camera and render pass sees. We use them to separate scene geometry, editor helpers, and zone overlays into distinct rendering buckets without duplicating scene structure.
## Layer Map
| Constant | Value | Package | Purpose |
|---|---|---|---|
| `SCENE_LAYER` | `0` | `@pascal-app/viewer` | Default Three.js layer — all regular scene geometry |
| `EDITOR_LAYER` | `1` | `apps/editor` | Editor-only helpers: grid, tool previews, cursor meshes, snap guides |
| `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass |
Import the constants from their owning packages:
```ts
// In viewer code
import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'
// In editor code
import { EDITOR_LAYER } from '@/lib/constants'
```
## Why Separate Zones onto Layer 2
Zones use semi-transparent, `depthTest: false` materials that must be composited *on top of* the scene without being fed into SSGI or TRAA. The post-processing pipeline in `post-processing.tsx` renders a dedicated `zonePass` with a `Layers` mask that enables only `ZONE_LAYER` (and disables `SCENE_LAYER`), then blends its output into the final composite manually:
```ts
const zoneLayers = useMemo(() => {
const l = new Layers()
l.enable(ZONE_LAYER)
l.disable(SCENE_LAYER)
return l
}, [])
zonePass.setLayers(zoneLayers)
```
This keeps zones out of the SSGI depth/normal buffers (which would produce incorrect AO on transparent surfaces) while still letting them appear correctly over the scene.
## Why Separate Editor Helpers onto Layer 1
The editor camera enables `EDITOR_LAYER` so tools and helpers are visible during editing. The thumbnail generator disables `EDITOR_LAYER` so exports show clean geometry without snap lines or cursor spheres.
## Rules
- **Never hardcode layer numbers.** Always use the named constants.
- **`SCENE_LAYER` and `ZONE_LAYER` belong in `@pascal-app/viewer`** — they are renderer concerns, not editor concerns.
- **`EDITOR_LAYER` belongs in `apps/editor`** — the viewer must never import it; editor behaviour is injected via props/children.
- **Zone meshes must set `layers={ZONE_LAYER}`** so they are picked up by `zonePass` and excluded from `scenePass` depth buffers.
- **Editor helper meshes must set `layers={EDITOR_LAYER}`** so they are invisible to the thumbnail camera and the viewer's render passes.
- **Do not add new layers without updating this rule** and the post-processing pipeline accordingly.
-94
View File
@@ -1,94 +0,0 @@
---
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.
-68
View File
@@ -1,68 +0,0 @@
---
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.
-80
View File
@@ -1,80 +0,0 @@
---
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.
-95
View File
@@ -1,95 +0,0 @@
---
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.
-106
View File
@@ -1,106 +0,0 @@
---
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.
-92
View File
@@ -1,92 +0,0 @@
---
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.
-79
View File
@@ -1,79 +0,0 @@
---
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 mutate `useScene` for committed changes and `useLiveTransforms` for ephemeral drag state.** A tool's end-of-interaction write (click-to-commit, release-to-commit) goes to `useScene` and is captured in undo history. Per-mouse-move previews go to `useLiveTransforms` so history and subscribers aren't spammed.
- **Live-drag exception for direct mesh transforms.** During an active drag a tool may apply a transform offset directly to `sceneRegistry.nodes.get(id).position`/`rotation`/`scale` *when and only when* the same offset is mirrored into `useLiveTransforms` for that node. This exception exists because the 3D renderers don't reconcile `useLiveTransforms` onto `mesh.position` yet; once a `LiveTransformSystem` does that, this exception goes away. Conditions:
- The mesh offset must mirror the `useLiveTransforms` entry (same delta on both), so anything reading `useLiveTransforms` sees the same preview as the 3D view.
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
- **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 *and* any live transforms/mesh offsets when the tool unmounts.
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
- 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.
-88
View File
@@ -1,88 +0,0 @@
---
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.
+1
View File
@@ -0,0 +1 @@
../.agents/skills
@@ -1 +0,0 @@
../../../.claude/skills/review-architecture/SKILL.md