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/node-schemas.md b/.claude/rules/node-schemas.md new file mode 120000 index 00000000..93ceffa0 --- /dev/null +++ b/.claude/rules/node-schemas.md @@ -0,0 +1 @@ +../../.cursor/rules/node-schemas.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/spatial-queries.md b/.claude/rules/spatial-queries.md new file mode 120000 index 00000000..c3345522 --- /dev/null +++ b/.claude/rules/spatial-queries.md @@ -0,0 +1 @@ +../../.cursor/rules/spatial-queries.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..4ac11580 --- /dev/null +++ b/.cursor/rules/creating-rules.mdc @@ -0,0 +1,78 @@ +--- +description: How to create and maintain project rules +globs: +alwaysApply: false +--- + +# Creating Rules + +Rules live in two places and are kept in sync via symlinks: + +- `.cursor/rules/.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 | +| `node-schemas` | Zod schema pattern for node types, createNode, updateNode | +| `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools | diff --git a/.cursor/rules/events.mdc b/.cursor/rules/events.mdc new file mode 100644 index 00000000..ec751cfb --- /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). 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 { + 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/node-schemas.mdc b/.cursor/rules/node-schemas.mdc new file mode 100644 index 00000000..6cbc9080 --- /dev/null +++ b/.cursor/rules/node-schemas.mdc @@ -0,0 +1,94 @@ +--- +description: Node type definitions, Zod schema pattern, and how to create nodes in the scene +globs: packages/core/src/schema/** +alwaysApply: false +--- + +# Node Schemas + +All node types are defined as Zod schemas in `packages/core/src/schema/nodes/`. Each schema extends `BaseNode` and exports both the schema and its inferred TypeScript type. + +**Sources**: @packages/core/src/schema/base.ts, @packages/core/src/schema/nodes/ + +## BaseNode + +Every node shares these fields: + +```ts +{ + object: 'node' // always literal 'node' + id: string // typed ID e.g. "wall_abc123" + type: string // node type discriminator e.g. "wall" + name?: string // optional display name + parentId: string | null // parent node ID; null = root + visible: boolean // defaults to true + metadata: Record // 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 +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. diff --git a/.cursor/rules/renderers.mdc b/.cursor/rules/renderers.mdc new file mode 100644 index 00000000..09acabd9 --- /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({ node }: { node: MyNode }) { + const ref = useRef(null!) + useRegistry(node.id, 'my-node', ref) // 3 args: id, type, ref — no return value + const events = useNodeEvents(node, 'my-node') + + 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/spatial-queries.mdc b/.cursor/rules/spatial-queries.mdc new file mode 100644 index 00000000..b33ae5f3 --- /dev/null +++ b/.cursor/rules/spatial-queries.mdc @@ -0,0 +1,106 @@ +--- +description: Placement validation for tools — canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling +globs: apps/editor/components/tools/** +alwaysApply: false +--- + +# Spatial Queries + +`useSpatialQuery()` validates whether an item can be placed at a given position without overlapping existing items. Every placement tool must call it before committing a node to the scene. + +**Source**: @packages/core/src/hooks/spatial-grid/use-spatial-query.ts + +## Hook + +```ts +const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() +``` + +All three methods return `{ valid: boolean; conflictIds: string[] }`. +`canPlaceOnWall` additionally returns `adjustedY: number` (snapped height). + +--- + +## canPlaceOnFloor + +```ts +canPlaceOnFloor( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], // scaled width/height/depth + rotation: [number, number, number], + ignoreIds?: string[], // pass [draftItem.id] to exclude self +): { valid: boolean; conflictIds: string[] } +``` + +**Usage in a tool:** +```ts +const pos: [number, number, number] = [x, 0, z] +const { valid } = canPlaceOnFloor(levelId, pos, getScaledDimensions(item), item.rotation, [item.id]) +if (valid) createNode(item, levelId) +``` + +--- + +## canPlaceOnWall + +```ts +canPlaceOnWall( + levelId: string, + wallId: string, + localX: number, // distance along wall from start + localY: number, // height from floor + dimensions: [number, number, number], + attachType: 'wall' | 'wall-side', // 'wall' needs clearance both sides; 'wall-side' only one + side?: 'front' | 'back', + ignoreIds?: string[], +): { valid: boolean; conflictIds: string[]; adjustedY: number } +``` + +`adjustedY` contains the snapped Y so items sit flush on the slab — always use it instead of the raw `localY`: + +```ts +const { valid, adjustedY } = canPlaceOnWall(levelId, wallId, x, y, dims, 'wall', undefined, [item.id]) +if (valid) updateNode(item.id, { wallT: x, wallY: adjustedY }) +``` + +--- + +## canPlaceOnCeiling + +```ts +canPlaceOnCeiling( + ceilingId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], +): { valid: boolean; conflictIds: string[] } +``` + +--- + +## Slab Elevation + +When items rest on a slab (not flat ground), use these to get the correct Y: + +```ts +import { spatialGridManager } from '@pascal-app/core' + +// Y at a single point +const y = spatialGridManager.getSlabElevationAt(levelId, x, z) + +// Y considering the item's full footprint (highest slab point under item) +const y = spatialGridManager.getSlabElevationForItem(levelId, position, dimensions, rotation) +``` + +--- + +## Rules + +- **Always pass `[item.id]` in `ignoreIds`** when validating a draft item that already exists in the scene — otherwise it collides with itself. +- **Use `adjustedY` from `canPlaceOnWall`** — don't use the raw cursor Y for wall-mounted items. +- **Use `getScaledDimensions(item)`** (@packages/core/src/schema/nodes/item.ts) to account for item scale, not the raw `asset.dimensions`. +- Validate on every pointer move for live feedback (highlight ghost red/green). Only `createNode` / `updateNode` on pointer up or click. + +See @apps/editor/components/tools/item/use-placement-coordinator.tsx for a full implementation. diff --git a/.cursor/rules/systems.mdc b/.cursor/rules/systems.mdc new file mode 100644 index 00000000..c35a4a9f --- /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. **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. 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..e608940a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# Pascal Editor V2 — Architecture + +## Project Structure + +Monorepo managed with Turborepo. Packages are shared libraries; apps are deployable applications. + +``` +apps/ + editor/ # Main Next.js app (editor + public routes) +packages/ + core/ # Scene schema, state, systems, spatial logic + viewer/ # 3D canvas component (React Three Fiber) + auth/ # Authentication (better-auth + Supabase) + db/ # Database layer (Drizzle ORM + Supabase) + ui/ # Shared React UI components +``` + +--- + +## packages/core + +Central library — no UI, no rendering. Everything else depends on it. + +- **schema/** — TypeScript types for all node types (`Wall`, `Slab`, `Door`, `Item`, etc.) +- **store/** — Zustand scene store (`useScene`) with undo/redo via Zundo +- **systems/** — Per-element business logic: geometry generation, constraints (`WallSystem`, `SlabSystem`, `DoorSystem`, …) +- **events/** — Typed event bus for node changes +- **hooks/** — `useRegistry` (node ID → THREE.Object3D), `useSpatialGrid` (2D spatial index) +- **lib/** — Space detection, asset storage, polygon utilities + +Node storage is a flat dictionary (`nodes: Record`). 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 never import from `apps/editor`. Editor-specific behaviour (tools, systems, selection) is injected as children or props. +- **Registry pattern** — `useRegistry()` maps node IDs to live THREE objects without tree traversal. +- **Spatial grid** — 2D grid for fast wall/zone neighbourhood queries; avoid brute-force iteration. +- **Node creation** — Always use `NodeType.parse({…})` then `createNode(node, parentId)`. Never construct raw node objects. + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| 3D | Three.js (WebGPU), React Three Fiber | +| Framework | Next.js 16, React 19 | +| State | Zustand + Zundo | +| UI | Radix UI, Tailwind CSS 4 | +| Database | Supabase PostgreSQL + Drizzle ORM | +| Auth | better-auth | +| Tooling | Biome, TypeScript 5.9, Turborepo | 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