From 03f452d4d10408511a8c35c0eb892abe43ab05da Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Mar 2026 09:40:07 +0100 Subject: [PATCH] key skills --- .claude/CLAUDE.md | 1 + .claude/rules/creating-rules.md | 1 + .claude/rules/events.md | 1 + .claude/rules/renderers.md | 1 + .claude/rules/scene-registry.md | 1 + .claude/rules/selection-managers.md | 1 + .claude/rules/systems.md | 1 + .claude/rules/tools.md | 1 + .claude/rules/viewer-isolation.md | 1 + .cursor/rules/creating-rules.mdc | 76 ++++++++++++++++++++++ .cursor/rules/events.mdc | 87 +++++++++++++++++++++++++ .cursor/rules/renderers.mdc | 68 ++++++++++++++++++++ .cursor/rules/scene-registry.mdc | 80 +++++++++++++++++++++++ .cursor/rules/selection-managers.mdc | 95 ++++++++++++++++++++++++++++ .cursor/rules/systems.mdc | 92 +++++++++++++++++++++++++++ .cursor/rules/tools.mdc | 75 ++++++++++++++++++++++ .cursor/rules/viewer-isolation.mdc | 88 ++++++++++++++++++++++++++ AGENTS.md | 94 +++++++++++++++++++++++++++ CLAUDE.md | 1 + 19 files changed, 765 insertions(+) create mode 120000 .claude/CLAUDE.md create mode 120000 .claude/rules/creating-rules.md create mode 120000 .claude/rules/events.md create mode 120000 .claude/rules/renderers.md create mode 120000 .claude/rules/scene-registry.md create mode 120000 .claude/rules/selection-managers.md create mode 120000 .claude/rules/systems.md create mode 120000 .claude/rules/tools.md create mode 120000 .claude/rules/viewer-isolation.md create mode 100644 .cursor/rules/creating-rules.mdc create mode 100644 .cursor/rules/events.mdc create mode 100644 .cursor/rules/renderers.mdc create mode 100644 .cursor/rules/scene-registry.mdc create mode 100644 .cursor/rules/selection-managers.mdc create mode 100644 .cursor/rules/systems.mdc create mode 100644 .cursor/rules/tools.mdc create mode 100644 .cursor/rules/viewer-isolation.mdc create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.claude/rules/creating-rules.md b/.claude/rules/creating-rules.md new file mode 120000 index 00000000..6fb51cc9 --- /dev/null +++ b/.claude/rules/creating-rules.md @@ -0,0 +1 @@ +../../.cursor/rules/creating-rules.mdc \ No newline at end of file diff --git a/.claude/rules/events.md b/.claude/rules/events.md new file mode 120000 index 00000000..72ba2224 --- /dev/null +++ b/.claude/rules/events.md @@ -0,0 +1 @@ +../../.cursor/rules/events.mdc \ No newline at end of file diff --git a/.claude/rules/renderers.md b/.claude/rules/renderers.md new file mode 120000 index 00000000..a7bb563a --- /dev/null +++ b/.claude/rules/renderers.md @@ -0,0 +1 @@ +../../.cursor/rules/renderers.mdc \ No newline at end of file diff --git a/.claude/rules/scene-registry.md b/.claude/rules/scene-registry.md new file mode 120000 index 00000000..017d4c15 --- /dev/null +++ b/.claude/rules/scene-registry.md @@ -0,0 +1 @@ +../../.cursor/rules/scene-registry.mdc \ No newline at end of file diff --git a/.claude/rules/selection-managers.md b/.claude/rules/selection-managers.md new file mode 120000 index 00000000..3c2d1d93 --- /dev/null +++ b/.claude/rules/selection-managers.md @@ -0,0 +1 @@ +../../.cursor/rules/selection-managers.mdc \ No newline at end of file diff --git a/.claude/rules/systems.md b/.claude/rules/systems.md new file mode 120000 index 00000000..b20dbdc3 --- /dev/null +++ b/.claude/rules/systems.md @@ -0,0 +1 @@ +../../.cursor/rules/systems.mdc \ No newline at end of file diff --git a/.claude/rules/tools.md b/.claude/rules/tools.md new file mode 120000 index 00000000..9c7547e5 --- /dev/null +++ b/.claude/rules/tools.md @@ -0,0 +1 @@ +../../.cursor/rules/tools.mdc \ No newline at end of file diff --git a/.claude/rules/viewer-isolation.md b/.claude/rules/viewer-isolation.md new file mode 120000 index 00000000..f484584b --- /dev/null +++ b/.claude/rules/viewer-isolation.md @@ -0,0 +1 @@ +../../.cursor/rules/viewer-isolation.mdc \ No newline at end of file diff --git a/.cursor/rules/creating-rules.mdc b/.cursor/rules/creating-rules.mdc new file mode 100644 index 00000000..6e3578a0 --- /dev/null +++ b/.cursor/rules/creating-rules.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/.mdc` — source of truth (Cursor format) +- `.claude/rules/.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 | diff --git a/.cursor/rules/events.mdc b/.cursor/rules/events.mdc new file mode 100644 index 00000000..d9021dd5 --- /dev/null +++ b/.cursor/rules/events.mdc @@ -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 + +``` +: +``` + +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 { + 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 +} +``` + +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 +``` + +`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. diff --git a/.cursor/rules/renderers.mdc b/.cursor/rules/renderers.mdc new file mode 100644 index 00000000..d824c681 --- /dev/null +++ b/.cursor/rules/renderers.mdc @@ -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 + +``` + — iterates rootNodeIds from useScene + └─ — switches on node.type, renders the matching component + └─ — (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 ( + + + + + ) +} +``` + +## Adding a New Node Type + +1. Create `packages/viewer/src/components/renderers//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. diff --git a/.cursor/rules/scene-registry.mdc b/.cursor/rules/scene-registry.mdc new file mode 100644 index 00000000..e7e63d44 --- /dev/null +++ b/.cursor/rules/scene-registry.mdc @@ -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(), // id → Object3D + byType: { + wall: new Set(), + slab: new Set(), + item: new Set(), + // … 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(null!) + useRegistry(node.id, 'wall', ref) // ← required in every renderer + + return +} +``` + +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. diff --git a/.cursor/rules/selection-managers.mdc b/.cursor/rules/selection-managers.mdc new file mode 100644 index 00000000..3a83c094 --- /dev/null +++ b/.cursor/rules/selection-managers.mdc @@ -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 ``, 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 +``` + +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 ``). + +``` +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. diff --git a/.cursor/rules/systems.mdc b/.cursor/rules/systems.mdc new file mode 100644 index 00000000..665a14da --- /dev/null +++ b/.cursor/rules/systems.mdc @@ -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 `` alongside renderers. See @packages/viewer/src/components/viewer/index.tsx for the mount order. + +**Systems are a customization point.** Any consumer of `` — 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 `` + +2. Create `-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 + + + + + ``` + +4. If the system must run before renderers, place it earlier in the JSX tree. diff --git a/.cursor/rules/tools.mdc b/.cursor/rules/tools.mdc new file mode 100644 index 00000000..840e1d57 --- /dev/null +++ b/.cursor/rules/tools.mdc @@ -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 ( + + {/* ghost / preview geometry only */} + + ) +} +``` + +## 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//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. diff --git a/.cursor/rules/viewer-isolation.mdc b/.cursor/rules/viewer-isolation.mdc new file mode 100644 index 00000000..a715747b --- /dev/null +++ b/.cursor/rules/viewer-isolation.mdc @@ -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 ( + useViewer.getState().setSelection(id)} + onExport={handleExport} + > + {/* Editor injects tools as children — viewer renders them inside the canvas */} + + + ) +} +``` + +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 + + {/* editor only */} + {/* editor only */} + {/* editor only */} + +``` + +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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..053918a0 --- /dev/null +++ b/AGENTS.md @@ -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`). 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 `` 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 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file