[
  {
    "path": ".claude/settings.json",
    "content": "{\n  \"permissions\": {\n    \"allow\": [\n      \"Bash(npx turbo:*)\"\n    ]\n  }\n}\n"
  },
  {
    "path": ".cursor/rules/creating-rules.mdc",
    "content": "---\ndescription: How to create and maintain project rules\nglobs: .cursor/rules/**\nalwaysApply: false\n---\n\n# Creating Rules\n\nRules live in two places and are kept in sync via symlinks:\n\n- `.cursor/rules/<rule-name>.mdc` — source of truth (Cursor format)\n- `.claude/rules/<rule-name>.md` — symlink pointing to the cursor file\n\n## Workflow\n\n**1. Write the rule in `.cursor/rules/`**\n\n```\n.cursor/rules/my-rule.mdc\n```\n\n**2. Create a symlink in `.claude/rules/`**\n\n```bash\nln -s ../../.cursor/rules/my-rule.mdc .claude/rules/my-rule.md\n```\n\nThe `../../` prefix is required because the symlink lives two levels deep.\n\n**3. Verify**\n\n```bash\nls -la .claude/rules/my-rule.md\n# → .claude/rules/my-rule.md -> ../../.cursor/rules/my-rule.mdc\n```\n\n## Rule File Format\n\n```markdown\n---\ndescription: One-line summary of what this rule covers\nglobs:\nalwaysApply: false\n---\n\n# Rule Title\n\nShort intro paragraph.\n\n## Section\n\nConcrete guidance with examples.\n```\n\n- Set `alwaysApply: true` only for rules that apply to every file in the project.\n- Use `globs` to scope a rule to specific paths (e.g. `packages/viewer/**`).\n\n## Good Practices\n\n- Keep rules under 500 lines. Split large rules into smaller focused files.\n- Include concrete examples or reference real files with `@filename`.\n- Add a rule when the same mistake has been made more than once — not preemptively.\n- Prefer showing a correct example over listing prohibitions.\n\n## Existing Rules\n\n| Rule | Covers |\n|---|---|\n| `creating-rules` | This file — how to add rules |\n| `renderers` | Node renderer pattern in `packages/viewer` |\n| `systems` | Core and viewer systems architecture |\n| `tools` | Editor tools structure in `apps/editor` |\n| `viewer-isolation` | Keeping `@pascal-app/viewer` editor-agnostic |\n| `scene-registry` | Global node ID → Object3D map and `useRegistry` |\n| `selection-managers` | Two-layer selection (viewer + editor), events, outliner |\n| `events` | Typed event bus — emitting and listening to node and grid events |\n| `node-schemas` | Zod schema pattern for node types, createNode, updateNode |\n| `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools |\n| `layers` | Three.js layer constants, ownership, and rendering separation |\n"
  },
  {
    "path": ".cursor/rules/events.mdc",
    "content": "---\ndescription: Typed event bus — emitting and listening to node and grid events\nglobs: packages/core/src/events/**,packages/viewer/**,apps/editor/**\nalwaysApply: false\n---\n\n# Events\n\nThe event bus (`emitter`) is a global `mitt` instance typed with `EditorEvents`. It decouples renderers (which emit) from selection managers and tools (which listen).\n\n**Source**: @packages/core/src/events/bus.ts\n\n## Event Key Format\n\n```\n<nodeType>:<suffix>\n```\n\nExample keys: `wall:click`, `item:enter`, `door:double-click`, `grid:pointerdown`\n\n### Node Types\n`wall` `item` `site` `building` `level` `zone` `slab` `ceiling` `roof` `window` `door`\n\n### Suffixes\n```ts\n'click' | 'move' | 'enter' | 'leave' | 'pointerdown' | 'pointerup' | 'context-menu' | 'double-click'\n```\n\nThe `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.\n\n## NodeEvent Shape\n\n```ts\ninterface NodeEvent<T extends AnyNode = AnyNode> {\n  node: T                                  // typed node that triggered the event\n  position: [number, number, number]       // world-space hit position\n  localPosition: [number, number, number]  // object-local hit position\n  normal?: [number, number, number]        // face normal, if available\n  stopPropagation: () => void\n  nativeEvent: ThreeEvent<PointerEvent>\n}\n```\n\nGrid events only carry `position` and `nativeEvent` (no `node`).\n\n## Emitting\n\nRenderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer:\n\n```tsx\n// packages/viewer/src/hooks/use-node-events.ts\nconst events = useNodeEvents(node, 'wall')\nreturn <mesh ref={ref} {...events} />\n```\n\n`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits `wall:click`, `wall:enter`, etc. It suppresses events while the camera is dragging.\n\n## Listening\n\nListen in a `useEffect`. Always clean up with `emitter.off` using the **same function reference**:\n\n```ts\n// Single event\nuseEffect(() => {\n  const handler = (e: WallEvent) => { /* … */ }\n  emitter.on('wall:click', handler)\n  return () => emitter.off('wall:click', handler)\n}, [])\n\n// Multiple node types, same handler\nuseEffect(() => {\n  const types = ['wall', 'slab', 'door'] as const\n  const handler = (e: NodeEvent) => { /* … */ }\n  types.forEach(t => emitter.on(`${t}:click`, handler as any))\n  return () => types.forEach(t => emitter.off(`${t}:click`, handler as any))\n}, [])\n```\n\nSee @apps/editor/components/editor/selection-manager.tsx for a full multi-type listener example.\n\n## Rules\n\n- **Renderers only emit, never listen.** Listening belongs in selection managers, tools, or systems.\n- **Always clean up.** Forgetting `emitter.off` causes duplicate handlers and memory leaks.\n- **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.\n- **Don't use emitter for state.** It's for one-shot interaction events. Persistent state goes in `useScene`, `useViewer`, or `useEditor`.\n- **`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.\n"
  },
  {
    "path": ".cursor/rules/layers.mdc",
    "content": "---\ndescription: Three.js layer conventions — which layer each object type lives on and why\nglobs: packages/viewer/**,apps/editor/**\nalwaysApply: false\n---\n\n# Three.js Layers\n\nThree.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.\n\n## Layer Map\n\n| Constant | Value | Package | Purpose |\n|---|---|---|---|\n| `SCENE_LAYER` | `0` | `@pascal-app/viewer` | Default Three.js layer — all regular scene geometry |\n| `EDITOR_LAYER` | `1` | `apps/editor` | Editor-only helpers: grid, tool previews, cursor meshes, snap guides |\n| `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass |\n\nImport the constants from their owning packages:\n\n```ts\n// In viewer code\nimport { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'\n\n// In editor code\nimport { EDITOR_LAYER } from '@/lib/constants'\n```\n\n## Why Separate Zones onto Layer 2\n\nZones 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:\n\n```ts\nconst zoneLayers = useMemo(() => {\n  const l = new Layers()\n  l.enable(ZONE_LAYER)\n  l.disable(SCENE_LAYER)\n  return l\n}, [])\n\nzonePass.setLayers(zoneLayers)\n```\n\nThis 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.\n\n## Why Separate Editor Helpers onto Layer 1\n\nThe 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.\n\n## Rules\n\n- **Never hardcode layer numbers.** Always use the named constants.\n- **`SCENE_LAYER` and `ZONE_LAYER` belong in `@pascal-app/viewer`** — they are renderer concerns, not editor concerns.\n- **`EDITOR_LAYER` belongs in `apps/editor`** — the viewer must never import it; editor behaviour is injected via props/children.\n- **Zone meshes must set `layers={ZONE_LAYER}`** so they are picked up by `zonePass` and excluded from `scenePass` depth buffers.\n- **Editor helper meshes must set `layers={EDITOR_LAYER}`** so they are invisible to the thumbnail camera and the viewer's render passes.\n- **Do not add new layers without updating this rule** and the post-processing pipeline accordingly.\n"
  },
  {
    "path": ".cursor/rules/node-schemas.mdc",
    "content": "---\ndescription: Node type definitions, Zod schema pattern, and how to create nodes in the scene\nglobs: packages/core/src/schema/**\nalwaysApply: false\n---\n\n# Node Schemas\n\nAll 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.\n\n**Sources**: @packages/core/src/schema/base.ts, @packages/core/src/schema/nodes/\n\n## BaseNode\n\nEvery node shares these fields:\n\n```ts\n{\n  object: 'node'            // always literal 'node'\n  id: string                // typed ID e.g. \"wall_abc123\"\n  type: string              // node type discriminator e.g. \"wall\"\n  name?: string             // optional display name\n  parentId: string | null   // parent node ID; null = root\n  visible: boolean          // defaults to true\n  metadata: Record<string, unknown>  // arbitrary JSON, defaults to {}\n}\n```\n\n## Defining a New Node Type\n\n```ts\n// packages/core/src/schema/nodes/my-node.ts\nimport { z } from 'zod'\nimport { BaseNode, objectId, nodeType } from '../base'\n\nexport const MyNode = BaseNode.extend({\n  id: objectId('my-node'),      // generates IDs like \"my-node_abc123\"\n  type: nodeType('my-node'),    // sets literal type discriminator\n  // add node-specific fields:\n  width: z.number().default(1),\n  label: z.string().optional(),\n}).describe('My node — one-line description of what it represents')\n\nexport type MyNode = z.infer<typeof MyNode>\nexport type MyNodeId = MyNode['id']\n```\n\nThen add `MyNode` to the `AnyNode` union in `packages/core/src/schema/types.ts`.\n\n## Creating Nodes in Tools\n\nAlways use `.parse()` to validate and generate a proper typed ID. Never construct a plain object manually.\n\n```ts\nimport { WallNode } from '@pascal-app/core'\nimport { useScene } from '@pascal-app/core'\n\n// 1. Parse validates and fills defaults (including auto-generated id)\nconst wall = WallNode.parse({ name: 'Wall 1', start: [0, 0], end: [5, 0] })\n\n// 2. createNode(node, parentId?) inserts it into the scene\nconst { createNode } = useScene.getState()\ncreateNode(wall, levelId)\n```\n\nFor batch creation:\n\n```ts\nconst { createNodes } = useScene.getState()\ncreateNodes([\n  { node: WallNode.parse({ start: [0, 0], end: [5, 0] }), parentId: levelId },\n  { node: WallNode.parse({ start: [5, 0], end: [5, 4] }), parentId: levelId },\n])\n```\n\n## Updating Nodes\n\n```ts\nconst { updateNode } = useScene.getState()\nupdateNode(wall.id, { height: 2.8 })   // partial update, merges with existing\n```\n\n## Real Examples\n\n- **Simple geometry node**: @packages/core/src/schema/nodes/wall.ts — `start`, `end`, `thickness`, `height`\n- **Polygon node**: @packages/core/src/schema/nodes/slab.ts — `polygon: [number, number][]`, `holes`\n- **Positioned node**: @packages/core/src/schema/nodes/item.ts — `position`, `rotation`, `scale`, `asset`\n\n## Rules\n\n- **Always use `.parse()`** — it generates the correct ID prefix and fills defaults. `WallNode.parse({...})` not `{ type: 'wall', id: '...' }`.\n- **Never hardcode IDs.** Let `objectId('type')` generate them.\n- **Add new node types to `AnyNode`** in `types.ts` or they won't be accepted by the store.\n- **Keep schemas in `packages/core`**, not in the viewer or editor — the schema is shared by all packages.\n"
  },
  {
    "path": ".cursor/rules/renderers.mdc",
    "content": "---\ndescription: Node renderer pattern in packages/viewer\nglobs: packages/viewer/**\nalwaysApply: false\n---\n\n# Renderers\n\nRenderers live in `packages/viewer/src/components/renderers/`. Each renderer is responsible for one node type's Three.js geometry and materials — nothing else.\n\n## Dispatch Chain\n\n```\n<SceneRenderer>          — iterates rootNodeIds from useScene\n  └─ <NodeRenderer>      — switches on node.type, renders the matching component\n       └─ <WallRenderer> — (or SlabRenderer, DoorRenderer, …)\n```\n\nSee @packages/viewer/src/components/renderers/scene-renderer.tsx and @packages/viewer/src/components/renderers/node-renderer.tsx.\n\n## Renderer Responsibilities\n\nA renderer **should**:\n- Read its node from `useScene` via the node's ID\n- Register its mesh(es) with `useRegistry()` so other systems can look them up\n- Subscribe to pointer events via `useNodeEvents()`\n- Render geometry and apply materials based on node properties\n\nA renderer **must not**:\n- Run geometry generation logic (that belongs in a System)\n- Import anything from `apps/editor`\n- Manage selection state directly (use `useViewer` for read, emit events for write)\n- Perform expensive per-frame calculations in the component body\n\n## Example — Minimal Renderer\n\n```tsx\n// packages/viewer/src/components/renderers/my-node/index.tsx\nimport { useRegistry } from '@pascal-app/core'\nimport { useNodeEvents } from '../../hooks/use-node-events'\nimport { useScene } from '@pascal-app/core'\n\nexport function MyNodeRenderer({ node }: { node: MyNode }) {\n  const ref = useRef<Mesh>(null!)\n  useRegistry(node.id, 'my-node', ref)   // 3 args: id, type, ref — no return value\n  const events = useNodeEvents(node, 'my-node')\n\n  return (\n    <mesh ref={ref} {...events}>\n      <boxGeometry args={[node.width, node.height, node.depth]} />\n      <meshStandardMaterial color={node.color} />\n    </mesh>\n  )\n}\n```\n\n## Adding a New Node Type\n\n1. Create `packages/viewer/src/components/renderers/<type>/index.tsx`\n2. Add a case to `NodeRenderer` in `node-renderer.tsx`\n3. Add the corresponding system in `packages/core/src/systems/` if the node needs derived geometry\n4. Export from `packages/viewer/src/index.ts` if needed externally\n\n## Performance Notes\n\n- Use `useMemo` for geometry that depends on node properties — avoid recreating on every render.\n- For complex cutout or boolean geometry, delegate to a System (e.g. `WallCutout`).\n- Register one mesh per node ID; if a renderer spawns multiple meshes, use a group ref or pick the primary one for registry.\n"
  },
  {
    "path": ".cursor/rules/scene-registry.mdc",
    "content": "---\ndescription: Scene registry pattern — mapping node IDs to live THREE.Object3D instances\nglobs: packages/core/src/hooks/scene-registry/**,packages/viewer/**\nalwaysApply: false\n---\n\n# Scene Registry\n\nThe 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.\n\n**Source**: @packages/core/src/hooks/scene-registry/scene-registry.ts\n\n## Structure\n\n```ts\nexport const sceneRegistry = {\n  nodes: new Map<string, THREE.Object3D>(),   // id → Object3D\n  byType: {\n    wall: new Set<string>(),\n    slab: new Set<string>(),\n    item: new Set<string>(),\n    // … one Set per node type\n  },\n}\n```\n\n`nodes` is the primary lookup. `byType` lets systems iterate all objects of one type without scanning the whole map.\n\n## Registering in a Renderer\n\nEvery 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.\n\n```tsx\nimport { useRegistry } from '@pascal-app/core'\n\nexport function WallRenderer({ node }: { node: WallNode }) {\n  const ref = useRef<Mesh>(null!)\n  useRegistry(node.id, 'wall', ref)   // ← required in every renderer\n\n  return <mesh ref={ref} … />\n}\n```\n\nThe hook handles both registration on mount and cleanup on unmount automatically.\n\n## Looking Up Objects\n\nAnywhere outside the renderer — in systems, selection managers, export logic:\n\n```ts\n// Single lookup\nconst obj = sceneRegistry.nodes.get(nodeId)\nif (obj) { /* use obj */ }\n\n// Iterate all walls\nfor (const id of sceneRegistry.byType.wall) {\n  const obj = sceneRegistry.nodes.get(id)\n}\n```\n\n## Rules\n\n- **One registration per node ID.** If a renderer spawns multiple meshes, register the outermost group (the one that represents the node).\n- **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.\n- **Don't mutate the registry manually.** Only `useRegistry` should add/remove entries. Systems and selection managers are read-only consumers.\n- **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.\n\n## Outliner Sync\n\nThe `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):\n\n```ts\noutliner.selectedObjects.length = 0\nfor (const id of selection.selectedIds) {\n  const obj = sceneRegistry.nodes.get(id)\n  if (obj) outliner.selectedObjects.push(obj)\n}\n```\n\nSee @packages/viewer/src/components/viewer/selection-manager.tsx for the full sync pattern.\n"
  },
  {
    "path": ".cursor/rules/selection-managers.mdc",
    "content": "---\ndescription: Selection managers — two-layer architecture for viewer and editor selection\nglobs: packages/viewer/src/components/viewer/selection-manager.tsx,apps/editor/components/editor/selection-manager.tsx\nalwaysApply: false\n---\n\n# Selection Managers\n\nThere are two selection managers. They are separate components, not the same component configured differently.\n\n| Component | Location | Knows about |\n|---|---|---|\n| `SelectionManager` | `packages/viewer/src/components/viewer/selection-manager.tsx` | Viewer state only |\n| `SelectionManager` (editor) | `apps/editor/components/editor/selection-manager.tsx` | Phase, mode, tool state |\n\nThe 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.\n\n---\n\n## How Selection Works\n\n**Event flow:**\n\n```\nuseNodeEvents(node, type) on a renderer mesh\n  → emitter.emit('wall:click', NodeEvent)\n  → SelectionManager listens via emitter.on(…)\n  → calls useViewer.setSelection(…)\n  → outliner sync re-runs → Three.js outline updates\n```\n\n`useNodeEvents` returns R3F pointer handlers. Spread them onto the mesh:\n\n```tsx\nconst events = useNodeEvents(node, 'wall')\nreturn <mesh ref={ref} {...events} />\n```\n\nEvents are suppressed during camera drag (`useViewer.getState().cameraDragging`).\n\n---\n\n## Viewer Selection Manager\n\nHierarchical path: **Building → Level → Zone → Elements**\n\nAt each level, only the next tier is selectable. Clicking outside deselects. The path is stored in `useViewer`:\n\n```ts\ntype SelectionPath = {\n  buildingId: string | null\n  levelId: string | null\n  zoneId: string | null\n  selectedIds: string[]   // walls, items, slabs, etc.\n}\n```\n\n`setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything.\n\nMulti-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it.\n\n---\n\n## Editor Selection Manager\n\nExtends 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>`).\n\n```\nphase: 'site'      → selectable: buildings\nphase: 'structure' → selectable: walls, zones, slabs, ceilings, roofs, doors, windows\n  structureLayer: 'zones'    → only zones\n  structureLayer: 'elements' → all structure types\nphase: 'furnish'   → selectable: furniture items only\n```\n\nClicking a node of a different phase auto-switches the phase. Double-click drills into a context level.\n\n---\n\n## Rules\n\n- **Never add selection logic to renderers.** Renderers spread `useNodeEvents` events and stop there. All selection decisions live in the selection manager.\n- **Never add editor phase logic to the viewer's SelectionManager.** Phase, mode, and tool awareness belong exclusively in the editor's selection manager.\n- **`useViewer` is the single source of truth for selection state.** Both managers read and write through `setSelection` / `resetSelection`. Nothing else should mutate `selection` directly.\n- **Outliner arrays are mutated in-place** (not replaced) for performance. Don't assign new arrays to `outliner.selectedObjects` or `outliner.hoveredObjects`.\n- **Hover is a separate scalar** (`hoveredId: string | null`), not part of `selectedIds`. Update it via `setHoveredId`.\n\n---\n\n## Adding Selectability to a New Node Type\n\n1. Add the type to `SelectableNodeType` in the viewer store / selection manager.\n2. Make sure its renderer calls `useNodeEvents(node, type)` and spreads the handlers.\n3. Add a case to whichever selection strategy needs it (viewer hierarchy level or editor phase).\n4. Ensure `useRegistry` is called in the renderer so the outliner can highlight it.\n"
  },
  {
    "path": ".cursor/rules/spatial-queries.mdc",
    "content": "---\ndescription: Placement validation for tools — canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling\nglobs: apps/editor/components/tools/**\nalwaysApply: false\n---\n\n# Spatial Queries\n\n`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.\n\n**Source**: @packages/core/src/hooks/spatial-grid/use-spatial-query.ts\n\n## Hook\n\n```ts\nconst { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()\n```\n\nAll three methods return `{ valid: boolean; conflictIds: string[] }`.\n`canPlaceOnWall` additionally returns `adjustedY: number` (snapped height).\n\n---\n\n## canPlaceOnFloor\n\n```ts\ncanPlaceOnFloor(\n  levelId: string,\n  position: [number, number, number],\n  dimensions: [number, number, number],   // scaled width/height/depth\n  rotation: [number, number, number],\n  ignoreIds?: string[],                   // pass [draftItem.id] to exclude self\n): { valid: boolean; conflictIds: string[] }\n```\n\n**Usage in a tool:**\n```ts\nconst pos: [number, number, number] = [x, 0, z]\nconst { valid } = canPlaceOnFloor(levelId, pos, getScaledDimensions(item), item.rotation, [item.id])\nif (valid) createNode(item, levelId)\n```\n\n---\n\n## canPlaceOnWall\n\n```ts\ncanPlaceOnWall(\n  levelId: string,\n  wallId: string,\n  localX: number,          // distance along wall from start\n  localY: number,          // height from floor\n  dimensions: [number, number, number],\n  attachType: 'wall' | 'wall-side',  // 'wall' needs clearance both sides; 'wall-side' only one\n  side?: 'front' | 'back',\n  ignoreIds?: string[],\n): { valid: boolean; conflictIds: string[]; adjustedY: number }\n```\n\n`adjustedY` contains the snapped Y so items sit flush on the slab — always use it instead of the raw `localY`:\n\n```ts\nconst { valid, adjustedY } = canPlaceOnWall(levelId, wallId, x, y, dims, 'wall', undefined, [item.id])\nif (valid) updateNode(item.id, { wallT: x, wallY: adjustedY })\n```\n\n---\n\n## canPlaceOnCeiling\n\n```ts\ncanPlaceOnCeiling(\n  ceilingId: string,\n  position: [number, number, number],\n  dimensions: [number, number, number],\n  rotation: [number, number, number],\n  ignoreIds?: string[],\n): { valid: boolean; conflictIds: string[] }\n```\n\n---\n\n## Slab Elevation\n\nWhen items rest on a slab (not flat ground), use these to get the correct Y:\n\n```ts\nimport { spatialGridManager } from '@pascal-app/core'\n\n// Y at a single point\nconst y = spatialGridManager.getSlabElevationAt(levelId, x, z)\n\n// Y considering the item's full footprint (highest slab point under item)\nconst y = spatialGridManager.getSlabElevationForItem(levelId, position, dimensions, rotation)\n```\n\n---\n\n## Rules\n\n- **Always pass `[item.id]` in `ignoreIds`** when validating a draft item that already exists in the scene — otherwise it collides with itself.\n- **Use `adjustedY` from `canPlaceOnWall`** — don't use the raw cursor Y for wall-mounted items.\n- **Use `getScaledDimensions(item)`** (@packages/core/src/schema/nodes/item.ts) to account for item scale, not the raw `asset.dimensions`.\n- Validate on every pointer move for live feedback (highlight ghost red/green). Only `createNode` / `updateNode` on pointer up or click.\n\nSee @apps/editor/components/tools/item/use-placement-coordinator.tsx for a full implementation.\n"
  },
  {
    "path": ".cursor/rules/systems.mdc",
    "content": "---\ndescription: Core and viewer systems architecture\nglobs: packages/core/src/systems/**,packages/viewer/src/systems/**\nalwaysApply: false\n---\n\n# Systems\n\nSystems own business logic, geometry generation, and constraints. They run in the Three.js frame loop and are never rendered directly.\n\n## Two Kinds of Systems\n\n### Core Systems — `packages/core/src/systems/`\n\nPure logic: no rendering, no Three.js objects. They read nodes from `useScene`, compute derived values (geometry, constraints), and write results back.\n\n| System | Responsibility |\n|---|---|\n| `WallSystem` | Wall mitering, corner joints |\n| `SlabSystem` | Polygon-based floor/roof generation |\n| `CeilingSystem` | Polygon-based ceiling generation |\n| `RoofSystem` | Pitched roof shape |\n| `DoorSystem` | Placement constraints on walls |\n| `WindowSystem` | Placement constraints on walls |\n| `ItemSystem` | Item transforms, collision |\n\n### Viewer Systems — `packages/viewer/src/systems/`\n\nAccess Three.js objects (via `useRegistry`) and manage rendering side-effects.\n\n| System | Responsibility |\n|---|---|\n| `LevelSystem` | Stacked / exploded / solo / manual level positions |\n| `WallCutout` | Cuts door/window holes in wall geometry |\n| `ZoneSystem` | Zone display and label placement |\n| `InteractiveSystem` | Item toggles and sliders in the scene |\n| `GuideSystem` | Temporary helper geometry |\n| `ScanSystem` | Point cloud rendering |\n\n## Pattern\n\nSystems are React components that render nothing (`return null`) and use `useFrame` for per-frame logic.\n\n```tsx\n// packages/core/src/systems/my-system.tsx\nimport { useFrame } from '@react-three/fiber'\nimport { useScene } from '../store/use-scene'\n\nexport function MySystem() {\n  const nodes = useScene(s => s.nodes)\n\n  useFrame(() => {\n    // compute and write back derived state\n  })\n\n  return null\n}\n```\n\nCore and viewer systems are mounted inside `<Viewer>` alongside renderers. See @packages/viewer/src/components/viewer/index.tsx for the mount order.\n\n**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.\n\n## Rules\n\n- **Core systems must not import Three.js** — they work with plain data.\n- **Viewer systems must not contain business logic** — delegate to core if the rule is domain-level.\n- **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.\n- Systems should be **idempotent**: given the same nodes, they produce the same output.\n- 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.\n\n## Adding a New System\n\n1. Decide the scope:\n   - **Domain logic** → `packages/core/src/systems/`\n   - **Viewer rendering side-effect** → `packages/viewer/src/systems/` — mount in `packages/viewer/src/components/viewer/index.tsx`\n   - **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>`\n\n2. Create `<name>-system.tsx` in the appropriate directory.\n\n3. Mount it in the right place:\n   - Viewer-internal systems go in `packages/viewer/src/components/viewer/index.tsx`\n   - App-specific systems are injected as children from outside:\n     ```tsx\n     // apps/editor — editor injects its own systems without modifying the viewer\n     <Viewer>\n       <MyEditorSystem />\n       <ToolManager />\n     </Viewer>\n     ```\n\n4. **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.\n"
  },
  {
    "path": ".cursor/rules/tools.mdc",
    "content": "---\ndescription: Editor tools structure in apps/editor\nglobs: apps/editor/components/tools/**\nalwaysApply: false\n---\n\n# Tools\n\nTools are React components that capture user input (pointer, keyboard) and translate it into `useScene` mutations. They live exclusively in `apps/editor/components/tools/`.\n\n## Lifecycle\n\n`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.\n\nSee @apps/editor/components/tools/tool-manager.tsx.\n\n## Tool Categories by Phase\n\n**Site**\n- `site-boundary-editor` — draw/edit property boundary polygon\n\n**Structure**\n- `wall-tool` — draw walls segment by segment\n- `slab-tool` + `slab-boundary-editor` + `slab-hole-editor`\n- `ceiling-tool` + `ceiling-boundary-editor` + `ceiling-hole-editor`\n- `roof-tool`\n- `door-tool` + `door-move-tool`\n- `window-tool` + `window-move-tool`\n- `item-tool` + `item-move-tool`\n- `zone-tool` + `zone-boundary-editor`\n\n**Furnish**\n- `item-tool` — place furniture\n\n**Shared utilities**\n- `polygon-editor` — reusable boundary/hole editing logic\n- `cursor-sphere` — 3D cursor visualisation\n\n## Pattern\n\n```tsx\n// apps/editor/components/tools/my-tool/index.tsx\nimport { useScene } from '@pascal-app/core'\nimport { useEditor } from '../../store/use-editor'\n\nexport function MyTool() {\n  const createNode = useScene(s => s.createNode)\n  const setTool = useEditor(s => s.setTool)\n\n  // Pointer handlers mutate the scene store directly.\n  // No local geometry — use a renderer for any preview mesh.\n\n  return (\n    <mesh onPointerDown={handleDown} onPointerMove={handleMove}>\n      {/* ghost / preview geometry only */}\n    </mesh>\n  )\n}\n```\n\n## Rules\n\n- **Tools only mutate `useScene`** — they do not call Three.js APIs directly.\n- **No business logic in tools** — delegate geometry/constraint rules to core systems.\n- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.\n- **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts.\n- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only.\n- Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. \"draw + move\") into separate components selected by `useEditor`.\n\n## Adding a New Tool\n\n1. Create `apps/editor/components/tools/<name>/index.tsx`.\n2. Register the tool in `ToolManager` under the correct phase and mode.\n3. Add the tool identifier to the `useEditor` tool union type.\n4. If the tool requires new node types, add schema + renderer + system first.\n"
  },
  {
    "path": ".cursor/rules/viewer-isolation.mdc",
    "content": "---\ndescription: Viewer must be editor-agnostic — controlled from outside via props and children\nglobs: packages/viewer/**\nalwaysApply: false\n---\n\n# Viewer Isolation\n\n`@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.\n\n## The Rule\n\n> The viewer is controlled from outside. It exposes control points (props, callbacks, children). It never reaches into `apps/editor`.\n\n## Forbidden in `packages/viewer`\n\n```ts\n// ❌ Never import from the editor app\nimport { useEditor } from '@/store/use-editor'\nimport { ToolManager } from '@/components/tools/tool-manager'\n\n// ❌ Never reference editor-specific concepts\nif (isEditorMode) { … }\n```\n\n## Correct Pattern — Pass Control from Outside\n\nThe editor mounts the viewer and passes what it needs:\n\n```tsx\n// apps/editor/components/editor-canvas.tsx  ✅\nimport { Viewer } from '@pascal-app/viewer'\nimport { ToolManager } from '../tools/tool-manager'\nimport { useEditor } from '../../store/use-editor'\n\nexport function EditorCanvas() {\n  const { selection } = useViewer()\n\n  return (\n    <Viewer\n      theme=\"light\"\n      onSelect={(id) => useViewer.getState().setSelection(id)}\n      onExport={handleExport}\n    >\n      {/* Editor injects tools as children — viewer renders them inside the canvas */}\n      <ToolManager />\n    </Viewer>\n  )\n}\n```\n\nThe viewer accepts `children` and renders them inside the R3F canvas. This is the extension point for tools, overlays, and editor-specific systems.\n\n## Viewer's Own State (`useViewer`)\n\nThe viewer store contains **only presentation state**:\n\n- `selection` — which nodes are highlighted\n- `cameraMode` — perspective / orthographic\n- `levelMode` — stacked / exploded / solo / manual\n- `wallMode` — up / cutaway / down\n- `theme` — light / dark\n- Display toggles: `showScans`, `showGuides`, `showGrid`\n\nIf a piece of state is only meaningful inside the editor (e.g. active tool, phase, edit mode) — it belongs in `useEditor`, not `useViewer`.\n\n## Nested Viewer for Editor-Specific Features\n\nWhen an editor feature needs to live \"inside\" the canvas but must not pollute the viewer package, inject it as a child:\n\n```tsx\n// ✅ Editor-specific overlay injected as child\n<Viewer>\n  <SelectionBoxOverlay />   {/* editor only */}\n  <SnapIndicator />         {/* editor only */}\n  <ToolManager />           {/* editor only */}\n</Viewer>\n```\n\nThis pattern lets the viewer stay ignorant of these components while they still have access to the R3F context.\n\n## Checklist Before Adding Code to `packages/viewer`\n\n- [ ] Does this feature make sense in the read-only viewer route?\n- [ ] Does it reference `useEditor`, tool state, or phase/mode?\n- [ ] Could it be passed in as a prop or child instead?\n\nIf any answer is \"editor-specific\", keep it in `apps/editor` and inject it via children or props.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐛 Bug Report\ndescription: Report something that isn't working correctly\nlabels: [\"bug\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue.\n\n  - type: textarea\n    id: description\n    attributes:\n      label: What happened?\n      description: A clear description of the bug.\n      placeholder: Describe what went wrong...\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to reproduce\n      description: How can we reproduce the issue?\n      placeholder: |\n        1. Go to '...'\n        2. Click on '...'\n        3. See error\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected behavior\n      description: What did you expect to happen?\n    validations:\n      required: true\n\n  - type: input\n    id: browser\n    attributes:\n      label: Browser & OS\n      description: e.g. Chrome 120, macOS 14\n      placeholder: Chrome 120, macOS 14\n    validations:\n      required: false\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: Screenshots or screen recordings\n      description: |\n        A short screen recording is the fastest way to show us the bug.\n        Drag and drop a video file or paste a link — even a quick clip helps a lot.\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: Any other information that might be helpful (console errors, related issues, etc.)\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💬 Questions & Help\n    url: https://github.com/pascalorg/editor/discussions/categories/q-a\n    about: Ask questions and get help from the community\n  - name: 💡 Ideas & Discussion\n    url: https://github.com/pascalorg/editor/discussions/categories/ideas\n    about: Share ideas and discuss features before opening a formal request\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: ✨ Feature Request\ndescription: Suggest a new feature or improvement\nlabels: [\"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        We love hearing ideas from the community! Please describe what you'd like to see in Pascal Editor.\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem or motivation\n      description: What problem does this solve? Why do you want this?\n      placeholder: I'm always frustrated when...\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed solution\n      description: How do you think this should work?\n    validations:\n      required: false\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives considered\n      description: Any workarounds or alternative approaches you've thought about?\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: Mockups, screenshots, references to other tools, etc.\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## What does this PR do?\n\n<!-- A brief description of the change. Link to a related issue if applicable: Fixes #123 -->\n\n## How to test\n\n<!-- Steps for reviewers to verify this change works -->\n\n1. \n2. \n3. \n\n## Screenshots / screen recording\n\n<!--\n🎥 Screen recordings are strongly encouraged for any visual or interactive change.\nEven a quick 15-second clip helps reviewers understand the change far better than screenshots alone.\n\nDrag and drop a video or paste a link here.\n-->\n\n## Checklist\n\n- [ ] I've tested this locally with `bun dev`\n- [ ] My code follows the existing code style (run `bun check` to verify)\n- [ ] I've updated relevant documentation (if applicable)\n- [ ] This PR targets the `main` branch\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  workflow_dispatch:\n    inputs:\n      package:\n        description: \"Package to release\"\n        required: true\n        type: choice\n        options:\n          - core\n          - viewer\n          - both\n      bump:\n        description: \"Version bump\"\n        required: true\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n      dry-run:\n        description: \"Dry run (no publish)\"\n        required: false\n        type: boolean\n        default: false\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    environment: npm\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: oven-sh/setup-bun@v2\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          registry-url: \"https://registry.npmjs.org\"\n\n      - name: Install dependencies\n        run: bun install --frozen-lockfile\n\n      - name: Configure git\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n\n      - name: Bump & publish core\n        if: inputs.package == 'core' || inputs.package == 'both'\n        working-directory: packages/core\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          BUMP=${{ inputs.bump }}\n          VERSION=$(jq -r '.version' package.json)\n          IFS='.' read -r MAJ MIN PAT <<< \"$VERSION\"\n          if [ \"$BUMP\" = \"major\" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi\n          if [ \"$BUMP\" = \"minor\" ]; then MIN=$((MIN+1)); PAT=0; fi\n          if [ \"$BUMP\" = \"patch\" ]; then PAT=$((PAT+1)); fi\n          VERSION=\"$MAJ.$MIN.$PAT\"\n          jq --arg v \"$VERSION\" '.version = $v' package.json > tmp.json && mv tmp.json package.json\n          echo \"CORE_VERSION=$VERSION\" >> $GITHUB_ENV\n\n          bun run build\n\n          if [ \"${{ inputs.dry-run }}\" = \"true\" ]; then\n            echo \"🏜️ Dry run — would publish @pascal-app/core@$VERSION\"\n            npm publish --dry-run --access public\n          else\n            npm publish --access public\n            echo \"📦 Published @pascal-app/core@$VERSION\"\n          fi\n\n      - name: Bump & publish viewer\n        if: inputs.package == 'viewer' || inputs.package == 'both'\n        working-directory: packages/viewer\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          BUMP=${{ inputs.bump }}\n          VERSION=$(jq -r '.version' package.json)\n          IFS='.' read -r MAJ MIN PAT <<< \"$VERSION\"\n          if [ \"$BUMP\" = \"major\" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi\n          if [ \"$BUMP\" = \"minor\" ]; then MIN=$((MIN+1)); PAT=0; fi\n          if [ \"$BUMP\" = \"patch\" ]; then PAT=$((PAT+1)); fi\n          VERSION=\"$MAJ.$MIN.$PAT\"\n          jq --arg v \"$VERSION\" '.version = $v' package.json > tmp.json && mv tmp.json package.json\n          echo \"VIEWER_VERSION=$VERSION\" >> $GITHUB_ENV\n\n          bun run build\n\n          if [ \"${{ inputs.dry-run }}\" = \"true\" ]; then\n            echo \"🏜️ Dry run — would publish @pascal-app/viewer@$VERSION\"\n            npm publish --dry-run --access public\n          else\n            npm publish --access public\n            echo \"📦 Published @pascal-app/viewer@$VERSION\"\n          fi\n\n      - name: Commit version bumps & tag\n        if: inputs.dry-run == false\n        run: |\n          git add -A\n          PKGS=\"\"\n          TAGS=\"\"\n\n          if [ -n \"$CORE_VERSION\" ]; then\n            PKGS=\"$PKGS @pascal-app/core@$CORE_VERSION\"\n            TAGS=\"$TAGS @pascal-app/core@$CORE_VERSION\"\n          fi\n          if [ -n \"$VIEWER_VERSION\" ]; then\n            PKGS=\"$PKGS @pascal-app/viewer@$VIEWER_VERSION\"\n            TAGS=\"$TAGS @pascal-app/viewer@$VIEWER_VERSION\"\n          fi\n\n          git commit -m \"release:${PKGS}\"\n\n          for TAG in $TAGS; do\n            git tag \"$TAG\"\n          done\n\n          git push --follow-tags\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# Dependencies\nnode_modules\npackage-lock.json\n.pnp\n.pnp.js\n\n# Local env files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Testing\ncoverage\n\n# Turbo\n.turbo\n\n# Supabase local state\nsupabase/.branches/\nsupabase/.temp/\n\n# Vercel\n.vercel\n\n# Build Outputs\n.next/\nout/\nbuild\ndist\n*.tsbuildinfo\n\n\n# Debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Misc\n.DS_Store\n*.pem\n/.playwright-mcp\nog-test\n.env*.local\n"
  },
  {
    "path": ".npmrc",
    "content": ""
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"biome.configPath\": \"./biome.jsonc\",\n  \"editor.defaultFormatter\": \"biomejs.biome\",\n  \"editor.formatOnSave\": false,\n  \"editor.formatOnPaste\": true,\n  \"emmet.showExpandedAbbreviation\": \"never\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.biome\": \"always\",\n    \"quickfix.biome\": \"always\",\n    \"source.organizeImports.biome\": \"always\",\n    \"source.organizeImports\": \"never\"\n  },\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[javascript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\",\n    \"editor.formatOnPaste\": false\n  },\n  \"[javascriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[css]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"css.lint.unknownAtRules\": \"ignore\",\n  \"[graphql]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"evenBetterToml.formatter.allowedBlankLines\": 4\n}\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Pascal Editor\n\nThanks for your interest in contributing! We welcome all kinds of contributions — bug fixes, new features, documentation, and ideas.\n\n## Getting started\n\n### Prerequisites\n\n- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+)\n\n### Setup\n\n```bash\ngit clone https://github.com/pascalorg/editor.git\ncd editor\nbun install\nbun dev\n```\n\nThe editor will be running at **http://localhost:3000**. That's it!\n\n### Optional\n\nCopy `.env.example` to `.env` and add a Google Maps API key if you want address search functionality. The editor works fully without it.\n\n## Making changes\n\n### Code style\n\nWe use [Biome](https://biomejs.dev/) for linting and formatting. Before submitting a PR:\n\n```bash\nbun check        # Check for issues\nbun check:fix    # Auto-fix issues\n```\n\n### Project structure\n\n| Package | What it does |\n|---------|-------------|\n| `packages/core` | Scene schema, state management, systems — no UI |\n| `packages/viewer` | 3D rendering with React Three Fiber |\n| `apps/editor` | The full editor app (Next.js) |\n\nA key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children.\n\n## Submitting a PR\n\n1. **Fork the repo** and create a branch from `main`\n2. **Make your changes** and test locally with `bun dev`\n3. **Run `bun check`** to make sure linting passes\n4. **Open a PR** with a clear description of what changed and why\n5. **Link related issues** if applicable (e.g., \"Fixes #42\")\n\n### PR tips\n\n- Keep PRs focused — one feature or fix per PR\n- Include screenshots or recordings for visual changes\n- If you're unsure about an approach, open an issue or discussion first\n\n## Reporting bugs\n\nUse the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster.\n\n## Suggesting features\n\nUse the [Feature Request](https://github.com/pascalorg/editor/issues/new?template=feature_request.yml) template, or start a [Discussion](https://github.com/pascalorg/editor/discussions) if you want to brainstorm first.\n\n## Questions?\n\nHead to [Discussions](https://github.com/pascalorg/editor/discussions) — we're happy to help!\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Pascal Group Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Pascal Editor\n\nA 3D building editor built with React Three Fiber and WebGPU.\n\n[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![npm @pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core)\n[![npm @pascal-app/viewer](https://img.shields.io/npm/v/@pascal-app/viewer?label=%40pascal-app%2Fviewer)](https://www.npmjs.com/package/@pascal-app/viewer)\n[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/SaBRA9t2)\n[![X (Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app)\n\nhttps://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b\n\n\n\n## Repository Architecture\n\nThis is a Turborepo monorepo with three main packages:\n\n```\neditor-v2/\n├── apps/\n│   └── editor/          # Next.js application\n├── packages/\n│   ├── core/            # Schema definitions, state management, systems\n│   └── viewer/          # 3D rendering components\n```\n\n### Separation of Concerns\n\n| Package | Responsibility |\n|---------|---------------|\n| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus |\n| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing |\n| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems |\n\nThe **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities.\n\n### Stores\n\nEach package has its own Zustand store for managing state:\n\n| Store | Package | Responsibility |\n|-------|---------|----------------|\n| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. |\n| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. |\n| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. |\n\n**Access patterns:**\n\n```typescript\n// Subscribe to state changes (React component)\nconst nodes = useScene((state) => state.nodes)\nconst levelId = useViewer((state) => state.selection.levelId)\nconst activeTool = useEditor((state) => state.tool)\n\n// Access state outside React (callbacks, systems)\nconst node = useScene.getState().nodes[id]\nuseViewer.getState().setSelection({ levelId: 'level_123' })\n```\n\n---\n\n## Core Concepts\n\n### Nodes\n\nNodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`:\n\n```typescript\nBaseNode {\n  id: string              // Auto-generated with type prefix (e.g., \"wall_abc123\")\n  type: string            // Discriminator for type-safe handling\n  parentId: string | null // Parent node reference\n  visible: boolean\n  camera?: Camera         // Optional saved camera position\n  metadata?: JSON         // Arbitrary metadata (e.g., { isTransient: true })\n}\n```\n\n**Node Hierarchy:**\n\n```\nSite\n└── Building\n    └── Level\n        ├── Wall → Item (doors, windows)\n        ├── Slab\n        ├── Ceiling → Item (lights)\n        ├── Roof\n        ├── Zone\n        ├── Scan (3D reference)\n        └── Guide (2D reference)\n```\n\nNodes are stored in a **flat dictionary** (`Record<id, Node>`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays.\n\n---\n\n### Scene State (Zustand Store)\n\nThe scene is managed by a Zustand store in `@pascal-app/core`:\n\n```typescript\nuseScene.getState() = {\n  nodes: Record<id, AnyNode>,  // All nodes\n  rootNodeIds: string[],       // Top-level nodes (sites)\n  dirtyNodes: Set<string>,     // Nodes pending system updates\n\n  createNode(node, parentId),\n  updateNode(id, updates),\n  deleteNode(id),\n}\n```\n\n**Middleware:**\n- **Persist** - Saves to IndexedDB (excludes transient nodes)\n- **Temporal** (Zundo) - Undo/redo with 50-step history\n\n---\n\n### Scene Registry\n\nThe registry maps node IDs to their Three.js objects for fast lookup:\n\n```typescript\nsceneRegistry = {\n  nodes: Map<id, Object3D>,    // ID → 3D object\n  byType: {\n    wall: Set<id>,\n    item: Set<id>,\n    zone: Set<id>,\n    // ...\n  }\n}\n```\n\nRenderers register their refs using the `useRegistry` hook:\n\n```tsx\nconst ref = useRef<Mesh>(null!)\nuseRegistry(node.id, 'wall', ref)\n```\n\nThis allows systems to access 3D objects directly without traversing the scene graph.\n\n---\n\n### Node Renderers\n\nRenderers are React components that create Three.js objects for each node type:\n\n```\nSceneRenderer\n└── NodeRenderer (dispatches by type)\n    ├── BuildingRenderer\n    ├── LevelRenderer\n    ├── WallRenderer\n    ├── SlabRenderer\n    ├── ZoneRenderer\n    ├── ItemRenderer\n    └── ...\n```\n\n**Pattern:**\n1. Renderer creates a placeholder mesh/group\n2. Registers it with `useRegistry`\n3. Systems update geometry based on node data\n\nExample (simplified):\n```tsx\nconst WallRenderer = ({ node }) => {\n  const ref = useRef<Mesh>(null!)\n  useRegistry(node.id, 'wall', ref)\n\n  return (\n    <mesh ref={ref}>\n      <boxGeometry args={[0, 0, 0]} />  {/* Replaced by WallSystem */}\n      <meshStandardMaterial />\n      {node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}\n    </mesh>\n  )\n}\n```\n\n---\n\n### Systems\n\nSystems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store.\n\n**Core Systems (in `@pascal-app/core`):**\n\n| System | Responsibility |\n|--------|---------------|\n| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |\n| `SlabSystem` | Generates floor geometry from polygons |\n| `CeilingSystem` | Generates ceiling geometry |\n| `RoofSystem` | Generates roof geometry |\n| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |\n\n**Viewer Systems (in `@pascal-app/viewer`):**\n\n| System | Responsibility |\n|--------|---------------|\n| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |\n| `ScanSystem` | Controls 3D scan visibility |\n| `GuideSystem` | Controls guide image visibility |\n\n**Processing Pattern:**\n```typescript\nuseFrame(() => {\n  for (const id of dirtyNodes) {\n    const obj = sceneRegistry.nodes.get(id)\n    const node = useScene.getState().nodes[id]\n\n    // Update geometry, transforms, etc.\n    updateGeometry(obj, node)\n\n    dirtyNodes.delete(id)\n  }\n})\n```\n\n---\n\n### Dirty Nodes\n\nWhen a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes.\n\n```typescript\n// Automatic: createNode, updateNode, deleteNode mark nodes dirty\nuseScene.getState().updateNode(wallId, { thickness: 0.2 })\n// → wallId added to dirtyNodes\n// → WallSystem regenerates geometry next frame\n// → wallId removed from dirtyNodes\n```\n\n**Manual marking:**\n```typescript\nuseScene.getState().dirtyNodes.add(wallId)\n```\n\n---\n\n### Event Bus\n\nInter-component communication uses a typed event emitter (mitt):\n\n```typescript\n// Node events\nemitter.on('wall:click', (event) => { ... })\nemitter.on('item:enter', (event) => { ... })\nemitter.on('zone:context-menu', (event) => { ... })\n\n// Grid events (background)\nemitter.on('grid:click', (event) => { ... })\n\n// Event payload\nNodeEvent {\n  node: AnyNode\n  position: [x, y, z]\n  localPosition: [x, y, z]\n  normal?: [x, y, z]\n  stopPropagation: () => void\n}\n```\n\n---\n\n### Spatial Grid Manager\n\nHandles collision detection and placement validation:\n\n```typescript\nspatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)\nspatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)\nspatialGridManager.getSlabElevationAt(levelId, x, z)\n```\n\nUsed by item placement tools to validate positions and calculate slab elevations.\n\n---\n\n## Editor Architecture\n\nThe editor extends the viewer with:\n\n### Tools\n\nTools are activated via the toolbar and handle user input for specific operations:\n\n- **SelectTool** - Selection and manipulation\n- **WallTool** - Draw walls\n- **ZoneTool** - Create zones\n- **ItemTool** - Place furniture/fixtures\n- **SlabTool** - Create floor slabs\n\n### Selection Manager\n\nThe editor uses a custom selection manager with hierarchical navigation:\n\n```\nSite → Building → Level → Zone → Items\n```\n\nEach depth level has its own selection strategy for hover/click behavior.\n\n### Editor-Specific Systems\n\n- `ZoneSystem` - Controls zone visibility based on level mode\n- Custom camera controls with node focusing\n\n---\n\n## Data Flow\n\n```\nUser Action (click, drag)\n       ↓\nTool Handler\n       ↓\nuseScene.createNode() / updateNode()\n       ↓\nNode added/updated in store\nNode marked dirty\n       ↓\nReact re-renders NodeRenderer\nuseRegistry() registers 3D object\n       ↓\nSystem detects dirty node (useFrame)\nUpdates geometry via sceneRegistry\nClears dirty flag\n```\n\n---\n\n## Technology Stack\n\n- **React 19** + **Next.js 16**\n- **Three.js** (WebGPU renderer)\n- **React Three Fiber** + **Drei**\n- **Zustand** (state management)\n- **Zod** (schema validation)\n- **Zundo** (undo/redo)\n- **three-bvh-csg** (Boolean geometry operations)\n- **Turborepo** (monorepo management)\n- **Bun** (package manager)\n\n---\n\n## Getting Started\n\n### Development\n\nRun the development server from the **root directory** to enable hot reload for all packages:\n\n```bash\n# Install dependencies\nbun install\n\n# Run development server (builds packages + starts editor with watch mode)\nbun dev\n\n# This will:\n# 1. Build @pascal-app/core and @pascal-app/viewer\n# 2. Start watching both packages for changes\n# 3. Start the Next.js editor dev server\n# Open http://localhost:3000\n```\n\n**Important:** Always run `bun dev` from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in `packages/core/src/` or `packages/viewer/src/`.\n\n### Building for Production\n\n```bash\n# Build all packages\nturbo build\n\n# Build specific package\nturbo build --filter=@pascal-app/core\n```\n\n### Publishing Packages\n\n```bash\n# Build packages\nturbo build --filter=@pascal-app/core --filter=@pascal-app/viewer\n\n# Publish to npm\nnpm publish --workspace=@pascal-app/core --access public\nnpm publish --workspace=@pascal-app/viewer --access public\n```\n\n---\n\n## Key Files\n\n| Path | Description |\n|------|-------------|\n| `packages/core/src/schema/` | Node type definitions (Zod schemas) |\n| `packages/core/src/store/use-scene.ts` | Scene state store |\n| `packages/core/src/hooks/scene-registry/` | 3D object registry |\n| `packages/core/src/systems/` | Geometry generation systems |\n| `packages/viewer/src/components/renderers/` | Node renderers |\n| `packages/viewer/src/components/viewer/` | Main Viewer component |\n| `apps/editor/components/tools/` | Editor tools |\n| `apps/editor/store/` | Editor-specific state |\n\n---\n\n## Contributors\n\n<a href=\"https://github.com/Aymericr\"><img src=\"https://avatars.githubusercontent.com/u/4444492?v=4\" width=\"60\" height=\"60\" alt=\"Aymeric Rabot\" style=\"border-radius:50%\"></a>\n<a href=\"https://github.com/wass08\"><img src=\"https://avatars.githubusercontent.com/u/6551176?v=4\" width=\"60\" height=\"60\" alt=\"Wassim Samad\" style=\"border-radius:50%\"></a>\n\n---\n\n<a href=\"https://trendshift.io/repositories/23831\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/23831\" alt=\"pascalorg/editor | Trendshift\" width=\"250\" height=\"55\"/></a>\n"
  },
  {
    "path": "SETUP.md",
    "content": "# Pascal Editor — Setup\n\n## Prerequisites\n\n- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+)\n\n## Quick Start\n\n```bash\nbun install\nbun dev\n```\n\nThe editor will be running at **http://localhost:3000**.\n\n## Environment Variables (optional)\n\nCopy `.env.example` to `.env` if you need:\n\n```bash\ncp .env.example .env\n```\n\n| Variable | Required | Description |\n|----------|----------|-------------|\n| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | No | Enables address search in the editor |\n| `PORT` | No | Dev server port (default: 3000) |\n\nThe editor works fully without any environment variables.\n\n## Monorepo Structure\n\n```\n├── apps/\n│   └── editor/          # Next.js editor application\n├── packages/\n│   ├── core/            # @pascal-app/core — Scene schema, state, systems\n│   ├── viewer/          # @pascal-app/viewer — 3D rendering\n│   └── ui/              # Shared UI components\n└── tooling/             # Build & release tooling\n```\n\n## Scripts\n\n| Command | Description |\n|---------|-------------|\n| `bun dev` | Start the development server |\n| `bun build` | Build all packages |\n| `bun check` | Lint and format check (Biome) |\n| `bun check:fix` | Auto-fix lint and format issues |\n| `bun check-types` | TypeScript type checking |\n\n## Contributing\n\nSee [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on submitting PRs and reporting issues.\n"
  },
  {
    "path": "apps/editor/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# env files (can opt-in for commiting if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n.env*.local\n"
  },
  {
    "path": "apps/editor/README.md",
    "content": "# Pascal Editor\n\nA 3D building editor built with React Three Fiber and WebGPU.\n\n## Repository Architecture\n\nThis is a Turborepo monorepo with three main packages:\n\n```\neditor-v2/\n├── apps/\n│   └── editor/          # Next.js application (this package)\n├── packages/\n│   ├── core/            # Schema definitions, state management, systems\n│   └── viewer/          # 3D rendering components\n```\n\n### Separation of Concerns\n\n| Package | Responsibility |\n|---------|---------------|\n| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus |\n| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing |\n| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems |\n\nThe **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities.\n\n### Stores\n\nEach package has its own Zustand store for managing state:\n\n| Store | Package | Responsibility |\n|-------|---------|----------------|\n| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. |\n| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. |\n| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. |\n\n**Access patterns:**\n\n```typescript\n// Subscribe to state changes (React component)\nconst nodes = useScene((state) => state.nodes)\nconst levelId = useViewer((state) => state.selection.levelId)\nconst activeTool = useEditor((state) => state.tool)\n\n// Access state outside React (callbacks, systems)\nconst node = useScene.getState().nodes[id]\nuseViewer.getState().setSelection({ levelId: 'level_123' })\n```\n\n---\n\n## Core Concepts\n\n### Nodes\n\nNodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`:\n\n```typescript\nBaseNode {\n  id: string              // Auto-generated with type prefix (e.g., \"wall_abc123\")\n  type: string            // Discriminator for type-safe handling\n  parentId: string | null // Parent node reference\n  visible: boolean\n  camera?: Camera         // Optional saved camera position\n  metadata?: JSON         // Arbitrary metadata (e.g., { isTransient: true })\n}\n```\n\n**Node Hierarchy:**\n\n```\nSite\n└── Building\n    └── Level\n        ├── Wall → Item (doors, windows)\n        ├── Slab\n        ├── Ceiling → Item (lights)\n        ├── Roof\n        ├── Zone\n        ├── Scan (3D reference)\n        └── Guide (2D reference)\n```\n\nNodes are stored in a **flat dictionary** (`Record<id, Node>`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays.\n\n---\n\n### Scene State (Zustand Store)\n\nThe scene is managed by a Zustand store in `@pascal-app/core`:\n\n```typescript\nuseScene.getState() = {\n  nodes: Record<id, AnyNode>,  // All nodes\n  rootNodeIds: string[],       // Top-level nodes (sites)\n  dirtyNodes: Set<string>,     // Nodes pending system updates\n\n  createNode(node, parentId),\n  updateNode(id, updates),\n  deleteNode(id),\n}\n```\n\n**Middleware:**\n- **Persist** - Saves to IndexedDB (excludes transient nodes)\n- **Temporal** (Zundo) - Undo/redo with 50-step history\n\n---\n\n### Scene Registry\n\nThe registry maps node IDs to their Three.js objects for fast lookup:\n\n```typescript\nsceneRegistry = {\n  nodes: Map<id, Object3D>,    // ID → 3D object\n  byType: {\n    wall: Set<id>,\n    item: Set<id>,\n    zone: Set<id>,\n    // ...\n  }\n}\n```\n\nRenderers register their refs using the `useRegistry` hook:\n\n```tsx\nconst ref = useRef<Mesh>(null!)\nuseRegistry(node.id, 'wall', ref)\n```\n\nThis allows systems to access 3D objects directly without traversing the scene graph.\n\n---\n\n### Node Renderers\n\nRenderers are React components that create Three.js objects for each node type:\n\n```\nSceneRenderer\n└── NodeRenderer (dispatches by type)\n    ├── BuildingRenderer\n    ├── LevelRenderer\n    ├── WallRenderer\n    ├── SlabRenderer\n    ├── ZoneRenderer\n    ├── ItemRenderer\n    └── ...\n```\n\n**Pattern:**\n1. Renderer creates a placeholder mesh/group\n2. Registers it with `useRegistry`\n3. Systems update geometry based on node data\n\nExample (simplified):\n```tsx\nconst WallRenderer = ({ node }) => {\n  const ref = useRef<Mesh>(null!)\n  useRegistry(node.id, 'wall', ref)\n\n  return (\n    <mesh ref={ref}>\n      <boxGeometry args={[0, 0, 0]} />  {/* Replaced by WallSystem */}\n      <meshStandardMaterial />\n      {node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}\n    </mesh>\n  )\n}\n```\n\n---\n\n### Systems\n\nSystems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store.\n\n**Core Systems (in `@pascal-app/core`):**\n\n| System | Responsibility |\n|--------|---------------|\n| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |\n| `SlabSystem` | Generates floor geometry from polygons |\n| `CeilingSystem` | Generates ceiling geometry |\n| `RoofSystem` | Generates roof geometry |\n| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |\n\n**Viewer Systems (in `@pascal-app/viewer`):**\n\n| System | Responsibility |\n|--------|---------------|\n| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |\n| `ScanSystem` | Controls 3D scan visibility |\n| `GuideSystem` | Controls guide image visibility |\n\n**Processing Pattern:**\n```typescript\nuseFrame(() => {\n  for (const id of dirtyNodes) {\n    const obj = sceneRegistry.nodes.get(id)\n    const node = useScene.getState().nodes[id]\n\n    // Update geometry, transforms, etc.\n    updateGeometry(obj, node)\n\n    dirtyNodes.delete(id)\n  }\n})\n```\n\n---\n\n### Dirty Nodes\n\nWhen a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes.\n\n```typescript\n// Automatic: createNode, updateNode, deleteNode mark nodes dirty\nuseScene.getState().updateNode(wallId, { thickness: 0.2 })\n// → wallId added to dirtyNodes\n// → WallSystem regenerates geometry next frame\n// → wallId removed from dirtyNodes\n```\n\n**Manual marking:**\n```typescript\nuseScene.getState().dirtyNodes.add(wallId)\n```\n\n---\n\n### Event Bus\n\nInter-component communication uses a typed event emitter (mitt):\n\n```typescript\n// Node events\nemitter.on('wall:click', (event) => { ... })\nemitter.on('item:enter', (event) => { ... })\nemitter.on('zone:context-menu', (event) => { ... })\n\n// Grid events (background)\nemitter.on('grid:click', (event) => { ... })\n\n// Event payload\nNodeEvent {\n  node: AnyNode\n  position: [x, y, z]\n  localPosition: [x, y, z]\n  normal?: [x, y, z]\n  stopPropagation: () => void\n}\n```\n\n---\n\n### Spatial Grid Manager\n\nHandles collision detection and placement validation:\n\n```typescript\nspatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)\nspatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)\nspatialGridManager.getSlabElevationAt(levelId, x, z)\n```\n\nUsed by item placement tools to validate positions and calculate slab elevations.\n\n---\n\n## Editor Architecture\n\nThe editor extends the viewer with:\n\n### Tools\n\nTools are activated via the toolbar and handle user input for specific operations:\n\n- **SelectTool** - Selection and manipulation\n- **WallTool** - Draw walls\n- **ZoneTool** - Create zones\n- **ItemTool** - Place furniture/fixtures\n- **SlabTool** - Create floor slabs\n\n### Selection Manager\n\nThe editor uses a custom selection manager with hierarchical navigation:\n\n```\nSite → Building → Level → Zone → Items\n```\n\nEach depth level has its own selection strategy for hover/click behavior.\n\n### Editor-Specific Systems\n\n- `ZoneSystem` - Controls zone visibility based on level mode\n- Custom camera controls with node focusing\n\n---\n\n## Data Flow\n\n```\nUser Action (click, drag)\n       ↓\nTool Handler\n       ↓\nuseScene.createNode() / updateNode()\n       ↓\nNode added/updated in store\nNode marked dirty\n       ↓\nReact re-renders NodeRenderer\nuseRegistry() registers 3D object\n       ↓\nSystem detects dirty node (useFrame)\nUpdates geometry via sceneRegistry\nClears dirty flag\n```\n\n---\n\n## Technology Stack\n\n- **React 19** + **Next.js 15**\n- **Three.js** (WebGPU renderer)\n- **React Three Fiber** + **Drei**\n- **Zustand** (state management)\n- **Zod** (schema validation)\n- **Zundo** (undo/redo)\n- **three-bvh-csg** (Boolean geometry operations)\n\n---\n\n## Getting Started\n\n```bash\n# Install dependencies\npnpm install\n\n# Run development server\npnpm dev\n\n# Open http://localhost:3000\n```\n\n---\n\n## Key Files\n\n| Path | Description |\n|------|-------------|\n| `packages/core/src/schema/` | Node type definitions (Zod schemas) |\n| `packages/core/src/hooks/use-scene.ts` | Scene state store |\n| `packages/core/src/hooks/scene-registry/` | 3D object registry |\n| `packages/core/src/systems/` | Geometry generation systems |\n| `packages/viewer/src/components/renderers/` | Node renderers |\n| `packages/viewer/src/components/viewer/` | Main Viewer component |\n| `apps/editor/components/tools/` | Editor tools |\n| `apps/editor/store/` | Editor-specific state |\n"
  },
  {
    "path": "apps/editor/app/api/health/route.ts",
    "content": "export function GET() {\n  return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })\n}\n"
  },
  {
    "path": "apps/editor/app/globals.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@source \"../../../packages/editor/src\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme {\n  --font-sans:\n    var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,\n    sans-serif;\n  --font-mono:\n    var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco,\n    Consolas, monospace;\n  --font-pixel:\n    var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace,\n    SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n  --font-barlow:\n    var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,\n    sans-serif;\n}\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --font-sans: var(--font-barlow), sans-serif;\n  --font-mono: var(--font-geist-mono), monospace;\n  --font-pixel:\n    var(--font-geist-pixel-square), var(--font-geist-mono), monospace;\n  --font-barlow: var(--font-barlow), sans-serif;\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar: var(--sidebar);\n  --color-chart-5: var(--chart-5);\n  --color-chart-4: var(--chart-4);\n  --color-chart-3: var(--chart-3);\n  --color-chart-2: var(--chart-2);\n  --color-chart-1: var(--chart-1);\n  --color-ring: var(--ring);\n  --color-input: var(--input);\n  --color-border: var(--border);\n  --color-destructive: var(--destructive);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-accent: var(--accent);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-muted: var(--muted);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-secondary: var(--secondary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-primary: var(--primary);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-popover: var(--popover);\n  --color-card-foreground: var(--card-foreground);\n  --color-card: var(--card);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n  --radius: 0.625rem;\n  --background: oklch(0.998 0 0);\n  --foreground: oklch(0.145 0 0);\n  --card: oklch(0.998 0 0);\n  --card-foreground: oklch(0.145 0 0);\n  --popover: oklch(0.998 0 0);\n  --popover-foreground: oklch(0.145 0 0);\n  --primary: oklch(0.205 0 0);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.97 0 0);\n  --secondary-foreground: oklch(0.205 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --accent: oklch(0.97 0 0);\n  --accent-foreground: oklch(0.205 0 0);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.922 0 0);\n  --input: oklch(0.922 0 0);\n  --ring: oklch(0.708 0 0);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n.dark {\n  --background: oklch(0.205 0 0); /* ~171717 */\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(\n    0.235 0 0\n  ); /* slightly lighter than background (0.205) but darker than previous (0.269) */\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.235 0 0); /* matching accent */\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n  button,\n  [role=\"button\"],\n  a {\n    cursor: pointer;\n  }\n}\n\n/* Apple-style smooth corners (squircle) — progressive enhancement */\n.rounded-smooth {\n  border-radius: var(--radius-lg);\n  corner-shape: squircle;\n}\n.rounded-smooth-xl {\n  border-radius: var(--radius-xl);\n  corner-shape: squircle;\n}\n\n.no-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n.no-scrollbar {\n  -ms-overflow-style: none; /* IE and Edge */\n  scrollbar-width: none; /* Firefox */\n}\n\n@media (prefers-reduced-motion: reduce) {\n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n\n/* Loaders */\n.pascal-loader-1 {\n  width: 45px;\n  aspect-ratio: 1;\n  --c: no-repeat linear-gradient(currentColor 0 0);\n  background: var(--c), var(--c), var(--c);\n  animation:\n    pascal-l1-1 1s infinite,\n    pascal-l1-2 1s infinite;\n}\n@keyframes pascal-l1-1 {\n  0%,\n  100% {\n    background-size: 20% 100%;\n  }\n  33%,\n  66% {\n    background-size: 20% 20%;\n  }\n}\n@keyframes pascal-l1-2 {\n  0%,\n  33% {\n    background-position:\n      0 0,\n      50% 50%,\n      100% 100%;\n  }\n  66%,\n  100% {\n    background-position:\n      100% 0,\n      50% 50%,\n      0 100%;\n  }\n}\n\n.pascal-loader-2 {\n  width: 45px;\n  aspect-ratio: 0.75;\n  --c: no-repeat linear-gradient(currentColor 0 0);\n  background:\n    var(--c) 0% 50%,\n    var(--c) 50% 50%,\n    var(--c) 100% 50%;\n  background-size: 20% 50%;\n  animation: pascal-l2 1s infinite linear;\n}\n@keyframes pascal-l2 {\n  20% {\n    background-position:\n      0% 0%,\n      50% 50%,\n      100% 50%;\n  }\n  40% {\n    background-position:\n      0% 100%,\n      50% 0%,\n      100% 50%;\n  }\n  60% {\n    background-position:\n      0% 50%,\n      50% 100%,\n      100% 0%;\n  }\n  80% {\n    background-position:\n      0% 50%,\n      50% 50%,\n      100% 100%;\n  }\n}\n\n.pascal-loader-3 {\n  width: 45px;\n  aspect-ratio: 0.75;\n  --c: no-repeat linear-gradient(currentColor 0 0);\n  background:\n    var(--c) 0% 100%,\n    var(--c) 50% 100%,\n    var(--c) 100% 100%;\n  background-size: 20% 65%;\n  animation: pascal-l3 1s infinite linear;\n}\n@keyframes pascal-l3 {\n  16.67% {\n    background-position:\n      0% 0%,\n      50% 100%,\n      100% 100%;\n  }\n  33.33% {\n    background-position:\n      0% 0%,\n      50% 0%,\n      100% 100%;\n  }\n  50% {\n    background-position:\n      0% 0%,\n      50% 0%,\n      100% 0%;\n  }\n  66.67% {\n    background-position:\n      0% 100%,\n      50% 0%,\n      100% 0%;\n  }\n  83.33% {\n    background-position:\n      0% 100%,\n      50% 100%,\n      100% 0%;\n  }\n}\n\n.pascal-loader-4 {\n  width: 45px;\n  aspect-ratio: 1;\n  --c: no-repeat linear-gradient(currentColor 0 0);\n  background: var(--c), var(--c), var(--c);\n  animation:\n    pascal-l4-1 1s infinite,\n    pascal-l4-2 1s infinite;\n}\n@keyframes pascal-l4-1 {\n  0%,\n  100% {\n    background-size: 20% 100%;\n  }\n  33%,\n  66% {\n    background-size: 20% 40%;\n  }\n}\n@keyframes pascal-l4-2 {\n  0%,\n  33% {\n    background-position:\n      0 0,\n      50% 100%,\n      100% 100%;\n  }\n  66%,\n  100% {\n    background-position:\n      100% 0,\n      0 100%,\n      50% 100%;\n  }\n}\n\n.pascal-loader-5 {\n  width: 45px;\n  aspect-ratio: 1;\n  --c: no-repeat linear-gradient(currentColor 0 0);\n  background: var(--c), var(--c), var(--c);\n  animation:\n    pascal-l5-1 1s infinite,\n    pascal-l5-2 1s infinite;\n}\n@keyframes pascal-l5-1 {\n  0%,\n  100% {\n    background-size: 20% 100%;\n  }\n  33%,\n  66% {\n    background-size: 20% 40%;\n  }\n}\n@keyframes pascal-l5-2 {\n  0%,\n  33% {\n    background-position:\n      0 0,\n      50% 100%,\n      100% 0;\n  }\n  66%,\n  100% {\n    background-position:\n      0 100%,\n      50% 0,\n      100% 100%;\n  }\n}\n"
  },
  {
    "path": "apps/editor/app/layout.tsx",
    "content": "import { Agentation } from 'agentation'\nimport { GeistPixelSquare } from 'geist/font/pixel'\nimport { Barlow } from 'next/font/google'\nimport localFont from 'next/font/local'\nimport Script from 'next/script'\nimport './globals.css'\n\nconst geistSans = localFont({\n  src: './fonts/GeistVF.woff',\n  variable: '--font-geist-sans',\n})\nconst geistMono = localFont({\n  src: './fonts/GeistMonoVF.woff',\n  variable: '--font-geist-mono',\n})\n\nconst barlow = Barlow({\n  subsets: ['latin'],\n  weight: ['400', '500', '600', '700'],\n  variable: '--font-barlow',\n  display: 'swap',\n})\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode\n}>) {\n  return (\n    <html\n      className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}\n      lang=\"en\"\n    >\n      <head>\n        {process.env.NODE_ENV === 'development' && (\n          <Script\n            crossOrigin=\"anonymous\"\n            src=\"//unpkg.com/react-scan/dist/auto.global.js\"\n            strategy=\"beforeInteractive\"\n          />\n        )}\n      </head>\n      <body className=\"font-sans\">\n        {children}\n        {process.env.NODE_ENV === 'development' && <Agentation />}\n      </body>\n    </html>\n  )\n}\n"
  },
  {
    "path": "apps/editor/app/page.tsx",
    "content": "'use client'\n\nimport {\n  Editor,\n  type SidebarTab,\n  ViewerToolbarLeft,\n  ViewerToolbarRight,\n} from '@pascal-app/editor'\n\nconst SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [\n  {\n    id: 'site',\n    label: 'Scene',\n    component: () => null, // Built-in SitePanel handles this\n  },\n]\n\nexport default function Home() {\n  return (\n    <div className=\"h-screen w-screen\">\n      <Editor\n        layoutVersion=\"v2\"\n        projectId=\"local-editor\"\n        sidebarTabs={SIDEBAR_TABS}\n        viewerToolbarLeft={<ViewerToolbarLeft />}\n        viewerToolbarRight={<ViewerToolbarRight />}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/editor/app/privacy/page.tsx",
    "content": "import type { Metadata } from 'next'\nimport Link from 'next/link'\n\nexport const metadata: Metadata = {\n  title: 'Privacy Policy',\n  description: 'Privacy Policy for Pascal Editor and the Pascal platform.',\n}\n\nexport default function PrivacyPage() {\n  return (\n    <div className=\"min-h-screen bg-background\">\n      <header className=\"sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur\">\n        <div className=\"container mx-auto px-6 py-4\">\n          <nav className=\"flex items-center gap-4 text-sm\">\n            <Link\n              className=\"text-muted-foreground transition-colors hover:text-foreground\"\n              href=\"/\"\n            >\n              Home\n            </Link>\n            <span className=\"text-muted-foreground\">/</span>\n            <Link\n              className=\"text-muted-foreground transition-colors hover:text-foreground\"\n              href=\"/terms\"\n            >\n              Terms of Service\n            </Link>\n            <span className=\"text-muted-foreground\">|</span>\n            <span className=\"font-medium text-foreground\">Privacy Policy</span>\n          </nav>\n        </div>\n      </header>\n\n      <main className=\"container mx-auto max-w-3xl px-6 py-12\">\n        <article className=\"prose prose-neutral dark:prose-invert max-w-none\">\n          <h1 className=\"mb-2 font-bold text-3xl\">Privacy Policy</h1>\n          <p className=\"mb-8 text-muted-foreground text-sm\">Effective Date: February 20, 2026</p>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">1. Introduction</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot; or &quot;our&quot;) operates the\n              Pascal Editor and Platform at pascal.app. This Privacy Policy explains how we collect,\n              use, and protect your information when you use our services.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">2. Information We Collect</h2>\n\n            <h3 className=\"mt-4 font-medium text-lg\">Account Information</h3>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              When you create an account, we collect:\n            </p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>Email address</li>\n              <li>Name</li>\n              <li>Profile picture/avatar</li>\n              <li>OAuth provider data (from Google when you sign in with Google)</li>\n            </ul>\n\n            <h3 className=\"mt-4 font-medium text-lg\">Project Data</h3>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              When you use the Platform, we store your projects, including 3D building designs,\n              floor plans, and associated metadata.\n            </p>\n\n            <h3 className=\"mt-4 font-medium text-lg\">Usage Analytics</h3>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We use Vercel Analytics and Speed Insights to collect anonymized usage data, including\n              page views, performance metrics, and general usage patterns. This helps us improve the\n              Platform.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">3. How We Use Your Information</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">We use your information to:</p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>Provide and maintain your account</li>\n              <li>Store and sync your projects across devices</li>\n              <li>Improve our services based on usage patterns</li>\n              <li>\n                Send optional email notifications about new features and updates (you can opt out in\n                settings)\n              </li>\n              <li>Respond to support requests</li>\n              <li>Ensure platform security and prevent abuse</li>\n            </ul>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">4. Data Storage</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              Your data is stored using Supabase (PostgreSQL database) on secure cloud\n              infrastructure. We implement appropriate technical and organizational measures to\n              protect your data.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">5. Third-Party Services</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We use the following third-party services to operate the Platform:\n            </p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>\n                <strong>Google</strong> - OAuth authentication for sign-in\n              </li>\n              <li>\n                <strong>Vercel</strong> - Application hosting, analytics, and performance monitoring\n              </li>\n              <li>\n                <strong>Supabase</strong> - Database hosting and authentication infrastructure\n              </li>\n            </ul>\n            <p className=\"mt-4 text-foreground/90 leading-relaxed\">\n              Each of these services has their own privacy policies governing their handling of your\n              data.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">6. Cookies</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We use minimal cookies necessary for the Platform to function:\n            </p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>\n                <strong>Session cookies</strong> - Essential for authentication and keeping you\n                signed in\n              </li>\n              <li>\n                <strong>Analytics cookies</strong> - Used by Vercel Analytics to collect anonymized\n                usage data\n              </li>\n            </ul>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">7. Your Rights</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">You have the right to:</p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>Access the personal data we hold about you</li>\n              <li>Request correction of inaccurate data</li>\n              <li>Request deletion of your data</li>\n              <li>Export your project data</li>\n              <li>Opt out of marketing communications</li>\n            </ul>\n            <p className=\"mt-4 text-foreground/90 leading-relaxed\">\n              To exercise any of these rights, please contact us at{' '}\n              <a\n                className=\"text-foreground underline hover:text-foreground/80\"\n                href=\"mailto:support@pascal.app\"\n              >\n                support@pascal.app\n              </a>\n              .\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">8. Data Retention</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We retain your data for as long as your account is active. If you delete your account,\n              we will delete your personal data and project data within 30 days, except where we are\n              required by law to retain certain information.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">9. Children&apos;s Privacy</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              The Platform is not intended for children under 13. We do not knowingly collect\n              personal information from children under 13. If you believe we have collected such\n              information, please contact us immediately.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">10. Changes to This Policy</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We may update this Privacy Policy from time to time. We will notify you of material\n              changes by posting the updated policy on the Platform. Your continued use of the\n              Platform after changes are posted constitutes your acceptance of the revised policy.\n            </p>\n          </section>\n\n          <section className=\"space-y-4\">\n            <h2 className=\"font-semibold text-xl\">11. Contact Us</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              If you have questions about this Privacy Policy or how we handle your data, please\n              contact us at{' '}\n              <a\n                className=\"text-foreground underline hover:text-foreground/80\"\n                href=\"mailto:support@pascal.app\"\n              >\n                support@pascal.app\n              </a>\n              .\n            </p>\n          </section>\n        </article>\n      </main>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/editor/app/terms/page.tsx",
    "content": "import type { Metadata } from 'next'\nimport Link from 'next/link'\n\nexport const metadata: Metadata = {\n  title: 'Terms of Service',\n  description: 'Terms of Service for Pascal Editor and the Pascal platform.',\n}\n\nexport default function TermsPage() {\n  return (\n    <div className=\"min-h-screen bg-background\">\n      <header className=\"sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur\">\n        <div className=\"container mx-auto px-6 py-4\">\n          <nav className=\"flex items-center gap-4 text-sm\">\n            <Link\n              className=\"text-muted-foreground transition-colors hover:text-foreground\"\n              href=\"/\"\n            >\n              Home\n            </Link>\n            <span className=\"text-muted-foreground\">/</span>\n            <span className=\"font-medium text-foreground\">Terms of Service</span>\n            <span className=\"text-muted-foreground\">|</span>\n            <Link\n              className=\"text-muted-foreground transition-colors hover:text-foreground\"\n              href=\"/privacy\"\n            >\n              Privacy Policy\n            </Link>\n          </nav>\n        </div>\n      </header>\n\n      <main className=\"container mx-auto max-w-3xl px-6 py-12\">\n        <article className=\"prose prose-neutral dark:prose-invert max-w-none\">\n          <h1 className=\"mb-2 font-bold text-3xl\">Terms of Service</h1>\n          <p className=\"mb-8 text-muted-foreground text-sm\">Effective Date: February 20, 2026</p>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">1. Introduction</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              Welcome to Pascal Editor (&quot;Editor&quot;) and the Pascal platform at pascal.app\n              (&quot;Platform&quot;), operated by Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot;\n              or &quot;our&quot;). By accessing or using our services, you agree to these Terms of\n              Service.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">2. The Editor and Platform</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              The Pascal Editor is open-source software released under the MIT License. You may use,\n              copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor\n              software in accordance with the MIT License terms.\n            </p>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              The Pascal platform (pascal.app) and its associated services, including user accounts,\n              cloud storage, and project hosting, are proprietary services owned and operated by\n              Pascal Group Inc. These Terms govern your use of the Platform.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">3. Accounts and Authentication</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              To use certain features of the Platform, you must create an account. We use Google\n              OAuth and magic link email authentication through Supabase. You are responsible for\n              maintaining the security of your account credentials and for all activities that occur\n              under your account.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">4. Acceptable Use</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">You agree not to:</p>\n            <ul className=\"list-disc space-y-2 pl-6 text-foreground/90\">\n              <li>\n                Use the Platform for any unlawful purpose or in violation of any applicable laws\n              </li>\n              <li>\n                Upload, share, or distribute content that infringes intellectual property rights\n              </li>\n              <li>Attempt to gain unauthorized access to the Platform or its systems</li>\n              <li>Interfere with or disrupt the Platform&apos;s infrastructure</li>\n              <li>Upload malicious code, viruses, or harmful content</li>\n              <li>Harass, abuse, or harm other users</li>\n              <li>Use the Platform to send spam or unsolicited communications</li>\n            </ul>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">5. Your Content and Intellectual Property</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              You retain full ownership of all content, projects, and data you create or upload to\n              the Platform (&quot;Your Content&quot;). By using the Platform, you grant us a limited\n              license to store, display, and transmit Your Content solely to provide our services to\n              you.\n            </p>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We do not claim any ownership rights over Your Content. You may export or delete Your\n              Content at any time.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">6. Platform Ownership</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              The Platform, including its design, features, and proprietary code, is owned by Pascal\n              Group Inc. and protected by intellectual property laws. While the Editor source code\n              is open-source under the MIT License, the Platform services, branding, and\n              infrastructure remain our proprietary property.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">7. Account Termination</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We reserve the right to suspend or terminate your account if you violate these Terms\n              or engage in conduct that we determine is harmful to the Platform or other users. You\n              may also delete your account at any time by contacting us at{' '}\n              <a\n                className=\"text-foreground underline hover:text-foreground/80\"\n                href=\"mailto:support@pascal.app\"\n              >\n                support@pascal.app\n              </a>\n              .\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">8. Disclaimer of Warranties</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              THE PLATFORM IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; WITHOUT\n              WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO\n              IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND\n              NON-INFRINGEMENT.\n            </p>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We do not warrant that the Platform will be uninterrupted, error-free, or free of\n              harmful components.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">9. Limitation of Liability</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE FOR ANY\n              INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF\n              DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE PLATFORM.\n            </p>\n          </section>\n\n          <section className=\"mb-8 space-y-4\">\n            <h2 className=\"font-semibold text-xl\">10. Changes to Terms</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              We may update these Terms from time to time. We will notify you of material changes by\n              posting the updated Terms on the Platform. Your continued use of the Platform after\n              changes are posted constitutes your acceptance of the revised Terms.\n            </p>\n          </section>\n\n          <section className=\"space-y-4\">\n            <h2 className=\"font-semibold text-xl\">11. Contact Us</h2>\n            <p className=\"text-foreground/90 leading-relaxed\">\n              If you have questions about these Terms, please contact us at{' '}\n              <a\n                className=\"text-foreground underline hover:text-foreground/80\"\n                href=\"mailto:support@pascal.app\"\n              >\n                support@pascal.app\n              </a>\n              .\n            </p>\n          </section>\n        </article>\n      </main>\n    </div>\n  )\n}\n"
  },
  {
    "path": "apps/editor/env.mjs",
    "content": "/**\n * Environment variable validation for the editor app.\n *\n * This file validates that required environment variables are set at runtime.\n * Variables are defined in the root .env file.\n *\n * @see https://env.t3.gg/docs/nextjs\n */\nimport { createEnv } from '@t3-oss/env-nextjs'\nimport { z } from 'zod'\n\nexport const env = createEnv({\n  /**\n   * Server-side environment variables (not exposed to client)\n   */\n  server: {\n    // Database\n    POSTGRES_URL: z.string().min(1),\n    SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),\n\n    // Auth\n    BETTER_AUTH_SECRET: z.string().min(1),\n    GOOGLE_CLIENT_ID: z.string().optional(),\n    GOOGLE_CLIENT_SECRET: z.string().optional(),\n\n    // Email\n    RESEND_API_KEY: z.string().optional(),\n  },\n\n  /**\n   * Client-side environment variables (exposed to browser via NEXT_PUBLIC_)\n   */\n  client: {\n    NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),\n    NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),\n  },\n\n  /**\n   * Runtime values - pulls from process.env\n   */\n  runtimeEnv: {\n    // Server\n    POSTGRES_URL: process.env.POSTGRES_URL,\n    SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,\n    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,\n    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,\n    GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,\n    RESEND_API_KEY: process.env.RESEND_API_KEY,\n    // Client\n    NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,\n    NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,\n  },\n\n  /**\n   * Skip validation during build (env vars come from Vercel at runtime)\n   */\n  skipValidation: !!process.env.SKIP_ENV_VALIDATION,\n})\n"
  },
  {
    "path": "apps/editor/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\nexport const isDevelopment =\n  process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'\n\nexport const isProduction =\n  process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'\n\nexport const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'\n\n/**\n * Base URL for the application\n * Uses NEXT_PUBLIC_* variables which are available at build time\n */\nexport const BASE_URL = (() => {\n  // Development: localhost\n  if (isDevelopment) {\n    return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`\n  }\n\n  // Preview deployments: use Vercel branch URL\n  if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {\n    return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`\n  }\n\n  // Production: use custom domain or Vercel production URL\n  if (isProduction) {\n    return (\n      process.env.NEXT_PUBLIC_APP_URL ||\n      (process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL\n        ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`\n        : 'https://editor.pascal.app')\n    )\n  }\n\n  // Fallback (should never reach here in normal operation)\n  return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'\n})()\n"
  },
  {
    "path": "apps/editor/next.config.ts",
    "content": "import type { NextConfig } from 'next'\n\nconst nextConfig: NextConfig = {\n  typescript: {\n    ignoreBuildErrors: true,\n  },\n  transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core', '@pascal-app/editor'],\n  turbopack: {\n    resolveAlias: {\n      react: './node_modules/react',\n      three: './node_modules/three',\n      '@react-three/fiber': './node_modules/@react-three/fiber',\n      '@react-three/drei': './node_modules/@react-three/drei',\n    },\n  },\n  experimental: {\n    serverActions: {\n      bodySizeLimit: '100mb',\n    },\n  },\n  images: {\n    unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,\n    remotePatterns: [\n      {\n        protocol: 'https',\n        hostname: '**',\n      },\n      {\n        protocol: 'http',\n        hostname: '**',\n      },\n    ],\n  },\n}\n\nexport default nextConfig\n"
  },
  {
    "path": "apps/editor/package.json",
    "content": "{\n  \"name\": \"editor\",\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"dotenv -e ./.env.local --override -- next dev --port 3002\",\n    \"build\": \"dotenv -e ./.env.local --override -- next build\",\n    \"start\": \"next start\",\n    \"lint\": \"biome lint\",\n    \"check-types\": \"next typegen && tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@number-flow/react\": \"^0.5.14\",\n    \"@pascal-app/core\": \"*\",\n    \"@pascal-app/editor\": \"*\",\n    \"@pascal-app/viewer\": \"*\",\n    \"@react-three/drei\": \"^10.7.7\",\n    \"@react-three/fiber\": \"^9.5.0\",\n    \"@tailwindcss/postcss\": \"^4.2.1\",\n    \"clsx\": \"^2.1.1\",\n    \"geist\": \"^1.7.0\",\n    \"next\": \"16.2.1\",\n    \"postcss\": \"^8.5.6\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"three\": \"^0.183.1\"\n  },\n  \"devDependencies\": {\n    \"@pascal/typescript-config\": \"*\",\n    \"@types/howler\": \"^2.2.12\",\n    \"@types/node\": \"^22.19.12\",\n    \"@types/react\": \"19.2.2\",\n    \"@types/react-dom\": \"19.2.2\",\n    \"agentation\": \"^2.3.2\",\n    \"react-grab\": \"^0.1.25\",\n    \"react-scan\": \"^0.5.3\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"5.9.3\"\n  }\n}\n"
  },
  {
    "path": "apps/editor/postcss.config.mjs",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n}\n"
  },
  {
    "path": "apps/editor/public/demos/demo_1.json",
    "content": "{\n  \"nodes\": {\n    \"building_bv4ilcjivnxn8wkd\": {\n      \"object\": \"node\",\n      \"id\": \"building_bv4ilcjivnxn8wkd\",\n      \"type\": \"building\",\n      \"parentId\": null,\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"level_pojp0mw3qssu110w\", \"level_bbyvfs9qwzh4arjf\"],\n      \"position\": [0, 0, 0],\n      \"rotation\": [0, 0, 0]\n    },\n    \"level_pojp0mw3qssu110w\": {\n      \"object\": \"node\",\n      \"id\": \"level_pojp0mw3qssu110w\",\n      \"type\": \"level\",\n      \"parentId\": null,\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\n        \"slab_gr6zxi4915gqwjbn\",\n        \"zone_iozx54yy1hmmoads\",\n        \"item_ketiyaswru3k7vub\",\n        \"item_8zi2layxh9s8erc1\",\n        \"item_t29aoosaypbiwkq2\",\n        \"item_ycf4n2wudq423w5g\",\n        \"wall_0j28n7nskm2sst7m\",\n        \"wall_3wwt9bjqrdc5w09s\",\n        \"wall_785y11hb3nztn1ua\",\n        \"wall_g4h1v4vm9ou0wryc\",\n        \"item_4vch778zg55nfmgb\",\n        \"item_vqk00ajqdznqsygs\",\n        \"wall_ejrf1znv4twbeszy\",\n        \"wall_9l64ckn6p3yzmfxf\",\n        \"item_e3bxmnrhz9eclzgw\",\n        \"item_vf8hqhemi33y4n0w\",\n        \"item_k2zexryozzwgokox\",\n        \"item_6p3y4rva0zv24s1t\",\n        \"item_r7idtbazaf6482kq\",\n        \"item_0x8hjyork26n4g2m\",\n        \"item_tmprvzxa85izusug\",\n        \"item_g6219gs7mrpg7bzd\",\n        \"item_dts7f41ictd7hah4\",\n        \"item_in5trmidft4sglhx\",\n        \"zone_c7k2ssvmbv5d1xlm\",\n        \"item_0e9paq67kdbm5ux0\",\n        \"item_iyu7knyxqe82c3yg\",\n        \"item_nwe34qk8vzs1vag7\",\n        \"item_oay55zmjjo76s1fs\",\n        \"item_g060bhthvcra992w\",\n        \"item_38kt7s45alt2vrjg\",\n        \"item_n10cp9ke7n9hxl96\",\n        \"item_nt5fxip4a03cmaoi\",\n        \"item_1quoil3ytsuuarni\",\n        \"item_e1w89kkg2pql1v45\",\n        \"item_3akotmiffzdr8ule\",\n        \"item_1b3tinfswueb6gr8\",\n        \"item_y164oe3lxfx9qefg\",\n        \"item_dsalxofuqf96h8t4\",\n        \"roof_ui8zhim41alg6lq4\",\n        \"guide_acs9nzz19rm4vl2c\"\n      ],\n      \"level\": 0,\n      \"camera\": {\n        \"position\": [32.19770918094574, 13.355189178183336, 32.63027548275616],\n        \"target\": [\n          4.8501257048479305, -6.656412040461838e-16, 7.3634421472590255\n        ],\n        \"mode\": \"perspective\"\n      }\n    },\n    \"slab_gr6zxi4915gqwjbn\": {\n      \"object\": \"node\",\n      \"id\": \"slab_gr6zxi4915gqwjbn\",\n      \"type\": \"slab\",\n      \"name\": \"Plancher\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"polygon\": [[13, 6], [9, 6], [9, 0], [13, 0]],\n      \"elevation\": 0.05\n    },\n    \"level_bbyvfs9qwzh4arjf\": {\n      \"object\": \"node\",\n      \"id\": \"level_bbyvfs9qwzh4arjf\",\n      \"type\": \"level\",\n      \"parentId\": \"building_bv4ilcjivnxn8wkd\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"roof_jxd8tc6rcuaujl25\"],\n      \"level\": 1,\n      \"camera\": {\n        \"position\": [11.709072311989358, 25.635955613557638, 50.59653427090403],\n        \"target\": [6.9995635873796855, 2.4999999999999996, 0.6911898255966076],\n        \"mode\": \"perspective\"\n      }\n    },\n    \"roof_jxd8tc6rcuaujl25\": {\n      \"object\": \"node\",\n      \"id\": \"roof_jxd8tc6rcuaujl25\",\n      \"type\": \"roof\",\n      \"name\": \"Roof 1\",\n      \"parentId\": \"level_bbyvfs9qwzh4arjf\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [10.25, 0, 3.5],\n      \"rotation\": 0,\n      \"length\": 5.5,\n      \"height\": 1.6,\n      \"leftWidth\": 4.7,\n      \"rightWidth\": 2.7\n    },\n    \"zone_iozx54yy1hmmoads\": {\n      \"object\": \"node\",\n      \"id\": \"zone_iozx54yy1hmmoads\",\n      \"type\": \"zone\",\n      \"name\": \"Wawa Zone\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"polygon\": [[9, 6], [9, 0], [13, 0], [13, 6]],\n      \"color\": \"#3b82f6\",\n      \"camera\": {\n        \"position\": [18.715003971902778, 18.8254836683251, 12.086656976691291],\n        \"target\": [\n          6.9995635873796855, 1.6971845387795204e-17, 0.6911898255966076\n        ],\n        \"mode\": \"perspective\"\n      }\n    },\n    \"item_137wje66gax2c6bc\": {\n      \"object\": \"node\",\n      \"id\": \"item_137wje66gax2c6bc\",\n      \"type\": \"item\",\n      \"name\": \"window-large\",\n      \"parentId\": \"wall_q455ycyoqnjxjcdr\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [2, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-large\",\n        \"category\": \"window\",\n        \"name\": \"window-large\",\n        \"thumbnail\": \"/items/window-large/thumbnail.webp\",\n        \"src\": \"/items/window-large/model.glb\",\n        \"dimensions\": [2, 2, 0.4],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, 1, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_ketiyaswru3k7vub\": {\n      \"object\": \"node\",\n      \"id\": \"item_ketiyaswru3k7vub\",\n      \"type\": \"item\",\n      \"name\": \"lounge-chair\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [9.5, 0, 6.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"lounge-chair\",\n        \"category\": \"furniture\",\n        \"name\": \"lounge-chair\",\n        \"thumbnail\": \"/items/lounge-chair/thumbnail.webp\",\n        \"src\": \"/items/lounge-chair/model.glb\",\n        \"dimensions\": [1, 1.1, 1.5],\n        \"offset\": [0, 0, 0.09],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_8zi2layxh9s8erc1\": {\n      \"object\": \"node\",\n      \"id\": \"item_8zi2layxh9s8erc1\",\n      \"type\": \"item\",\n      \"name\": \"Lounge Chair 2\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [12, 0, 6.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"lounge-chair\",\n        \"category\": \"furniture\",\n        \"name\": \"lounge-chair\",\n        \"thumbnail\": \"/items/lounge-chair/thumbnail.webp\",\n        \"src\": \"/items/lounge-chair/model.glb\",\n        \"dimensions\": [1, 1.1, 1.5],\n        \"offset\": [0, 0, 0.09],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_t29aoosaypbiwkq2\": {\n      \"object\": \"node\",\n      \"id\": \"item_t29aoosaypbiwkq2\",\n      \"type\": \"item\",\n      \"name\": \"trash-bin\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [8.25, 0, 6.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"trash-bin\",\n        \"category\": \"furniture\",\n        \"name\": \"trash-bin\",\n        \"thumbnail\": \"/items/trash-bin/thumbnail.webp\",\n        \"src\": \"/items/trash-bin/model.glb\",\n        \"dimensions\": [0.5, 0.6, 0.5],\n        \"offset\": [0, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_ycf4n2wudq423w5g\": {\n      \"object\": \"node\",\n      \"id\": \"item_ycf4n2wudq423w5g\",\n      \"type\": \"item\",\n      \"name\": \"toy\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [7.25, 0, 6.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"toy\",\n        \"category\": \"furniture\",\n        \"name\": \"toy\",\n        \"thumbnail\": \"/items/toy/thumbnail.webp\",\n        \"src\": \"/items/toy/model.glb\",\n        \"dimensions\": [0.5, 0.5, 0.5],\n        \"offset\": [0, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"wall_0j28n7nskm2sst7m\": {\n      \"object\": \"node\",\n      \"id\": \"wall_0j28n7nskm2sst7m\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"item_53okxjgbe9htx0yb\", \"item_u94w6z7xl4a8icgn\"],\n      \"start\": [9, 0],\n      \"end\": [13, 0]\n    },\n    \"wall_3wwt9bjqrdc5w09s\": {\n      \"object\": \"node\",\n      \"id\": \"wall_3wwt9bjqrdc5w09s\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"item_4g68nbqcpnzrqpsa\", \"item_b5xi5dwyufh17763\"],\n      \"start\": [13, 0],\n      \"end\": [13, 6]\n    },\n    \"item_b5xi5dwyufh17763\": {\n      \"object\": \"node\",\n      \"id\": \"item_b5xi5dwyufh17763\",\n      \"type\": \"item\",\n      \"name\": \"Window-simple\",\n      \"parentId\": \"wall_3wwt9bjqrdc5w09s\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [1.5, 0, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-simple\",\n        \"category\": \"window\",\n        \"name\": \"Window-simple\",\n        \"thumbnail\": \"/items/window-simple/thumbnail.webp\",\n        \"src\": \"/items/window-simple/model.glb\",\n        \"dimensions\": [1.5, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [1.06, -0.35, 0.05],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_53okxjgbe9htx0yb\": {\n      \"object\": \"node\",\n      \"id\": \"item_53okxjgbe9htx0yb\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_0j28n7nskm2sst7m\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [2.5, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1.5, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.38, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_4g68nbqcpnzrqpsa\": {\n      \"object\": \"node\",\n      \"id\": \"item_4g68nbqcpnzrqpsa\",\n      \"type\": \"item\",\n      \"name\": \"Window-rectangle\",\n      \"parentId\": \"wall_3wwt9bjqrdc5w09s\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [4, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-rectangle\",\n        \"category\": \"window\",\n        \"name\": \"Window-rectangle\",\n        \"thumbnail\": \"/items/window-rectangle/thumbnail.webp\",\n        \"src\": \"/items/window-rectangle/model.glb\",\n        \"dimensions\": [3, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [-1.65, -0.34, 0.1],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.95, 1, 1]\n      }\n    },\n    \"item_u94w6z7xl4a8icgn\": {\n      \"object\": \"node\",\n      \"id\": \"item_u94w6z7xl4a8icgn\",\n      \"type\": \"item\",\n      \"name\": \"Window-square\",\n      \"parentId\": \"wall_0j28n7nskm2sst7m\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [1, 1, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-square\",\n        \"category\": \"window\",\n        \"name\": \"Window-square\",\n        \"thumbnail\": \"/items/window-square/thumbnail.webp\",\n        \"src\": \"/items/window-square/model.glb\",\n        \"dimensions\": [1, 1.5, 0.3],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, 0.72, 0],\n        \"rotation\": [0, 3.141592653589793, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"wall_785y11hb3nztn1ua\": {\n      \"object\": \"node\",\n      \"id\": \"wall_785y11hb3nztn1ua\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"item_d7yqucsv4neczzyc\"],\n      \"start\": [8.5, 0],\n      \"end\": [6, 0]\n    },\n    \"item_d7yqucsv4neczzyc\": {\n      \"object\": \"node\",\n      \"id\": \"item_d7yqucsv4neczzyc\",\n      \"type\": \"item\",\n      \"name\": \"door-bar\",\n      \"parentId\": \"wall_785y11hb3nztn1ua\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [1, 0, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"door-bar\",\n        \"category\": \"door\",\n        \"name\": \"door-bar\",\n        \"thumbnail\": \"/items/door-bar/thumbnail.webp\",\n        \"src\": \"/items/door-bar/model.glb\",\n        \"dimensions\": [1.5, 2.5, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [-0.48, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"wall_g4h1v4vm9ou0wryc\": {\n      \"object\": \"node\",\n      \"id\": \"wall_g4h1v4vm9ou0wryc\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\"item_mslnrvkv4302epxo\", \"item_xurqne401l2bdtd1\"],\n      \"start\": [6, 0],\n      \"end\": [0, 0]\n    },\n    \"item_mslnrvkv4302epxo\": {\n      \"object\": \"node\",\n      \"id\": \"item_mslnrvkv4302epxo\",\n      \"type\": \"item\",\n      \"name\": \"glass-door\",\n      \"parentId\": \"wall_g4h1v4vm9ou0wryc\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [2, 0, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"glass-door\",\n        \"category\": \"door\",\n        \"name\": \"glass-door\",\n        \"thumbnail\": \"/items/glass-door/thumbnail.webp\",\n        \"src\": \"/items/glass-door/model.glb\",\n        \"dimensions\": [1.5, 2.5, 0.4],\n        \"attachTo\": \"wall\",\n        \"offset\": [-0.52, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.9, 0.9, 0.9]\n      }\n    },\n    \"item_xurqne401l2bdtd1\": {\n      \"object\": \"node\",\n      \"id\": \"item_xurqne401l2bdtd1\",\n      \"type\": \"item\",\n      \"name\": \"door\",\n      \"parentId\": \"wall_g4h1v4vm9ou0wryc\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [4.5, 0, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"door\",\n        \"category\": \"door\",\n        \"name\": \"door\",\n        \"thumbnail\": \"/items/door/thumbnail.webp\",\n        \"src\": \"/items/door/model.glb\",\n        \"dimensions\": [1.5, 2.5, 0.4],\n        \"attachTo\": \"wall\",\n        \"offset\": [-0.43, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.9, 0.9, 0.9]\n      }\n    },\n    \"item_6e09t7muyapqji6s\": {\n      \"object\": \"node\",\n      \"id\": \"item_6e09t7muyapqji6s\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [4.5, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1.5, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.38, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_wcp57nwt4r0zl73b\": {\n      \"object\": \"node\",\n      \"id\": \"item_wcp57nwt4r0zl73b\",\n      \"type\": \"item\",\n      \"name\": \"Window-rectangle\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [7.5, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-rectangle\",\n        \"category\": \"window\",\n        \"name\": \"Window-rectangle\",\n        \"thumbnail\": \"/items/window-rectangle/thumbnail.webp\",\n        \"src\": \"/items/window-rectangle/model.glb\",\n        \"dimensions\": [3, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [-1.65, -0.34, 0.1],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.95, 1, 1]\n      }\n    },\n    \"item_qpmack787iazwo77\": {\n      \"object\": \"node\",\n      \"id\": \"item_qpmack787iazwo77\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [10.5, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1.5, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.38, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_n78zaonsptayyn9k\": {\n      \"object\": \"node\",\n      \"id\": \"item_n78zaonsptayyn9k\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [14, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1, 1, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.18, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"item_ee7tg7583z4d2w6m\": {\n      \"object\": \"node\",\n      \"id\": \"item_ee7tg7583z4d2w6m\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [15.5, 1, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1, 1, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.18, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"item_1kave256antvprwp\": {\n      \"object\": \"node\",\n      \"id\": \"item_1kave256antvprwp\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_lyuq9em1b88jx85g\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 1, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1, 1, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.18, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"item_4vch778zg55nfmgb\": {\n      \"object\": \"node\",\n      \"id\": \"item_4vch778zg55nfmgb\",\n      \"type\": \"item\",\n      \"name\": \"lounge-chair\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [2.5, 0, 20.75],\n      \"rotation\": [0, -1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"lounge-chair\",\n        \"category\": \"furniture\",\n        \"name\": \"lounge-chair\",\n        \"thumbnail\": \"/items/lounge-chair/thumbnail.webp\",\n        \"src\": \"/items/lounge-chair/model.glb\",\n        \"dimensions\": [1, 1.1, 1.5],\n        \"offset\": [0, 0, 0.09],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_vqk00ajqdznqsygs\": {\n      \"object\": \"node\",\n      \"id\": \"item_vqk00ajqdznqsygs\",\n      \"type\": \"item\",\n      \"name\": \"tv-stand\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [3, 0, 19.25],\n      \"rotation\": [0, -1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"tv-stand\",\n        \"category\": \"furniture\",\n        \"name\": \"tv-stand\",\n        \"thumbnail\": \"/items/tv-stand/thumbnail.webp\",\n        \"src\": \"/items/tv-stand/model.glb\",\n        \"dimensions\": [2, 0.4, 0.5],\n        \"offset\": [0, 0.21, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"wall_ejrf1znv4twbeszy\": {\n      \"object\": \"node\",\n      \"id\": \"wall_ejrf1znv4twbeszy\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\n        \"item_s9fs055u0ilri7pi\",\n        \"item_0173tdywhm704hah\",\n        \"item_7ykyzy2yre3761dq\",\n        \"item_4dehdkm4vx6r4ghv\"\n      ],\n      \"start\": [1, 13],\n      \"end\": [1, 0.5]\n    },\n    \"item_s9fs055u0ilri7pi\": {\n      \"object\": \"node\",\n      \"id\": \"item_s9fs055u0ilri7pi\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_ejrf1znv4twbeszy\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [10.5, 0.5, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1.5, 1.5, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.18, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.75, 0.75, 0.75]\n      }\n    },\n    \"item_4dehdkm4vx6r4ghv\": {\n      \"object\": \"node\",\n      \"id\": \"item_4dehdkm4vx6r4ghv\",\n      \"type\": \"item\",\n      \"name\": \"Window-double\",\n      \"parentId\": \"wall_ejrf1znv4twbeszy\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [8.5, 0.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"window-double\",\n        \"category\": \"window\",\n        \"name\": \"Window-double\",\n        \"thumbnail\": \"/items/window-double/thumbnail.webp\",\n        \"src\": \"/items/window-double/model.glb\",\n        \"dimensions\": [1.5, 1.5, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, -0.18, 0.02],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [0.75, 0.75, 0.75]\n      }\n    },\n    \"item_0173tdywhm704hah\": {\n      \"object\": \"node\",\n      \"id\": \"item_0173tdywhm704hah\",\n      \"type\": \"item\",\n      \"name\": \"door\",\n      \"parentId\": \"wall_ejrf1znv4twbeszy\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [6.5, 0, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"door\",\n        \"category\": \"door\",\n        \"name\": \"door\",\n        \"thumbnail\": \"/items/door/thumbnail.webp\",\n        \"src\": \"/items/door/model.glb\",\n        \"dimensions\": [1.5, 2, 0.4],\n        \"attachTo\": \"wall\",\n        \"offset\": [-0.43, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.8, 0.8, 0.8]\n      }\n    },\n    \"item_7ykyzy2yre3761dq\": {\n      \"object\": \"node\",\n      \"id\": \"item_7ykyzy2yre3761dq\",\n      \"type\": \"item\",\n      \"name\": \"air-conditioning\",\n      \"parentId\": \"wall_ejrf1znv4twbeszy\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [4, 1.5, 0],\n      \"rotation\": [0, 0, 0],\n      \"side\": \"front\",\n      \"asset\": {\n        \"id\": \"air-conditioning\",\n        \"category\": \"appliance\",\n        \"name\": \"air-conditioning\",\n        \"thumbnail\": \"/items/air-conditioning/thumbnail.webp\",\n        \"src\": \"/items/air-conditioning/model.glb\",\n        \"dimensions\": [2, 1, 0.9],\n        \"attachTo\": \"wall-side\",\n        \"offset\": [0, 0.37, 0.21],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"wall_9l64ckn6p3yzmfxf\": {\n      \"object\": \"node\",\n      \"id\": \"wall_9l64ckn6p3yzmfxf\",\n      \"type\": \"wall\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"children\": [\n        \"item_7x3elrsxyvoubfbi\",\n        \"item_ii6ymw4nrg64bw8h\",\n        \"item_bt8uiq7mb9w3m0up\"\n      ],\n      \"start\": [-4.5, 4],\n      \"end\": [-4.5, -2.5]\n    },\n    \"item_7x3elrsxyvoubfbi\": {\n      \"object\": \"node\",\n      \"id\": \"item_7x3elrsxyvoubfbi\",\n      \"type\": \"item\",\n      \"name\": \"Window-simple\",\n      \"parentId\": \"wall_9l64ckn6p3yzmfxf\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [4.5, 0, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"window-simple\",\n        \"category\": \"window\",\n        \"name\": \"Window-simple\",\n        \"thumbnail\": \"/items/window-simple/thumbnail.webp\",\n        \"src\": \"/items/window-simple/model.glb\",\n        \"dimensions\": [1.5, 2, 0.5],\n        \"attachTo\": \"wall\",\n        \"offset\": [1.06, 0, 0.05],\n        \"rotation\": [0, 3.14, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_ii6ymw4nrg64bw8h\": {\n      \"object\": \"node\",\n      \"id\": \"item_ii6ymw4nrg64bw8h\",\n      \"type\": \"item\",\n      \"name\": \"Window-square\",\n      \"parentId\": \"wall_9l64ckn6p3yzmfxf\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [1, 0.5, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"window-square\",\n        \"category\": \"window\",\n        \"name\": \"Window-square\",\n        \"thumbnail\": \"/items/window-square/thumbnail.webp\",\n        \"src\": \"/items/window-square/model.glb\",\n        \"dimensions\": [1, 1.5, 0.3],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, 0.72, 0],\n        \"rotation\": [0, 3.141592653589793, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"item_bt8uiq7mb9w3m0up\": {\n      \"object\": \"node\",\n      \"id\": \"item_bt8uiq7mb9w3m0up\",\n      \"type\": \"item\",\n      \"name\": \"Window-square\",\n      \"parentId\": \"wall_9l64ckn6p3yzmfxf\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [2.5, 0.5, 0],\n      \"rotation\": [0, 3.141592653589793, 0],\n      \"side\": \"back\",\n      \"asset\": {\n        \"id\": \"window-square\",\n        \"category\": \"window\",\n        \"name\": \"Window-square\",\n        \"thumbnail\": \"/items/window-square/thumbnail.webp\",\n        \"src\": \"/items/window-square/model.glb\",\n        \"dimensions\": [1, 1.5, 0.3],\n        \"attachTo\": \"wall\",\n        \"offset\": [0, 0.72, 0],\n        \"rotation\": [0, 3.141592653589793, 0],\n        \"scale\": [0.5, 0.5, 0.5]\n      }\n    },\n    \"item_e3bxmnrhz9eclzgw\": {\n      \"object\": \"node\",\n      \"id\": \"item_e3bxmnrhz9eclzgw\",\n      \"type\": \"item\",\n      \"name\": \"low-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [12, 0, 12.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"low-fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_vf8hqhemi33y4n0w\": {\n      \"object\": \"node\",\n      \"id\": \"item_vf8hqhemi33y4n0w\",\n      \"type\": \"item\",\n      \"name\": \"low-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [10, 0, 12.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"low-fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_k2zexryozzwgokox\": {\n      \"object\": \"node\",\n      \"id\": \"item_k2zexryozzwgokox\",\n      \"type\": \"item\",\n      \"name\": \"low-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [8, 0, 12.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"low-fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_6p3y4rva0zv24s1t\": {\n      \"object\": \"node\",\n      \"id\": \"item_6p3y4rva0zv24s1t\",\n      \"type\": \"item\",\n      \"name\": \"parking-spot\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [7.5, 0, 14.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"parking-spot\",\n        \"category\": \"outdoor\",\n        \"name\": \"parking-spot\",\n        \"thumbnail\": \"/items/parking-spot/thumbnail.webp\",\n        \"src\": \"/items/parking-spot/model.glb\",\n        \"dimensions\": [5, 1, 2.5],\n        \"offset\": [0, 0, 0.01],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.9, 1, 0.78]\n      }\n    },\n    \"item_r7idtbazaf6482kq\": {\n      \"object\": \"node\",\n      \"id\": \"item_r7idtbazaf6482kq\",\n      \"type\": \"item\",\n      \"name\": \"fir-tree\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [5.75, 0, 11.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"fir-tree\",\n        \"category\": \"outdoor\",\n        \"name\": \"fir-tree\",\n        \"thumbnail\": \"/items/fir-tree/thumbnail.webp\",\n        \"src\": \"/items/fir-tree/model.glb\",\n        \"dimensions\": [1.5, 3.2, 1.5],\n        \"offset\": [-0.09, 0.05, 0.03],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_0x8hjyork26n4g2m\": {\n      \"object\": \"node\",\n      \"id\": \"item_0x8hjyork26n4g2m\",\n      \"type\": \"item\",\n      \"name\": \"tree\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [12, 0, 16],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"tree\",\n        \"category\": \"outdoor\",\n        \"name\": \"tree\",\n        \"thumbnail\": \"/items/tree/thumbnail.webp\",\n        \"src\": \"/items/tree/model.glb\",\n        \"dimensions\": [4, 5, 4],\n        \"offset\": [0.09, 0.17, 0.06],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.65, 0.65, 0.65]\n      }\n    },\n    \"item_tmprvzxa85izusug\": {\n      \"object\": \"node\",\n      \"id\": \"item_tmprvzxa85izusug\",\n      \"type\": \"item\",\n      \"name\": \"pillar\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [3.75, 0, 13.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"pillar\",\n        \"category\": \"outdoor\",\n        \"name\": \"pillar\",\n        \"thumbnail\": \"/items/pillar/thumbnail.webp\",\n        \"src\": \"/items/pillar/model.glb\",\n        \"dimensions\": [0.5, 1.3, 0.5],\n        \"offset\": [0, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_g6219gs7mrpg7bzd\": {\n      \"object\": \"node\",\n      \"id\": \"item_g6219gs7mrpg7bzd\",\n      \"type\": \"item\",\n      \"name\": \"pillar\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [6.75, 0, 12.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"pillar\",\n        \"category\": \"outdoor\",\n        \"name\": \"pillar\",\n        \"thumbnail\": \"/items/pillar/thumbnail.webp\",\n        \"src\": \"/items/pillar/model.glb\",\n        \"dimensions\": [0.5, 1.3, 0.5],\n        \"offset\": [0, 0, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_dts7f41ictd7hah4\": {\n      \"object\": \"node\",\n      \"id\": \"item_dts7f41ictd7hah4\",\n      \"type\": \"item\",\n      \"name\": \"low-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [13, 0, 11.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"low-fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_in5trmidft4sglhx\": {\n      \"object\": \"node\",\n      \"id\": \"item_in5trmidft4sglhx\",\n      \"type\": \"item\",\n      \"name\": \"low-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [13, 0, 9.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"low-fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"zone_c7k2ssvmbv5d1xlm\": {\n      \"object\": \"node\",\n      \"id\": \"zone_c7k2ssvmbv5d1xlm\",\n      \"type\": \"zone\",\n      \"name\": \"Relax Zone\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"polygon\": [\n        [3, 15.5],\n        [3, 0],\n        [9, 0],\n        [9, 6],\n        [13, 6],\n        [16, 6],\n        [16, 15.5]\n      ],\n      \"color\": \"#22c55e\",\n      \"camera\": {\n        \"position\": [\n          -6.654332076100337, 17.996846152830106, 22.501743052051737\n        ],\n        \"target\": [\n          5.317880378107912, -2.779128022959309e-17, 10.080522989431769\n        ],\n        \"mode\": \"perspective\"\n      }\n    },\n    \"item_0e9paq67kdbm5ux0\": {\n      \"object\": \"node\",\n      \"id\": \"item_0e9paq67kdbm5ux0\",\n      \"type\": \"item\",\n      \"name\": \"high-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 0, 6.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"high-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"high-fence\",\n        \"thumbnail\": \"/items/high-fence/thumbnail.webp\",\n        \"src\": \"/items/high-fence/model.glb\",\n        \"dimensions\": [4, 4.1, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_iyu7knyxqe82c3yg\": {\n      \"object\": \"node\",\n      \"id\": \"item_iyu7knyxqe82c3yg\",\n      \"type\": \"item\",\n      \"name\": \"high-fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [21, 0, 6.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"high-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"high-fence\",\n        \"thumbnail\": \"/items/high-fence/thumbnail.webp\",\n        \"src\": \"/items/high-fence/model.glb\",\n        \"dimensions\": [4, 4.1, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_nwe34qk8vzs1vag7\": {\n      \"object\": \"node\",\n      \"id\": \"item_nwe34qk8vzs1vag7\",\n      \"type\": \"item\",\n      \"name\": \"tree\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [16.5, 0, 9],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"tree\",\n        \"category\": \"outdoor\",\n        \"name\": \"tree\",\n        \"thumbnail\": \"/items/tree/thumbnail.webp\",\n        \"src\": \"/items/tree/model.glb\",\n        \"dimensions\": [4, 5, 4],\n        \"offset\": [0.09, 0.17, 0.06],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.65, 0.65, 0.65]\n      }\n    },\n    \"item_oay55zmjjo76s1fs\": {\n      \"object\": \"node\",\n      \"id\": \"item_oay55zmjjo76s1fs\",\n      \"type\": \"item\",\n      \"name\": \"High Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [23, 0, 4.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"high-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"High Fence\",\n        \"thumbnail\": \"/items/high-fence/thumbnail.webp\",\n        \"src\": \"/items/high-fence/model.glb\",\n        \"dimensions\": [4, 4.1, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_g060bhthvcra992w\": {\n      \"object\": \"node\",\n      \"id\": \"item_g060bhthvcra992w\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [23, 0, -1.25],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"item_38kt7s45alt2vrjg\": {\n      \"object\": \"node\",\n      \"id\": \"item_38kt7s45alt2vrjg\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [22, 0, -2.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"item_n10cp9ke7n9hxl96\": {\n      \"object\": \"node\",\n      \"id\": \"item_n10cp9ke7n9hxl96\",\n      \"type\": \"item\",\n      \"name\": \"Low Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [23, 0, -4.75],\n      \"rotation\": [0, 0, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Low Fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_nt5fxip4a03cmaoi\": {\n      \"object\": \"node\",\n      \"id\": \"item_nt5fxip4a03cmaoi\",\n      \"type\": \"item\",\n      \"name\": \"Low Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [22, 0, -5.75],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"low-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Low Fence\",\n        \"thumbnail\": \"/items/low-fence/thumbnail.webp\",\n        \"src\": \"/items/low-fence/model.glb\",\n        \"dimensions\": [2, 0.8, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_1quoil3ytsuuarni\": {\n      \"object\": \"node\",\n      \"id\": \"item_1quoil3ytsuuarni\",\n      \"type\": \"item\",\n      \"name\": \"High Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [15, 0, 4.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"high-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"High Fence\",\n        \"thumbnail\": \"/items/high-fence/thumbnail.webp\",\n        \"src\": \"/items/high-fence/model.glb\",\n        \"dimensions\": [4, 4.1, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [1, 1, 1]\n      }\n    },\n    \"item_e1w89kkg2pql1v45\": {\n      \"object\": \"node\",\n      \"id\": \"item_e1w89kkg2pql1v45\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 0, 0.25],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"item_3akotmiffzdr8ule\": {\n      \"object\": \"node\",\n      \"id\": \"item_3akotmiffzdr8ule\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 0, -1.75],\n      \"rotation\": [0, 1.5707963267948966, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"item_1b3tinfswueb6gr8\": {\n      \"object\": \"node\",\n      \"id\": \"item_1b3tinfswueb6gr8\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 0, -4.75],\n      \"rotation\": [0, 4.71238898038469, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"item_y164oe3lxfx9qefg\": {\n      \"object\": \"node\",\n      \"id\": \"item_y164oe3lxfx9qefg\",\n      \"type\": \"item\",\n      \"name\": \"Medium Fence\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [17, 0, -6.75],\n      \"rotation\": [0, 4.71238898038469, 0],\n      \"asset\": {\n        \"id\": \"medium-fence\",\n        \"category\": \"outdoor\",\n        \"name\": \"Medium Fence\",\n        \"thumbnail\": \"/items/medium-fence/thumbnail.webp\",\n        \"src\": \"/items/medium-fence/model.glb\",\n        \"dimensions\": [2, 2, 0.5],\n        \"offset\": [0, 0.01, 0],\n        \"rotation\": [0, 0, 0],\n        \"scale\": [0.49, 0.49, 0.49]\n      }\n    },\n    \"roof_ui8zhim41alg6lq4\": {\n      \"object\": \"node\",\n      \"id\": \"roof_ui8zhim41alg6lq4\",\n      \"type\": \"roof\",\n      \"name\": \"Roof 2\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"position\": [1, 0, -5.5],\n      \"rotation\": 0,\n      \"length\": 0.5,\n      \"height\": 1.5,\n      \"leftWidth\": 12.1,\n      \"rightWidth\": 1\n    },\n    \"guide_acs9nzz19rm4vl2c\": {\n      \"object\": \"node\",\n      \"id\": \"guide_acs9nzz19rm4vl2c\",\n      \"type\": \"guide\",\n      \"name\": \"FaceIt0703_FaceItProductPage.png\",\n      \"parentId\": \"level_pojp0mw3qssu110w\",\n      \"visible\": true,\n      \"metadata\": {},\n      \"url\": \"asset://1e66ba17-99d2-4c5c-ad2b-00dff438b6a7\",\n      \"position\": [0, 0, 0],\n      \"rotation\": [0, 0, 0],\n      \"scale\": 1,\n      \"opacity\": 51\n    }\n  },\n  \"rootNodeIds\": [\"building_bv4ilcjivnxn8wkd\"]\n}\n"
  },
  {
    "path": "apps/editor/tsconfig.json",
    "content": "{\n  \"extends\": \"@pascal/typescript-config/nextjs.json\",\n  \"compilerOptions\": {\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./*\"]\n    }\n  },\n  \"include\": [\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"next-env.d.ts\",\n    \"next.config.js\",\n    \".next/types/**/*.ts\"\n  ],\n  \"exclude\": [\"node_modules\"],\n  \"references\": [\n    { \"path\": \"../../packages/core\" },\n    { \"path\": \"../../packages/viewer\" }\n  ]\n}\n"
  },
  {
    "path": "biome.jsonc",
    "content": "{\n  \"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n  \"vcs\": {\n    \"enabled\": true,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"assist\": {\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": \"on\"\n      }\n    }\n  },\n  \"formatter\": {\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 100\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"semicolons\": \"asNeeded\",\n      \"trailingCommas\": \"all\",\n      \"quoteStyle\": \"single\",\n      \"jsxQuoteStyle\": \"double\"\n    }\n  },\n  \"linter\": {\n    \"rules\": {\n      \"style\": {\n        \"useDefaultSwitchClause\": \"off\",\n        \"useConsistentTypeDefinitions\": \"off\",\n        \"useAtIndex\": \"off\",\n        \"useConsistentArrayType\": \"off\",\n        \"noNestedTernary\": \"off\",\n        \"useBlockStatements\": \"off\",\n        \"noNonNullAssertion\": \"off\",\n        \"useConst\": \"off\",\n        \"noMagicNumbers\": \"off\"\n      },\n      \"performance\": {\n        \"noNamespaceImport\": \"off\",\n        \"noImgElement\": \"off\",\n        \"noBarrelFile\": \"off\",\n        \"noDelete\": \"off\"\n      },\n      \"suspicious\": {\n        \"noEmptyBlockStatements\": \"off\",\n        \"noImplicitAnyLet\": \"off\",\n        \"noGlobalIsNan\": \"off\",\n        \"noExplicitAny\": \"off\",\n        \"useAwait\": \"off\",\n        \"noConsole\": \"off\",\n        \"noArrayIndexKey\": \"off\",\n        \"noEvolvingTypes\": \"off\",\n        \"noDocumentCookie\": \"off\"\n      },\n      \"complexity\": {\n        \"noExcessiveCognitiveComplexity\": \"off\",\n        \"noForEach\": \"off\",\n        \"noBannedTypes\": \"off\",\n        \"noUselessFragments\": \"off\",\n        \"useLiteralKeys\": \"off\"\n      },\n      \"correctness\": {\n        \"noUnusedVariables\": \"off\",\n        \"noUnusedFunctionParameters\": \"off\",\n        \"noUnusedImports\": {\n          \"level\": \"warn\",\n          \"fix\": \"safe\"\n        },\n        \"useExhaustiveDependencies\": \"info\",\n        \"noPrecisionLoss\": \"off\"\n      },\n      \"a11y\": {\n        \"noSvgWithoutTitle\": \"off\",\n        \"useSemanticElements\": \"off\",\n        \"noLabelWithoutControl\": \"off\",\n        \"useKeyWithClickEvents\": \"off\",\n        \"noStaticElementInteractions\": \"off\",\n        \"noNoninteractiveElementInteractions\": \"off\",\n        \"useButtonType\": \"off\"\n      },\n      \"nursery\": {\n        \"noShadow\": \"off\"\n      },\n      \"security\": {\n        \"noDangerouslySetInnerHtml\": \"info\"\n      }\n    }\n  },\n  \"files\": {\n    \"ignoreUnknown\": true,\n    \"includes\": [\n      \"packages/**/*.ts\",\n      \"packages/**/*.tsx\",\n      \"packages/**/*.js\",\n      \"packages/**/*.jsx\",\n      \"packages/**/*.json\",\n      \"packages/**/*.css\",\n      \"packages/**/*.md\",\n      \"packages/**/*.mdx\",\n      \"apps/**/*.ts\",\n      \"apps/**/*.tsx\",\n      \"apps/**/*.js\",\n      \"apps/**/*.jsx\",\n      \"!**/node_modules\",\n      \"!**/.next\",\n      \"!**/dist\",\n      \"!**/build\",\n      \"!**/public\",\n      \"!**/components/ui\"\n    ]\n  },\n  \"overrides\": [\n    {\n      \"includes\": [\"packages/editor/components/debug/react-scan.tsx\"],\n      \"assist\": {\n        \"actions\": {\n          \"source\": {\n            \"organizeImports\": \"off\"\n          }\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"editor\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"turbo run build\",\n    \"dev\": \"set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose\",\n    \"lint\": \"biome lint\",\n    \"lint:fix\": \"biome lint --write\",\n    \"format\": \"biome format --write\",\n    \"format:check\": \"biome format\",\n    \"check\": \"biome check\",\n    \"check:fix\": \"biome check --write\",\n    \"check-types\": \"turbo run check-types\",\n    \"kill\": \"lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'\",\n    \"release\": \"gh workflow run release.yml -f package=both -f bump=patch\",\n    \"release:viewer\": \"gh workflow run release.yml -f package=viewer -f bump=patch\",\n    \"release:core\": \"gh workflow run release.yml -f package=core -f bump=patch\",\n    \"release:minor\": \"gh workflow run release.yml -f package=both -f bump=minor\",\n    \"release:major\": \"gh workflow run release.yml -f package=both -f bump=major\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^2.4.6\",\n    \"dotenv-cli\": \"^11.0.0\",\n    \"turbo\": \"^2.8.15\",\n    \"typescript\": \"5.9.3\",\n    \"ultracite\": \"^7.2.5\"\n  },\n  \"engines\": {\n    \"node\": \">=18\"\n  },\n  \"packageManager\": \"bun@1.3.0\",\n  \"workspaces\": [\n    \"apps/*\",\n    \"packages/*\",\n    \"tooling/*\"\n  ]\n}\n"
  },
  {
    "path": "packages/core/README.md",
    "content": "# @pascal-app/core\n\nCore library for Pascal 3D building editor.\n\n## Installation\n\n```bash\nnpm install @pascal-app/core\n```\n\n## Peer Dependencies\n\n```bash\nnpm install react three @react-three/fiber @react-three/drei\n```\n\n## What's Included\n\n- **Node Schemas** - Zod schemas for all building primitives (walls, slabs, items, etc.)\n- **Scene State** - Zustand store with IndexedDB persistence and undo/redo\n- **Systems** - Geometry generation for walls, floors, ceilings, roofs\n- **Scene Registry** - Fast lookup from node IDs to Three.js objects\n- **Spatial Grid** - Collision detection and placement validation\n- **Event Bus** - Typed event emitter for inter-component communication\n- **Asset Storage** - IndexedDB-based file storage for user-uploaded assets\n\n## Usage\n\n```typescript\nimport { useScene, WallNode, ItemNode } from '@pascal-app/core'\n\n// Create a wall\nconst wall = WallNode.parse({\n  points: [[0, 0], [5, 0]],\n  height: 3,\n  thickness: 0.2,\n})\n\nuseScene.getState().createNode(wall, parentLevelId)\n\n// Subscribe to scene changes\nfunction MyComponent() {\n  const nodes = useScene((state) => state.nodes)\n  const walls = Object.values(nodes).filter(n => n.type === 'wall')\n\n  return <div>Total walls: {walls.length}</div>\n}\n```\n\n## Node Types\n\n- `SiteNode` - Root container\n- `BuildingNode` - Building within a site\n- `LevelNode` - Floor level\n- `WallNode` - Vertical wall with optional openings\n- `SlabNode` - Floor slab\n- `CeilingNode` - Ceiling surface\n- `RoofNode` - Roof geometry\n- `ZoneNode` - Spatial zone/room\n- `ItemNode` - Furniture, fixtures, appliances\n- `ScanNode` - 3D scan reference\n- `GuideNode` - 2D guide image reference\n\n## Systems\n\nSystems process dirty nodes each frame to update geometry:\n\n- `WallSystem` - Wall geometry with mitering and CSG cutouts\n- `SlabSystem` - Floor polygon generation\n- `CeilingSystem` - Ceiling geometry\n- `RoofSystem` - Roof generation\n- `ItemSystem` - Item positioning on walls/ceilings/floors\n\n## License\n\nMIT\n"
  },
  {
    "path": "packages/core/package.json",
    "content": "{\n  \"name\": \"@pascal-app/core\",\n  \"version\": \"0.3.3\",\n  \"description\": \"Core library for Pascal 3D building editor\",\n  \"type\": \"module\",\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"default\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\",\n    \"README.md\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc --build\",\n    \"dev\": \"tsc --build --watch\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"peerDependencies\": {\n    \"@react-three/drei\": \"^10\",\n    \"@react-three/fiber\": \"^9\",\n    \"react\": \"^18 || ^19\",\n    \"three\": \"^0.182\"\n  },\n  \"dependencies\": {\n    \"dedent\": \"^1.7.1\",\n    \"idb-keyval\": \"^6.2.2\",\n    \"mitt\": \"^3.0.1\",\n    \"nanoid\": \"^5.1.6\",\n    \"three-bvh-csg\": \"^0.0.18\",\n    \"three-mesh-bvh\": \"^0.9.8\",\n    \"zod\": \"^4.3.5\",\n    \"zundo\": \"^2.3.0\",\n    \"zustand\": \"^5\"\n  },\n  \"devDependencies\": {\n    \"@pascal/typescript-config\": \"*\",\n    \"@types/react\": \"^19.2.2\",\n    \"typescript\": \"5.9.3\",\n    \"@types/three\": \"^0.183.0\"\n  },\n  \"keywords\": [\n    \"3d\",\n    \"building\",\n    \"editor\",\n    \"architecture\",\n    \"webgpu\",\n    \"three.js\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/pascalorg/editor.git\",\n    \"directory\": \"packages/core\"\n  },\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/pascalorg/editor/tree/main/packages/core#readme\",\n  \"bugs\": \"https://github.com/pascalorg/editor/issues\"\n}\n"
  },
  {
    "path": "packages/core/src/events/bus.ts",
    "content": "import type { ThreeEvent } from '@react-three/fiber'\nimport mitt from 'mitt'\nimport type {\n  BuildingNode,\n  CeilingNode,\n  DoorNode,\n  ItemNode,\n  LevelNode,\n  RoofNode,\n  RoofSegmentNode,\n  SiteNode,\n  SlabNode,\n  StairNode,\n  StairSegmentNode,\n  WallNode,\n  WindowNode,\n  ZoneNode,\n} from '../schema'\nimport type { AnyNode } from '../schema/types'\n\n// Base event interfaces\nexport interface GridEvent {\n  position: [number, number, number]\n  nativeEvent: ThreeEvent<PointerEvent>\n}\n\nexport interface NodeEvent<T extends AnyNode = AnyNode> {\n  node: T\n  position: [number, number, number]\n  localPosition: [number, number, number]\n  normal?: [number, number, number]\n  stopPropagation: () => void\n  nativeEvent: ThreeEvent<PointerEvent>\n}\n\nexport type WallEvent = NodeEvent<WallNode>\nexport type ItemEvent = NodeEvent<ItemNode>\nexport type SiteEvent = NodeEvent<SiteNode>\nexport type BuildingEvent = NodeEvent<BuildingNode>\nexport type LevelEvent = NodeEvent<LevelNode>\nexport type ZoneEvent = NodeEvent<ZoneNode>\nexport type SlabEvent = NodeEvent<SlabNode>\nexport type CeilingEvent = NodeEvent<CeilingNode>\nexport type RoofEvent = NodeEvent<RoofNode>\nexport type RoofSegmentEvent = NodeEvent<RoofSegmentNode>\nexport type StairEvent = NodeEvent<StairNode>\nexport type StairSegmentEvent = NodeEvent<StairSegmentNode>\nexport type WindowEvent = NodeEvent<WindowNode>\nexport type DoorEvent = NodeEvent<DoorNode>\n\n// Event suffixes - exported for use in hooks\nexport const eventSuffixes = [\n  'click',\n  'move',\n  'enter',\n  'leave',\n  'pointerdown',\n  'pointerup',\n  'context-menu',\n  'double-click',\n] as const\n\nexport type EventSuffix = (typeof eventSuffixes)[number]\n\ntype NodeEvents<T extends string, E> = {\n  [K in `${T}:${EventSuffix}`]: E\n}\n\ntype GridEvents = {\n  [K in `grid:${EventSuffix}`]: GridEvent\n}\n\nexport interface CameraControlEvent {\n  nodeId: AnyNode['id']\n}\n\nexport interface ThumbnailGenerateEvent {\n  projectId: string\n}\n\ntype CameraControlEvents = {\n  'camera-controls:view': CameraControlEvent\n  'camera-controls:focus': CameraControlEvent\n  'camera-controls:capture': CameraControlEvent\n  'camera-controls:top-view': undefined\n  'camera-controls:orbit-cw': undefined\n  'camera-controls:orbit-ccw': undefined\n  'camera-controls:generate-thumbnail': ThumbnailGenerateEvent\n}\n\ntype ToolEvents = {\n  'tool:cancel': undefined\n}\n\ntype PresetEvents = {\n  'preset:generate-thumbnail': { presetId: string; nodeId: string }\n  'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }\n}\n\ntype EditorEvents = GridEvents &\n  NodeEvents<'wall', WallEvent> &\n  NodeEvents<'item', ItemEvent> &\n  NodeEvents<'site', SiteEvent> &\n  NodeEvents<'building', BuildingEvent> &\n  NodeEvents<'level', LevelEvent> &\n  NodeEvents<'zone', ZoneEvent> &\n  NodeEvents<'slab', SlabEvent> &\n  NodeEvents<'ceiling', CeilingEvent> &\n  NodeEvents<'roof', RoofEvent> &\n  NodeEvents<'roof-segment', RoofSegmentEvent> &\n  NodeEvents<'stair', StairEvent> &\n  NodeEvents<'stair-segment', StairSegmentEvent> &\n  NodeEvents<'window', WindowEvent> &\n  NodeEvents<'door', DoorEvent> &\n  CameraControlEvents &\n  ToolEvents &\n  PresetEvents\n\nexport const emitter = mitt<EditorEvents>()\n"
  },
  {
    "path": "packages/core/src/hooks/scene-registry/scene-registry.ts",
    "content": "import { useLayoutEffect } from 'react'\nimport type * as THREE from 'three'\n\nexport const sceneRegistry = {\n  // Master lookup: ID -> Object3D\n  nodes: new Map<string, THREE.Object3D>(),\n\n  // Categorized lookups: Type -> Set of IDs\n  // Using a Set is faster for adding/deleting than an Array\n  byType: {\n    site: new Set<string>(),\n    building: new Set<string>(),\n    ceiling: new Set<string>(),\n    level: new Set<string>(),\n    wall: new Set<string>(),\n    item: new Set<string>(),\n    slab: new Set<string>(),\n    zone: new Set<string>(),\n    roof: new Set<string>(),\n    'roof-segment': new Set<string>(),\n    stair: new Set<string>(),\n    'stair-segment': new Set<string>(),\n    scan: new Set<string>(),\n    guide: new Set<string>(),\n    window: new Set<string>(),\n    door: new Set<string>(),\n  },\n\n  /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */\n  clear() {\n    this.nodes.clear()\n    for (const set of Object.values(this.byType)) {\n      set.clear()\n    }\n  },\n}\n\nexport function useRegistry(\n  id: string,\n  type: keyof typeof sceneRegistry.byType,\n  ref: React.RefObject<THREE.Object3D>,\n) {\n  useLayoutEffect(() => {\n    const obj = ref.current\n    if (!obj) return\n\n    // 1. Add to master map\n    sceneRegistry.nodes.set(id, obj)\n\n    // 2. Add to type-specific set\n    sceneRegistry.byType[type].add(id)\n\n    // 4. Cleanup when component unmounts\n    return () => {\n      sceneRegistry.nodes.delete(id)\n      sceneRegistry.byType[type].delete(id)\n    }\n  }, [id, type, ref])\n}\n"
  },
  {
    "path": "packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts",
    "content": "import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'\nimport { getScaledDimensions } from '../../schema'\nimport { SpatialGrid } from './spatial-grid'\nimport { WallSpatialGrid } from './wall-spatial-grid'\n\n// ============================================================================\n// GEOMETRY HELPERS\n// ============================================================================\n\n/**\n * Point-in-polygon test using ray casting algorithm.\n */\nexport function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {\n  let inside = false\n  const n = polygon.length\n  for (let i = 0, j = n - 1; i < n; j = i++) {\n    const xi = polygon[i]![0],\n      zi = polygon[i]![1]\n    const xj = polygon[j]![0],\n      zj = polygon[j]![1]\n\n    if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {\n      inside = !inside\n    }\n  }\n  return inside\n}\n\n/**\n * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.\n */\nfunction getItemFootprint(\n  position: [number, number, number],\n  dimensions: [number, number, number],\n  rotation: [number, number, number],\n  inset = 0,\n): Array<[number, number]> {\n  const [x, , z] = position\n  const [w, , d] = dimensions\n  const yRot = rotation[1]\n  const halfW = Math.max(0, w / 2 - inset)\n  const halfD = Math.max(0, d / 2 - inset)\n  const cos = Math.cos(yRot)\n  const sin = Math.sin(yRot)\n\n  return [\n    [x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)],\n    [x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)],\n    [x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)],\n    [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)],\n  ]\n}\n\n/**\n * Test if two line segments (a1->a2) and (b1->b2) intersect.\n */\nfunction segmentsIntersect(\n  ax1: number,\n  az1: number,\n  ax2: number,\n  az2: number,\n  bx1: number,\n  bz1: number,\n  bx2: number,\n  bz2: number,\n): boolean {\n  const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>\n    (ax - ox) * (bz - oz) - (az - oz) * (bx - ox)\n\n  const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)\n  const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)\n  const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)\n  const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)\n\n  if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {\n    return true\n  }\n\n  // Collinear touching cases\n  const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>\n    Math.min(px, qx) <= rx &&\n    rx <= Math.max(px, qx) &&\n    Math.min(pz, qz) <= rz &&\n    rz <= Math.max(pz, qz)\n\n  if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true\n  if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true\n  if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true\n  if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true\n\n  return false\n}\n\n/**\n * Test if a line segment intersects any edge of a polygon.\n */\nfunction segmentIntersectsPolygon(\n  sx1: number,\n  sz1: number,\n  sx2: number,\n  sz2: number,\n  polygon: Array<[number, number]>,\n): boolean {\n  const n = polygon.length\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    if (\n      segmentsIntersect(\n        sx1,\n        sz1,\n        sx2,\n        sz2,\n        polygon[i]![0],\n        polygon[i]![1],\n        polygon[j]![0],\n        polygon[j]![1],\n      )\n    ) {\n      return true\n    }\n  }\n  return false\n}\n\n/**\n * Test if an item's footprint overlaps with a polygon.\n * Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect.\n */\nexport function itemOverlapsPolygon(\n  position: [number, number, number],\n  dimensions: [number, number, number],\n  rotation: [number, number, number],\n  polygon: Array<[number, number]>,\n  inset = 0,\n): boolean {\n  const corners = getItemFootprint(position, dimensions, rotation, inset)\n\n  // Check if any item corner is inside the polygon\n  for (const [cx, cz] of corners) {\n    if (pointInPolygon(cx, cz, polygon)) return true\n  }\n\n  // Check if any polygon vertex is inside the item footprint\n  // (handles case where slab is fully inside a large item)\n  for (const [px, pz] of polygon) {\n    if (pointInPolygon(px, pz, corners)) return true\n  }\n\n  // Check if any item edge intersects any polygon edge\n  for (let i = 0; i < 4; i++) {\n    const j = (i + 1) % 4\n    if (\n      segmentIntersectsPolygon(\n        corners[i]![0],\n        corners[i]![1],\n        corners[j]![0],\n        corners[j]![1],\n        polygon,\n      )\n    )\n      return true\n  }\n\n  return false\n}\n\n/**\n * Check if wall segment (a) is substantially on polygon edge segment (b).\n * Returns true only if BOTH endpoints of the wall are on or very close to the edge.\n * This prevents walls that just touch one point from being detected.\n */\nfunction segmentsCollinearAndOverlap(\n  ax1: number,\n  az1: number,\n  ax2: number,\n  az2: number,\n  bx1: number,\n  bz1: number,\n  bx2: number,\n  bz2: number,\n): boolean {\n  const EPSILON = 1e-6\n\n  // Cross product to check collinearity\n  const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1)\n  const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1)\n\n  if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {\n    return false // Not collinear\n  }\n\n  // Check if a point is on segment b\n  const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>\n    Math.min(px, qx) - EPSILON <= rx &&\n    rx <= Math.max(px, qx) + EPSILON &&\n    Math.min(pz, qz) - EPSILON <= rz &&\n    rz <= Math.max(pz, qz) + EPSILON\n\n  // BOTH endpoints of wall (a) must be on edge (b) for substantial overlap\n  const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)\n  const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2)\n\n  return a1OnB && a2OnB\n}\n\n/**\n * Test if a wall segment overlaps with a polygon.\n * A wall is considered to overlap if:\n * - Its midpoint is inside the polygon (wall crosses through)\n * - At least one endpoint is inside (wall partially or fully in slab)\n * - It's collinear with and overlaps a polygon edge (wall on slab boundary)\n *\n * Note: A wall with just one endpoint touching the edge but the rest outside\n * is NOT considered overlapping (adjacent only).\n */\nexport function wallOverlapsPolygon(\n  start: [number, number],\n  end: [number, number],\n  polygon: Array<[number, number]>,\n): boolean {\n  const dx = end[0] - start[0]\n  const dz = end[1] - start[1]\n  const len = Math.sqrt(dx * dx + dz * dz)\n\n  // Nudge endpoint test points a tiny step inward along the wall direction before\n  // testing containment. pointInPolygon (ray casting) produces false positives for\n  // points exactly on polygon vertices or edges — specifically the minimum-z corner\n  // of an axis-aligned polygon returns \"inside\" because the ray hits the opposite\n  // vertical edge exactly at its base. Nudging by 1e-6 m avoids this: a wall that\n  // merely starts at a slab corner and extends outward will have its nudged point\n  // clearly outside, while a wall that genuinely starts inside stays inside.\n  if (len > 1e-10) {\n    const step = Math.min(1e-6, len * 0.01)\n    const nx = (dx / len) * step\n    const nz = (dz / len) * step\n    if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true\n    if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true\n\n    // Also nudge perpendicular to the wall (into the slab interior) for walls that\n    // lie exactly on the slab boundary. The along-wall nudge keeps points on the\n    // boundary where pointInPolygon is unreliable; a perpendicular inward nudge\n    // moves the point clearly inside (or outside) the polygon.\n    // Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.\n    const PERP_STEP = 1e-4\n    const pnx = (-nz / step) * PERP_STEP // perpendicular left\n    const pnz = (nx / step) * PERP_STEP\n    for (const t of [0.25, 0.5, 0.75]) {\n      const bx = start[0] + dx * t\n      const bz = start[1] + dz * t\n      if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true\n      if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true\n    }\n  }\n\n  // Check if midpoint is inside (catches walls crossing through)\n  const midX = (start[0] + end[0]) / 2\n  const midZ = (start[1] + end[1]) / 2\n  if (pointInPolygon(midX, midZ, polygon)) return true\n\n  // Check if the wall is collinear with and overlaps any polygon edge\n  const n = polygon.length\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const [p1x, p1z] = polygon[i]!\n    const [p2x, p2z] = polygon[j]!\n\n    if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {\n      return true\n    }\n  }\n\n  return false\n}\n\nexport class SpatialGridManager {\n  private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid\n  private readonly wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid\n  private readonly walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)\n  private readonly slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)\n  private readonly ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid\n  private readonly ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data\n  private readonly itemCeilingMap = new Map<string, string>() // itemId -> ceilingId (reverse lookup)\n\n  private readonly cellSize: number\n\n  constructor(cellSize = 0.5) {\n    this.cellSize = cellSize\n  }\n\n  private getFloorGrid(levelId: string): SpatialGrid {\n    if (!this.floorGrids.has(levelId)) {\n      this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize }))\n    }\n    return this.floorGrids.get(levelId)!\n  }\n\n  private getWallGrid(levelId: string): WallSpatialGrid {\n    if (!this.wallGrids.has(levelId)) {\n      this.wallGrids.set(levelId, new WallSpatialGrid())\n    }\n    return this.wallGrids.get(levelId)!\n  }\n\n  private getWallLength(wallId: string): number {\n    const wall = this.walls.get(wallId)\n    if (!wall) return 0\n    const dx = wall.end[0] - wall.start[0]\n    const dy = wall.end[1] - wall.start[1]\n    return Math.sqrt(dx * dx + dy * dy)\n  }\n\n  private getWallHeight(wallId: string): number {\n    const wall = this.walls.get(wallId)\n    return wall?.height ?? 2.5 // Default wall height\n  }\n\n  private getCeilingGrid(ceilingId: string): SpatialGrid {\n    if (!this.ceilingGrids.has(ceilingId)) {\n      this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize }))\n    }\n    return this.ceilingGrids.get(ceilingId)!\n  }\n\n  private getSlabMap(levelId: string): Map<string, SlabNode> {\n    if (!this.slabsByLevel.has(levelId)) {\n      this.slabsByLevel.set(levelId, new Map())\n    }\n    return this.slabsByLevel.get(levelId)!\n  }\n\n  // Called when nodes change\n  handleNodeCreated(node: AnyNode, levelId: string) {\n    if (node.type === 'slab') {\n      this.getSlabMap(levelId).set(node.id, node as SlabNode)\n    } else if (node.type === 'ceiling') {\n      this.ceilings.set(node.id, node as CeilingNode)\n    } else if (node.type === 'wall') {\n      const wall = node as WallNode\n      this.walls.set(wall.id, wall)\n    } else if (node.type === 'item') {\n      const item = node as ItemNode\n      if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {\n        // Wall-attached item - use parentId as the wall ID\n        const wallId = item.parentId\n        if (wallId && this.walls.has(wallId)) {\n          const wallLength = this.getWallLength(wallId)\n          if (wallLength > 0) {\n            const [width, height] = getScaledDimensions(item)\n            const halfW = width / wallLength / 2\n            // Calculate t from local X position (position[0] is distance along wall)\n            const t = item.position[0] / wallLength\n            // position[1] is the bottom of the item\n            this.getWallGrid(levelId).insert({\n              itemId: item.id,\n              wallId,\n              tStart: t - halfW,\n              tEnd: t + halfW,\n              yStart: item.position[1],\n              yEnd: item.position[1] + height,\n              attachType: item.asset.attachTo as 'wall' | 'wall-side',\n              side: item.side,\n            })\n          }\n        }\n      } else if (item.asset.attachTo === 'ceiling') {\n        // Ceiling item - use parentId as the ceiling ID\n        const ceilingId = item.parentId\n        if (ceilingId && this.ceilings.has(ceilingId)) {\n          this.getCeilingGrid(ceilingId).insert(\n            item.id,\n            item.position,\n            getScaledDimensions(item),\n            item.rotation,\n          )\n          this.itemCeilingMap.set(item.id, ceilingId)\n        }\n      } else if (!item.asset.attachTo) {\n        // Floor item\n        this.getFloorGrid(levelId).insert(\n          item.id,\n          item.position,\n          getScaledDimensions(item),\n          item.rotation,\n        )\n      }\n    }\n  }\n\n  handleNodeUpdated(node: AnyNode, levelId: string) {\n    if (node.type === 'slab') {\n      this.getSlabMap(levelId).set(node.id, node as SlabNode)\n    } else if (node.type === 'ceiling') {\n      this.ceilings.set(node.id, node as CeilingNode)\n    } else if (node.type === 'wall') {\n      const wall = node as WallNode\n      this.walls.set(wall.id, wall)\n    } else if (node.type === 'item') {\n      const item = node as ItemNode\n      if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {\n        // Remove old placement and re-insert\n        this.getWallGrid(levelId).removeByItemId(item.id)\n        const wallId = item.parentId\n        if (wallId && this.walls.has(wallId)) {\n          const wallLength = this.getWallLength(wallId)\n          if (wallLength > 0) {\n            const [width, height] = getScaledDimensions(item)\n            const halfW = width / wallLength / 2\n            // Calculate t from local X position (position[0] is distance along wall)\n            const t = item.position[0] / wallLength\n            // position[1] is the bottom of the item\n            this.getWallGrid(levelId).insert({\n              itemId: item.id,\n              wallId,\n              tStart: t - halfW,\n              tEnd: t + halfW,\n              yStart: item.position[1],\n              yEnd: item.position[1] + height,\n              attachType: item.asset.attachTo as 'wall' | 'wall-side',\n              side: item.side,\n            })\n          }\n        }\n      } else if (item.asset.attachTo === 'ceiling') {\n        // Remove from old ceiling grid\n        const oldCeilingId = this.itemCeilingMap.get(item.id)\n        if (oldCeilingId) {\n          this.getCeilingGrid(oldCeilingId).remove(item.id)\n          this.itemCeilingMap.delete(item.id)\n        }\n        // Insert into new ceiling grid\n        const ceilingId = item.parentId\n        if (ceilingId && this.ceilings.has(ceilingId)) {\n          this.getCeilingGrid(ceilingId).insert(\n            item.id,\n            item.position,\n            getScaledDimensions(item),\n            item.rotation,\n          )\n          this.itemCeilingMap.set(item.id, ceilingId)\n        }\n      } else if (!item.asset.attachTo) {\n        this.getFloorGrid(levelId).update(\n          item.id,\n          item.position,\n          getScaledDimensions(item),\n          item.rotation,\n        )\n      }\n    }\n  }\n\n  handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {\n    if (nodeType === 'slab') {\n      this.getSlabMap(levelId).delete(nodeId)\n    } else if (nodeType === 'ceiling') {\n      this.ceilings.delete(nodeId)\n      this.ceilingGrids.delete(nodeId)\n    } else if (nodeType === 'wall') {\n      this.walls.delete(nodeId)\n      // Remove all items attached to this wall from the spatial grid\n      const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)\n      return removedItemIds // Caller can use this to delete the items from scene\n    } else if (nodeType === 'item') {\n      this.getFloorGrid(levelId).remove(nodeId)\n      this.getWallGrid(levelId).removeByItemId(nodeId)\n      // Also clean up ceiling grid\n      const oldCeilingId = this.itemCeilingMap.get(nodeId)\n      if (oldCeilingId) {\n        this.getCeilingGrid(oldCeilingId).remove(nodeId)\n        this.itemCeilingMap.delete(nodeId)\n      }\n    }\n    return []\n  }\n\n  // Query methods\n  canPlaceOnFloor(\n    levelId: string,\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n    ignoreIds?: string[],\n  ) {\n    const grid = this.getFloorGrid(levelId)\n    return grid.canPlace(position, dimensions, rotation, ignoreIds)\n  }\n\n  /**\n   * Check if an item can be placed on a wall\n   * @param levelId - the level containing the wall\n   * @param wallId - the wall to check\n   * @param localX - X position in wall-local space (distance from wall start)\n   * @param localY - Y position (height from floor)\n   * @param dimensions - item dimensions [width, height, depth]\n   * @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side)\n   * @param side - which side for 'wall-side' items\n   * @param ignoreIds - item IDs to ignore in collision check\n   */\n  canPlaceOnWall(\n    levelId: string,\n    wallId: string,\n    localX: number,\n    localY: number,\n    dimensions: [number, number, number],\n    attachType: 'wall' | 'wall-side' = 'wall',\n    side?: 'front' | 'back',\n    ignoreIds?: string[],\n  ) {\n    const wallLength = this.getWallLength(wallId)\n    if (wallLength === 0) {\n      return { valid: false, conflictIds: [] }\n    }\n    const wallHeight = this.getWallHeight(wallId)\n    // Convert local X position to parametric t (0-1)\n    const tCenter = localX / wallLength\n    const [itemWidth, itemHeight] = dimensions\n    return this.getWallGrid(levelId).canPlaceOnWall(\n      wallId,\n      wallLength,\n      wallHeight,\n      tCenter,\n      itemWidth,\n      localY,\n      itemHeight,\n      attachType,\n      side,\n      ignoreIds,\n    )\n  }\n\n  getWallForItem(levelId: string, itemId: string): string | undefined {\n    return this.getWallGrid(levelId).getWallForItem(itemId)\n  }\n\n  /**\n   * Get the total slab elevation at a given (x, z) position on a level.\n   * Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0.\n   */\n  getSlabElevationAt(levelId: string, x: number, z: number): number {\n    const slabMap = this.slabsByLevel.get(levelId)\n    if (!slabMap) return 0\n\n    let maxElevation = 0\n    for (const slab of slabMap.values()) {\n      if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {\n        // Check if point is in any hole\n        let inHole = false\n        const holes = slab.holes || []\n        for (const hole of holes) {\n          if (hole.length >= 3 && pointInPolygon(x, z, hole)) {\n            inHole = true\n            break\n          }\n        }\n\n        if (!inHole) {\n          const elevation = slab.elevation ?? 0.05\n          if (elevation > maxElevation) {\n            maxElevation = elevation\n          }\n        }\n      }\n    }\n    return maxElevation\n  }\n\n  /**\n   * Get the slab elevation for an item using its full footprint (bounding box).\n   * Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).\n   * Returns the highest overlapping slab elevation, or 0 if none.\n   */\n  getSlabElevationForItem(\n    levelId: string,\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n  ): number {\n    const slabMap = this.slabsByLevel.get(levelId)\n    if (!slabMap) return 0\n\n    let maxElevation = Number.NEGATIVE_INFINITY\n    for (const slab of slabMap.values()) {\n      if (\n        slab.polygon.length >= 3 &&\n        itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)\n      ) {\n        // Check if item is entirely within a hole (if so, ignore this slab)\n        // We consider it entirely in a hole if the item center is in the hole\n        let inHole = false\n        const [cx, , cz] = position\n        const holes = slab.holes || []\n        for (const hole of holes) {\n          if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {\n            inHole = true\n            break\n          }\n        }\n\n        if (!inHole) {\n          const elevation = slab.elevation ?? 0.05\n          if (elevation > maxElevation) {\n            maxElevation = elevation\n          }\n        }\n      }\n    }\n    return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation\n  }\n\n  /**\n   * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).\n   * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).\n   * Returns the highest slab elevation found, or 0 if none.\n   */\n  getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {\n    const slabMap = this.slabsByLevel.get(levelId)\n    if (!slabMap) return 0\n\n    let maxElevation = Number.NEGATIVE_INFINITY\n    for (const slab of slabMap.values()) {\n      if (slab.polygon.length < 3) continue\n      if (!wallOverlapsPolygon(start, end, slab.polygon)) continue\n\n      const holes = slab.holes || []\n      if (holes.length === 0) {\n        // No holes: wall is on this slab\n        const elevation = slab.elevation ?? 0.05\n        if (elevation > maxElevation) maxElevation = elevation\n        continue\n      }\n\n      // Sample multiple points along the wall to check whether any portion lies on\n      // solid slab (not inside any hole). Checking only the midpoint fails when the\n      // midpoint falls in a staircase hole but the wall's endpoints are on solid slab.\n      const dx = end[0] - start[0]\n      const dz = end[1] - start[1]\n      let hasValidPoint = false\n      for (const t of [0, 0.25, 0.5, 0.75, 1]) {\n        const px = start[0] + dx * t\n        const pz = start[1] + dz * t\n        let inHole = false\n        for (const hole of holes) {\n          if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {\n            inHole = true\n            break\n          }\n        }\n        if (!inHole) {\n          hasValidPoint = true\n          break\n        }\n      }\n\n      if (hasValidPoint) {\n        const elevation = slab.elevation ?? 0.05\n        if (elevation > maxElevation) maxElevation = elevation\n      }\n    }\n    return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation\n  }\n\n  /**\n   * Check if an item can be placed on a ceiling.\n   * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items.\n   */\n  canPlaceOnCeiling(\n    ceilingId: string,\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n    ignoreIds?: string[],\n  ): { valid: boolean; conflictIds: string[] } {\n    const ceiling = this.ceilings.get(ceilingId)\n    if (!ceiling || ceiling.polygon.length < 3) {\n      return { valid: false, conflictIds: [] }\n    }\n\n    // Check that the item footprint is entirely within the ceiling polygon\n    const corners = getItemFootprint(position, dimensions, rotation)\n    for (const [cx, cz] of corners) {\n      if (!pointInPolygon(cx, cz, ceiling.polygon)) {\n        return { valid: false, conflictIds: [] }\n      }\n    }\n\n    // Check if item center is in any hole (if so, it cannot be placed)\n    const [centerX, , centerZ] = position\n    const holes = ceiling.holes || []\n    for (const hole of holes) {\n      if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) {\n        return { valid: false, conflictIds: [] }\n      }\n    }\n\n    // Check for overlaps with other ceiling items\n    return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)\n  }\n\n  clearLevel(levelId: string) {\n    this.floorGrids.delete(levelId)\n    this.wallGrids.delete(levelId)\n    this.slabsByLevel.delete(levelId)\n  }\n\n  clear() {\n    this.floorGrids.clear()\n    this.wallGrids.clear()\n    this.walls.clear()\n    this.slabsByLevel.clear()\n    this.ceilingGrids.clear()\n    this.ceilings.clear()\n    this.itemCeilingMap.clear()\n  }\n}\n\n// Singleton instance\nexport const spatialGridManager = new SpatialGridManager()\n"
  },
  {
    "path": "packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts",
    "content": "import {\n  type AnyNode,\n  type AnyNodeId,\n  getScaledDimensions,\n  type ItemNode,\n  type SlabNode,\n  type WallNode,\n} from '../../schema'\nimport useScene from '../../store/use-scene'\nimport {\n  itemOverlapsPolygon,\n  spatialGridManager,\n  wallOverlapsPolygon,\n} from './spatial-grid-manager'\n\nexport function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {\n  // If the node itself is a level\n  if (node.type === 'level') return node.id\n\n  // Walk up parent chain to find level\n  // This assumes you track parentId or can derive it\n  let current: AnyNode | undefined = node\n\n  while (current) {\n    if (current.type === 'level') return current.id\n    // Find parent (you might need to add parentId to your schema or derive it)\n    if (current.parentId) {\n      current = nodes[current.parentId]\n    } else {\n      current = undefined\n    }\n  }\n\n  return 'default' // fallback for orphaned items\n}\n\n// Call this once at app initialization\nexport function initSpatialGridSync() {\n  const store = useScene\n  // 1. Initial sync - process all existing nodes\n  const state = store.getState()\n  for (const node of Object.values(state.nodes)) {\n    const levelId = resolveLevelId(node, state.nodes)\n    spatialGridManager.handleNodeCreated(node, levelId)\n  }\n\n  // 2. Then subscribe to future changes\n  const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)\n\n  // Subscribe to all changes\n  store.subscribe((state, prevState) => {\n    // Detect added nodes\n    for (const [id, node] of Object.entries(state.nodes)) {\n      if (!prevState.nodes[id as AnyNode['id']]) {\n        const levelId = resolveLevelId(node, state.nodes)\n        spatialGridManager.handleNodeCreated(node, levelId)\n\n        // When a slab is added, mark overlapping items/walls dirty\n        if (node.type === 'slab') {\n          markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)\n        }\n      }\n    }\n\n    // Detect removed nodes\n    for (const [id, node] of Object.entries(prevState.nodes)) {\n      if (!state.nodes[id as AnyNode['id']]) {\n        const levelId = resolveLevelId(node, prevState.nodes)\n        spatialGridManager.handleNodeDeleted(id, node.type, levelId)\n\n        // When a slab is removed, mark items/walls that were on it dirty (using current state)\n        if (node.type === 'slab') {\n          markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)\n        }\n      }\n    }\n\n    // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes)\n    for (const [id, node] of Object.entries(state.nodes)) {\n      const prev = prevState.nodes[id as AnyNode['id']]\n      if (!prev) continue\n\n      if (node.type === 'item' && prev.type === 'item') {\n        if (\n          !(\n            arraysEqual(node.position, prev.position) &&\n            arraysEqual(node.rotation, prev.rotation) &&\n            arraysEqual(node.scale, prev.scale)\n          ) ||\n          node.parentId !== prev.parentId ||\n          node.side !== prev.side\n        ) {\n          const levelId = resolveLevelId(node, state.nodes)\n          spatialGridManager.handleNodeUpdated(node, levelId)\n          // Scale changes affect footprint size — mark dirty so slab elevation recalculates\n          if (!arraysEqual(node.scale, prev.scale)) {\n            markDirty(node.id)\n          }\n        }\n      } else if (node.type === 'slab' && prev.type === 'slab') {\n        if (\n          node.polygon !== prev.polygon ||\n          node.elevation !== prev.elevation ||\n          node.holes !== prev.holes\n        ) {\n          const levelId = resolveLevelId(node, state.nodes)\n          spatialGridManager.handleNodeUpdated(node, levelId)\n\n          // Mark nodes overlapping old polygon and new polygon as dirty\n          markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)\n          markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)\n        }\n      }\n    }\n  })\n}\n\nfunction arraysEqual(a: number[], b: number[]): boolean {\n  return a.length === b.length && a.every((v, i) => v === b[i])\n}\n\n/**\n * Mark all floor items and walls that overlap a slab polygon as dirty.\n */\nfunction markNodesOverlappingSlab(\n  slab: SlabNode,\n  nodes: Record<string, AnyNode>,\n  markDirty: (id: AnyNodeId) => void,\n) {\n  if (slab.polygon.length < 3) return\n  const slabLevelId = resolveLevelId(slab, nodes)\n\n  for (const node of Object.values(nodes)) {\n    if (node.type === 'item') {\n      const item = node as ItemNode\n      // Only floor items are affected by slabs\n      if (item.asset.attachTo) continue\n      if (resolveLevelId(node, nodes) !== slabLevelId) continue\n      if (\n        itemOverlapsPolygon(\n          item.position,\n          getScaledDimensions(item),\n          item.rotation,\n          slab.polygon,\n          0.01,\n        )\n      ) {\n        markDirty(node.id)\n      }\n    } else if (node.type === 'wall') {\n      const wall = node as WallNode\n      if (resolveLevelId(node, nodes) !== slabLevelId) continue\n      if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {\n        markDirty(node.id)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/core/src/hooks/spatial-grid/spatial-grid.ts",
    "content": "type CellKey = `${number},${number}`\n\ninterface GridCell {\n  itemIds: Set<string>\n}\n\ninterface SpatialGridConfig {\n  cellSize: number // e.g., 0.5 meters = Sims-style half-tile\n}\n\nexport class SpatialGrid {\n  private readonly cells = new Map<CellKey, GridCell>()\n  private readonly itemCells = new Map<string, Set<CellKey>>() // reverse lookup\n\n  private readonly config: SpatialGridConfig\n\n  constructor(config: SpatialGridConfig) {\n    this.config = config\n  }\n\n  private posToCell(x: number, z: number): [number, number] {\n    return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)]\n  }\n\n  private cellKey(cx: number, cz: number): CellKey {\n    return `${cx},${cz}`\n  }\n\n  // Get all cells an item occupies based on its AABB\n  private getItemCells(\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n  ): CellKey[] {\n    // Simplified: axis-aligned bounding box\n    // For full rotation support, compute rotated corners\n    const [x, , z] = position\n    const [w, , d] = dimensions\n    const yRot = rotation[1] // Y-axis rotation\n\n    // Compute rotated footprint (simplified for 90° increments)\n    const cos = Math.abs(Math.cos(yRot))\n    const sin = Math.abs(Math.sin(yRot))\n    const rotatedW = w * cos + d * sin\n    const rotatedD = w * sin + d * cos\n\n    const minX = x - rotatedW / 2\n    const maxX = x + rotatedW / 2\n    const minZ = z - rotatedD / 2\n    const maxZ = z + rotatedD / 2\n\n    const [minCx, minCz] = this.posToCell(minX, minZ)\n    // Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap\n    // This allows adjacent items (touching but not overlapping) to not conflict\n    const epsilon = 1e-6\n    const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon)\n\n    const keys: CellKey[] = []\n    for (let cx = minCx; cx <= maxCx; cx++) {\n      for (let cz = minCz; cz <= maxCz; cz++) {\n        keys.push(this.cellKey(cx, cz))\n      }\n    }\n    return keys\n  }\n\n  // Register an item\n  insert(\n    itemId: string,\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n  ) {\n    const cellKeys = this.getItemCells(position, dimensions, rotation)\n\n    this.itemCells.set(itemId, new Set(cellKeys))\n\n    for (const key of cellKeys) {\n      if (!this.cells.has(key)) {\n        this.cells.set(key, { itemIds: new Set() })\n      }\n      this.cells.get(key)?.itemIds.add(itemId)\n    }\n  }\n\n  // Remove an item\n  remove(itemId: string) {\n    const cellKeys = this.itemCells.get(itemId)\n    if (!cellKeys) return\n\n    for (const key of cellKeys) {\n      const cell = this.cells.get(key)\n      if (cell) {\n        cell.itemIds.delete(itemId)\n        if (cell.itemIds.size === 0) {\n          this.cells.delete(key)\n        }\n      }\n    }\n    this.itemCells.delete(itemId)\n  }\n\n  // Update = remove + insert\n  update(\n    itemId: string,\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n  ) {\n    this.remove(itemId)\n    this.insert(itemId, position, dimensions, rotation)\n  }\n\n  // Query: is this placement valid?\n  canPlace(\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n    ignoreIds: string[] = [],\n  ): { valid: boolean; conflictIds: string[] } {\n    const cellKeys = this.getItemCells(position, dimensions, rotation)\n    const ignoreSet = new Set(ignoreIds)\n    const conflicts = new Set<string>()\n\n    for (const key of cellKeys) {\n      const cell = this.cells.get(key)\n      if (cell) {\n        for (const id of cell.itemIds) {\n          if (!ignoreSet.has(id)) {\n            conflicts.add(id)\n          }\n        }\n      }\n    }\n\n    return {\n      valid: conflicts.size === 0,\n      conflictIds: [...conflicts],\n    }\n  }\n\n  // Query: get all items near a point (for snapping, selection, etc.)\n  queryRadius(x: number, z: number, radius: number): string[] {\n    const cellRadius = Math.ceil(radius / this.config.cellSize)\n    const [cx, cz] = this.posToCell(x, z)\n    const found = new Set<string>()\n\n    for (let dx = -cellRadius; dx <= cellRadius; dx++) {\n      for (let dz = -cellRadius; dz <= cellRadius; dz++) {\n        const cell = this.cells.get(this.cellKey(cx + dx, cz + dz))\n        if (cell) {\n          for (const id of cell.itemIds) {\n            found.add(id)\n          }\n        }\n      }\n    }\n    return [...found]\n  }\n\n  getItemCount(): number {\n    return this.itemCells.size\n  }\n}\n"
  },
  {
    "path": "packages/core/src/hooks/spatial-grid/use-spatial-query.ts",
    "content": "import { useCallback } from 'react'\nimport type { CeilingNode, LevelNode, WallNode } from '../../schema'\nimport { spatialGridManager } from './spatial-grid-manager'\n\nexport function useSpatialQuery() {\n  const canPlaceOnFloor = useCallback(\n    (\n      levelId: LevelNode['id'],\n      position: [number, number, number],\n      dimensions: [number, number, number],\n      rotation: [number, number, number],\n      ignoreIds?: string[],\n    ) => {\n      return spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds)\n    },\n    [],\n  )\n\n  const canPlaceOnWall = useCallback(\n    (\n      levelId: LevelNode['id'],\n      wallId: WallNode['id'],\n      localX: number,\n      localY: number,\n      dimensions: [number, number, number],\n      attachType: 'wall' | 'wall-side' = 'wall',\n      side?: 'front' | 'back',\n      ignoreIds?: string[],\n    ) => {\n      return spatialGridManager.canPlaceOnWall(\n        levelId,\n        wallId,\n        localX,\n        localY,\n        dimensions,\n        attachType,\n        side,\n        ignoreIds,\n      )\n    },\n    [],\n  )\n\n  const canPlaceOnCeiling = useCallback(\n    (\n      ceilingId: CeilingNode['id'],\n      position: [number, number, number],\n      dimensions: [number, number, number],\n      rotation: [number, number, number],\n      ignoreIds?: string[],\n    ) => {\n      return spatialGridManager.canPlaceOnCeiling(\n        ceilingId,\n        position,\n        dimensions,\n        rotation,\n        ignoreIds,\n      )\n    },\n    [],\n  )\n\n  return { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }\n}\n"
  },
  {
    "path": "packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts",
    "content": "type WallSide = 'front' | 'back'\ntype AttachType = 'wall' | 'wall-side'\n\n// Small tolerance for floating point comparison to allow adjacent items\nconst EPSILON = 0.001\n\n// Margin from ceiling/floor when auto-snapping items\nconst AUTO_SNAP_MARGIN = 0.05\n\ninterface WallItemPlacement {\n  itemId: string\n  wallId: string\n  tStart: number // 0-1 parametric position along wall\n  tEnd: number\n  yStart: number // height range\n  yEnd: number\n  attachType?: AttachType // 'wall' blocks both sides, 'wall-side' blocks one side (undefined = 'wall' for legacy)\n  side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall')\n}\n\n/**\n * Auto-adjust Y position to fit item within wall bounds\n * Returns the adjusted Y position (bottom of item)\n */\nfunction autoAdjustYPosition(\n  yBottom: number,\n  itemHeight: number,\n  wallHeight: number,\n): { adjustedY: number; wasAdjusted: boolean } {\n  const yTop = yBottom + itemHeight\n\n  // If fits perfectly, no adjustment needed\n  if (yBottom >= 0 && yTop <= wallHeight) {\n    return { adjustedY: yBottom, wasAdjusted: false }\n  }\n\n  // If too high (top exceeds wall height), snap down from ceiling\n  if (yTop > wallHeight) {\n    const adjustedY = wallHeight - itemHeight - AUTO_SNAP_MARGIN\n    return { adjustedY: Math.max(0, adjustedY), wasAdjusted: true }\n  }\n\n  // If too low (bottom below floor), snap up from floor\n  if (yBottom < 0) {\n    return { adjustedY: AUTO_SNAP_MARGIN, wasAdjusted: true }\n  }\n\n  return { adjustedY: yBottom, wasAdjusted: false }\n}\n\nexport class WallSpatialGrid {\n  private readonly wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements\n  private readonly itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)\n\n  /**\n   * Check if an item can be placed on a wall with auto-adjustment for vertical position\n   * @param wallId - The wall to place on\n   * @param wallLength - Length of the wall\n   * @param wallHeight - Height of the wall\n   * @param tCenter - Parametric center position (0-1) along wall\n   * @param itemWidth - Width of the item\n   * @param yBottom - Bottom Y position of the item\n   * @param itemHeight - Height of the item\n   * @param attachType - 'wall' (blocks both sides) or 'wall-side' (blocks one side)\n   * @param side - Which side for 'wall-side' items\n   * @param ignoreIds - Item IDs to ignore in conflict check\n   * @returns Validation result with auto-adjusted Y position if needed\n   */\n  canPlaceOnWall(\n    wallId: string,\n    wallLength: number,\n    wallHeight: number,\n    tCenter: number,\n    itemWidth: number,\n    yBottom: number,\n    itemHeight: number,\n    attachType: AttachType = 'wall',\n    side?: WallSide,\n    ignoreIds: string[] = [],\n  ): { valid: boolean; conflictIds: string[]; adjustedY: number; wasAdjusted: boolean } {\n    const halfW = itemWidth / wallLength / 2\n    const tStart = tCenter - halfW\n    const tEnd = tCenter + halfW\n\n    // Check horizontal boundaries (still reject if item exceeds wall width)\n    if (tStart < 0 || tEnd > 1) {\n      return { valid: false, conflictIds: [], adjustedY: yBottom, wasAdjusted: false }\n    }\n\n    // Auto-adjust vertical position to fit within wall bounds\n    const { adjustedY, wasAdjusted } = autoAdjustYPosition(yBottom, itemHeight, wallHeight)\n    const yStart = adjustedY\n    const yEnd = adjustedY + itemHeight\n\n    const existing = this.wallItems.get(wallId) ?? []\n    const ignoreSet = new Set(ignoreIds)\n    const conflicts: string[] = []\n\n    for (const placement of existing) {\n      if (ignoreSet.has(placement.itemId)) continue\n\n      // Use EPSILON tolerance to allow items to be exactly adjacent\n      const tOverlap = tStart < placement.tEnd - EPSILON && tEnd > placement.tStart + EPSILON\n      const yOverlap = yStart < placement.yEnd - EPSILON && yEnd > placement.yStart + EPSILON\n\n      if (tOverlap && yOverlap) {\n        // Check side conflicts based on attach types\n        const hasConflict = this.checkSideConflict(attachType, side, placement)\n        if (hasConflict) {\n          conflicts.push(placement.itemId)\n        }\n      }\n    }\n\n    return { valid: conflicts.length === 0, conflictIds: conflicts, adjustedY, wasAdjusted }\n  }\n\n  /**\n   * Check if two items conflict based on their attach types and sides\n   * - 'wall' items block both sides, so they conflict with everything\n   * - 'wall-side' items only conflict if they're on the same side or if the other is a 'wall' item\n   */\n  private checkSideConflict(\n    newAttachType: AttachType,\n    newSide: WallSide | undefined,\n    existing: WallItemPlacement,\n  ): boolean {\n    // Treat undefined/legacy attachType as 'wall' (blocks both sides)\n    const existingAttachType = existing.attachType ?? 'wall'\n\n    // If new item is 'wall' type, it conflicts with everything (needs both sides)\n    if (newAttachType === 'wall') {\n      return true\n    }\n\n    // If existing item is 'wall' type, it blocks both sides\n    if (existingAttachType === 'wall') {\n      return true\n    }\n\n    // Both are 'wall-side' - only conflict if they're on the same side\n    // If either side is undefined, be conservative and assume conflict\n    if (!(newSide && existing.side)) {\n      return true\n    }\n    return newSide === existing.side\n  }\n\n  insert(placement: WallItemPlacement) {\n    const { wallId, itemId } = placement\n\n    if (!this.wallItems.has(wallId)) {\n      this.wallItems.set(wallId, [])\n    }\n    this.wallItems.get(wallId)?.push(placement)\n    this.itemToWall.set(itemId, wallId)\n  }\n\n  remove(wallId: string, itemId: string) {\n    const items = this.wallItems.get(wallId)\n    if (items) {\n      const idx = items.findIndex((p) => p.itemId === itemId)\n      if (idx !== -1) items.splice(idx, 1)\n    }\n    this.itemToWall.delete(itemId)\n  }\n\n  removeByItemId(itemId: string) {\n    const wallId = this.itemToWall.get(itemId)\n    if (wallId) {\n      this.remove(wallId, itemId)\n    }\n  }\n\n  // Useful for when a wall is deleted - remove all items on it\n  removeWall(wallId: string): string[] {\n    const items = this.wallItems.get(wallId) ?? []\n    const removedIds = items.map((p) => p.itemId)\n\n    for (const itemId of removedIds) {\n      this.itemToWall.delete(itemId)\n    }\n    this.wallItems.delete(wallId)\n\n    return removedIds // Return removed item IDs in case you need to delete them from scene\n  }\n\n  // Get which wall an item is on\n  getWallForItem(itemId: string): string | undefined {\n    return this.itemToWall.get(itemId)\n  }\n\n  clear() {\n    this.wallItems.clear()\n    this.itemToWall.clear()\n  }\n}\n"
  },
  {
    "path": "packages/core/src/index.ts",
    "content": "// Store\n\nexport type {\n  BuildingEvent,\n  CameraControlEvent,\n  CeilingEvent,\n  DoorEvent,\n  EventSuffix,\n  GridEvent,\n  ItemEvent,\n  LevelEvent,\n  NodeEvent,\n  RoofEvent,\n  RoofSegmentEvent,\n  SiteEvent,\n  SlabEvent,\n  StairEvent,\n  StairSegmentEvent,\n  WallEvent,\n  WindowEvent,\n  ZoneEvent,\n} from './events/bus'\n// Events\nexport { emitter, eventSuffixes } from './events/bus'\n// Hooks\nexport {\n  sceneRegistry,\n  useRegistry,\n} from './hooks/scene-registry/scene-registry'\nexport { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'\nexport {\n  initSpatialGridSync,\n  resolveLevelId,\n} from './hooks/spatial-grid/spatial-grid-sync'\nexport { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'\n// Asset storage\nexport { loadAssetUrl, saveAsset } from './lib/asset-storage'\n// Space detection\nexport {\n  detectSpacesForLevel,\n  initSpaceDetectionSync,\n  type Space,\n  wallTouchesOthers,\n} from './lib/space-detection'\n// Schema\nexport * from './schema'\nexport {\n  type ControlValue,\n  type ItemInteractiveState,\n  useInteractive,\n} from './store/use-interactive'\nexport { clearSceneHistory, default as useScene } from './store/use-scene'\n// Systems\nexport { CeilingSystem } from './systems/ceiling/ceiling-system'\nexport { DoorSystem } from './systems/door/door-system'\nexport { ItemSystem } from './systems/item/item-system'\nexport { RoofSystem } from './systems/roof/roof-system'\nexport { SlabSystem } from './systems/slab/slab-system'\nexport { StairSystem } from './systems/stair/stair-system'\nexport {\n  DEFAULT_WALL_HEIGHT,\n  DEFAULT_WALL_THICKNESS,\n  getWallPlanFootprint,\n  getWallThickness,\n} from './systems/wall/wall-footprint'\nexport {\n  calculateLevelMiters,\n  type Point2D,\n  pointToKey,\n  type WallMiterData,\n} from './systems/wall/wall-mitering'\nexport { WallSystem } from './systems/wall/wall-system'\nexport { WindowSystem } from './systems/window/window-system'\nexport { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'\nexport { isObject } from './utils/types'\n"
  },
  {
    "path": "packages/core/src/lib/asset-storage.ts",
    "content": "import { get, set } from 'idb-keyval'\n\nexport const ASSET_PREFIX = 'asset_data:'\n\n// Cache for active object URLs to prevent leaks and flickering\nconst urlCache = new Map<string, string>()\n\n/**\n * Save a file to IndexedDB and return a custom protocol URL\n */\nexport async function saveAsset(file: File): Promise<string> {\n  const id = crypto.randomUUID()\n  await set(`${ASSET_PREFIX}${id}`, file)\n  return `asset://${id}`\n}\n\n/**\n * Load a file from IndexedDB and return an object URL\n * If the URL is not a custom protocol URL, return it as is\n */\nexport async function loadAssetUrl(url: string): Promise<string | null> {\n  if (!url) return null\n\n  // If it's already a blob or http URL, return as is\n  if (url.startsWith('blob:') || url.startsWith('http')) {\n    return url\n  }\n\n  // Handle our custom asset protocol\n  if (url.startsWith('asset://')) {\n    const id = url.replace('asset://', '')\n\n    // Check cache first\n    if (urlCache.has(id)) {\n      return urlCache.get(id)!\n    }\n\n    try {\n      const file = await get<File | Blob>(`${ASSET_PREFIX}${id}`)\n      if (!file) {\n        console.warn(`Asset not found: ${id}`)\n        return null\n      }\n      const objectUrl = URL.createObjectURL(file)\n      urlCache.set(id, objectUrl)\n      return objectUrl\n    } catch (error) {\n      console.error('Failed to load asset:', error)\n      return null\n    }\n  }\n\n  // Legacy data URLs are returned as is\n  return url\n}\n"
  },
  {
    "path": "packages/core/src/lib/space-detection.ts",
    "content": "import type { WallNode } from '../schema'\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\nexport type Space = {\n  id: string\n  levelId: string\n  polygon: Array<[number, number]>\n  wallIds: string[]\n  isExterior: boolean\n}\n\n// ============================================================================\n// SYNC INITIALIZATION\n// ============================================================================\n\n/**\n * Initializes space detection sync with scene and editor stores\n * Call this once during app initialization\n */\nexport function initSpaceDetectionSync(\n  sceneStore: any, // useScene store\n  editorStore: any, // useEditor store\n): () => void {\n  const prevWallsByLevel = new Map<string, Set<string>>()\n  let isProcessing = false // Prevent re-entrant calls\n\n  // Subscribe to scene changes (standard Zustand subscribe, not selector-based)\n  const unsubscribe = sceneStore.subscribe((state: any) => {\n    // Skip if already processing to avoid infinite loops\n    if (isProcessing) return\n\n    const nodes = state.nodes\n    const currentWallsByLevel = new Map<string, Set<string>>()\n\n    // Group walls by level\n    for (const node of Object.values(nodes)) {\n      if ((node as any).type === 'wall' && (node as any).parentId) {\n        const levelId = (node as any).parentId\n        if (!currentWallsByLevel.has(levelId)) {\n          currentWallsByLevel.set(levelId, new Set())\n        }\n        currentWallsByLevel.get(levelId)?.add((node as any).id)\n      }\n    }\n\n    // Check each level for changes\n    const levelsToUpdate = new Set<string>()\n\n    // Check for new walls (created)\n    for (const [levelId, wallIds] of currentWallsByLevel.entries()) {\n      const prevWallIds = prevWallsByLevel.get(levelId)\n\n      if (!prevWallIds) {\n        // New level with walls - run detection if there are multiple walls\n        if (wallIds.size > 1) {\n          levelsToUpdate.add(levelId)\n        }\n        continue\n      }\n\n      // Find newly added walls\n      for (const wallId of wallIds) {\n        if (!prevWallIds.has(wallId)) {\n          // Wall was added - check if it touches other walls\n          const wall = nodes[wallId as keyof typeof nodes] as WallNode\n          const otherWalls = Array.from(wallIds)\n            .filter((id) => id !== wallId)\n            .map((id) => nodes[id as keyof typeof nodes] as WallNode)\n            .filter(Boolean)\n\n          if (wallTouchesOthers(wall, otherWalls)) {\n            levelsToUpdate.add(levelId)\n            break\n          }\n        }\n      }\n    }\n\n    // Check for deleted walls\n    for (const [levelId, prevWallIds] of prevWallsByLevel.entries()) {\n      const currentWallIds = currentWallsByLevel.get(levelId)\n\n      if (!currentWallIds) {\n        // All walls deleted from level - clear spaces\n        if (prevWallIds.size > 0) {\n          levelsToUpdate.add(levelId)\n        }\n        continue\n      }\n\n      // Check if any walls were deleted\n      for (const wallId of prevWallIds) {\n        if (!currentWallIds.has(wallId)) {\n          // Wall was deleted - run detection\n          levelsToUpdate.add(levelId)\n          break\n        }\n      }\n    }\n\n    // Run detection for affected levels\n    if (levelsToUpdate.size > 0) {\n      isProcessing = true\n      sceneStore.temporal.getState().pause()\n      try {\n        runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes)\n      } finally {\n        sceneStore.temporal.getState().resume()\n        isProcessing = false\n      }\n    }\n\n    // Update previous walls reference\n    prevWallsByLevel.clear()\n    for (const [levelId, wallIds] of currentWallsByLevel.entries()) {\n      prevWallsByLevel.set(levelId, wallIds)\n    }\n  })\n\n  return unsubscribe\n}\n\n/**\n * Runs space detection for the given levels\n * Updates wall nodes and editor spaces\n */\nfunction runSpaceDetection(\n  levelIds: string[],\n  sceneStore: any,\n  editorStore: any,\n  nodes: any,\n): void {\n  const { updateNode } = sceneStore.getState()\n  const { setSpaces } = editorStore.getState()\n\n  const allSpaces: Record<string, any> = {}\n\n  for (const levelId of levelIds) {\n    // Get walls for this level\n    const walls = Object.values(nodes).filter(\n      (node: any) => node.type === 'wall' && node.parentId === levelId,\n    ) as WallNode[]\n\n    if (walls.length === 0) {\n      // No walls - clear any spaces for this level\n      continue\n    }\n\n    // Run detection\n    const { wallUpdates, spaces } = detectSpacesForLevel(levelId, walls)\n\n    // Update wall nodes (only if values changed to avoid infinite loop)\n    for (const update of wallUpdates) {\n      const wall = nodes[update.wallId as keyof typeof nodes] as WallNode\n      if (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) {\n        updateNode(update.wallId as any, {\n          frontSide: update.frontSide,\n          backSide: update.backSide,\n        })\n      }\n    }\n\n    // Store spaces\n    for (const space of spaces) {\n      allSpaces[space.id] = space\n    }\n  }\n\n  // Update editor spaces\n  setSpaces(allSpaces)\n}\n\ntype Grid = {\n  cells: Map<string, 'empty' | 'wall' | 'exterior' | 'interior'>\n  resolution: number\n  minX: number\n  minZ: number\n  maxX: number\n  maxZ: number\n  width: number\n  height: number\n}\n\ntype WallSideUpdate = {\n  wallId: string\n  frontSide: 'interior' | 'exterior' | 'unknown'\n  backSide: 'interior' | 'exterior' | 'unknown'\n}\n\n// ============================================================================\n// MAIN DETECTION FUNCTION\n// ============================================================================\n\n/**\n * Detects spaces for a level by flood-filling a grid from the edges\n * Returns wall side updates and detected spaces\n */\nexport function detectSpacesForLevel(\n  levelId: string,\n  walls: WallNode[],\n  gridResolution = 0.5, // Match spatial grid cell size\n): {\n  wallUpdates: WallSideUpdate[]\n  spaces: Space[]\n} {\n  if (walls.length === 0) {\n    return { wallUpdates: [], spaces: [] }\n  }\n\n  // Build grid from walls\n  const grid = buildGrid(walls, gridResolution)\n\n  // Flood fill from edges to mark exterior\n  floodFillFromEdges(grid)\n\n  // Find interior spaces\n  const interiorSpaces = findInteriorSpaces(grid, levelId)\n\n  // Assign wall sides\n  const wallUpdates = assignWallSides(walls, grid)\n\n  return {\n    wallUpdates,\n    spaces: interiorSpaces,\n  }\n}\n\n// ============================================================================\n// GRID BUILDING\n// ============================================================================\n\n/**\n * Builds a discrete grid and marks cells occupied by walls\n */\nfunction buildGrid(walls: WallNode[], resolution: number): Grid {\n  // Find bounds\n  let minX = Number.POSITIVE_INFINITY\n  let minZ = Number.POSITIVE_INFINITY\n  let maxX = Number.NEGATIVE_INFINITY\n  let maxZ = Number.NEGATIVE_INFINITY\n\n  for (const wall of walls) {\n    minX = Math.min(minX, wall.start[0], wall.end[0])\n    minZ = Math.min(minZ, wall.start[1], wall.end[1])\n    maxX = Math.max(maxX, wall.start[0], wall.end[0])\n    maxZ = Math.max(maxZ, wall.start[1], wall.end[1])\n  }\n\n  // Add padding around bounds\n  const padding = 2 // meters\n  minX -= padding\n  minZ -= padding\n  maxX += padding\n  maxZ += padding\n\n  const width = Math.ceil((maxX - minX) / resolution)\n  const height = Math.ceil((maxZ - minZ) / resolution)\n\n  const grid: Grid = {\n    cells: new Map(),\n    resolution,\n    minX,\n    minZ,\n    maxX,\n    maxZ,\n    width,\n    height,\n  }\n\n  // Mark wall cells\n  for (const wall of walls) {\n    markWallCells(grid, wall)\n  }\n\n  return grid\n}\n\n/**\n * Marks all grid cells occupied by a wall using line rasterization\n * Uses denser sampling to ensure continuous barriers\n */\nfunction markWallCells(grid: Grid, wall: WallNode): void {\n  const thickness = wall.thickness ?? 0.2\n  const halfThickness = thickness / 2\n\n  const [x1, z1] = wall.start\n  const [x2, z2] = wall.end\n\n  // Wall direction vector\n  const dx = x2 - x1\n  const dz = z2 - z1\n  const len = Math.sqrt(dx * dx + dz * dz)\n  if (len < 0.001) return\n\n  // Normalized direction and perpendicular\n  const dirX = dx / len\n  const dirZ = dz / len\n  const perpX = -dirZ\n  const perpZ = dirX\n\n  // Denser sampling along wall length (at least 2x resolution)\n  const steps = Math.max(Math.ceil(len / (grid.resolution * 0.5)), 2)\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps\n    const x = x1 + dx * t\n    const z = z1 + dz * t\n\n    // Denser sampling across wall thickness\n    const thicknessSteps = Math.max(Math.ceil(thickness / (grid.resolution * 0.5)), 2)\n    for (let j = 0; j <= thicknessSteps; j++) {\n      const offset = (j / thicknessSteps - 0.5) * thickness\n      const wx = x + perpX * offset\n      const wz = z + perpZ * offset\n\n      const key = getCellKey(grid, wx, wz)\n      if (key) {\n        grid.cells.set(key, 'wall')\n      }\n    }\n  }\n}\n\n// ============================================================================\n// FLOOD FILL\n// ============================================================================\n\n/**\n * Flood fills from all edge cells to mark exterior space\n */\nfunction floodFillFromEdges(grid: Grid): void {\n  const queue: string[] = []\n\n  // Add all edge cells to queue\n  for (let x = 0; x < grid.width; x++) {\n    for (let z = 0; z < grid.height; z++) {\n      // Only process edge cells\n      if (x === 0 || x === grid.width - 1 || z === 0 || z === grid.height - 1) {\n        const key = getCellKeyFromIndex(x, z, grid.width)\n        const cell = grid.cells.get(key)\n        if (cell !== 'wall') {\n          grid.cells.set(key, 'exterior')\n          queue.push(key)\n        }\n      }\n    }\n  }\n\n  // Flood fill\n  while (queue.length > 0) {\n    const key = queue.shift()!\n    const [x, z] = parseCellKey(key)\n\n    // Check 4 neighbors\n    const neighbors: [number, number][] = [\n      [x + 1, z],\n      [x - 1, z],\n      [x, z + 1],\n      [x, z - 1],\n    ]\n\n    for (const [nx, nz] of neighbors) {\n      if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue\n\n      const nKey = getCellKeyFromIndex(nx, nz, grid.width)\n      const cell = grid.cells.get(nKey)\n\n      if (cell !== 'wall' && cell !== 'exterior') {\n        grid.cells.set(nKey, 'exterior')\n        queue.push(nKey)\n      }\n    }\n  }\n}\n\n// ============================================================================\n// INTERIOR SPACE DETECTION\n// ============================================================================\n\n/**\n * Finds all interior spaces (connected regions not marked as exterior or wall)\n */\nfunction findInteriorSpaces(grid: Grid, levelId: string): Space[] {\n  const spaces: Space[] = []\n  const visited = new Set<string>()\n\n  // Scan grid for interior cells\n  for (let x = 0; x < grid.width; x++) {\n    for (let z = 0; z < grid.height; z++) {\n      const key = getCellKeyFromIndex(x, z, grid.width)\n      if (visited.has(key)) continue\n\n      const cell = grid.cells.get(key)\n      if (cell === 'wall' || cell === 'exterior') {\n        visited.add(key)\n        continue\n      }\n\n      // Found interior cell - flood fill to find full space\n      const spaceCells = new Set<string>()\n      const queue = [key]\n      visited.add(key)\n      spaceCells.add(key)\n      // Mark the seed cell as interior in the grid\n      grid.cells.set(key, 'interior')\n\n      while (queue.length > 0) {\n        const curKey = queue.shift()!\n        const [cx, cz] = parseCellKey(curKey)\n\n        const neighbors: [number, number][] = [\n          [cx + 1, cz],\n          [cx - 1, cz],\n          [cx, cz + 1],\n          [cx, cz - 1],\n        ]\n\n        for (const [nx, nz] of neighbors) {\n          if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height) continue\n\n          const nKey = getCellKeyFromIndex(nx, nz, grid.width)\n          if (visited.has(nKey)) continue\n\n          const nCell = grid.cells.get(nKey)\n          if (nCell === 'wall' || nCell === 'exterior') {\n            visited.add(nKey)\n            continue\n          }\n\n          visited.add(nKey)\n          spaceCells.add(nKey)\n          // Mark as interior in grid\n          grid.cells.set(nKey, 'interior')\n          queue.push(nKey)\n        }\n      }\n\n      // Create space from cells\n      const polygon = extractPolygonFromCells(spaceCells, grid)\n      spaces.push({\n        id: `space-${spaces.length}`,\n        levelId,\n        polygon,\n        wallIds: [],\n        isExterior: false,\n      })\n    }\n  }\n\n  return spaces\n}\n\n/**\n * Extracts a simplified polygon from a set of grid cells\n * Returns bounding box for now (can be improved to trace actual boundary)\n */\nfunction extractPolygonFromCells(cells: Set<string>, grid: Grid): Array<[number, number]> {\n  let minX = Number.POSITIVE_INFINITY\n  let minZ = Number.POSITIVE_INFINITY\n  let maxX = Number.NEGATIVE_INFINITY\n  let maxZ = Number.NEGATIVE_INFINITY\n\n  for (const key of cells) {\n    const [x, z] = parseCellKey(key)\n    const worldX = grid.minX + x * grid.resolution\n    const worldZ = grid.minZ + z * grid.resolution\n\n    minX = Math.min(minX, worldX)\n    minZ = Math.min(minZ, worldZ)\n    maxX = Math.max(maxX, worldX)\n    maxZ = Math.max(maxZ, worldZ)\n  }\n\n  // Return bounding box as polygon\n  return [\n    [minX, minZ],\n    [maxX, minZ],\n    [maxX, maxZ],\n    [minX, maxZ],\n  ]\n}\n\n// ============================================================================\n// WALL SIDE ASSIGNMENT\n// ============================================================================\n\n/**\n * Assigns front/back side classification to each wall based on grid\n */\nfunction assignWallSides(walls: WallNode[], grid: Grid): WallSideUpdate[] {\n  const updates: WallSideUpdate[] = []\n\n  for (const wall of walls) {\n    const thickness = wall.thickness ?? 0.2\n    const [x1, z1] = wall.start\n    const [x2, z2] = wall.end\n\n    // Wall direction and perpendicular\n    const dx = x2 - x1\n    const dz = z2 - z1\n    const len = Math.sqrt(dx * dx + dz * dz)\n    if (len < 0.001) continue\n\n    const perpX = -dz / len\n    const perpZ = dx / len\n\n    // Sample point on front side (perpendicular direction)\n    const midX = (x1 + x2) / 2\n    const midZ = (z1 + z2) / 2\n    // Sample beyond wall thickness + one full grid cell to ensure we're in the next cell\n    const offset = thickness / 2 + grid.resolution\n\n    const frontX = midX + perpX * offset\n    const frontZ = midZ + perpZ * offset\n    const backX = midX - perpX * offset\n    const backZ = midZ - perpZ * offset\n\n    // Check what space each side faces\n    const frontKey = getCellKey(grid, frontX, frontZ)\n    const backKey = getCellKey(grid, backX, backZ)\n\n    const frontCell = frontKey ? grid.cells.get(frontKey) : undefined\n    const backCell = backKey ? grid.cells.get(backKey) : undefined\n\n    const frontSide = classifySide(frontCell)\n    const backSide = classifySide(backCell)\n\n    updates.push({\n      wallId: wall.id,\n      frontSide,\n      backSide,\n    })\n  }\n\n  return updates\n}\n\n/**\n * Classifies a cell as interior, exterior, or unknown\n */\nfunction classifySide(cell: string | undefined): 'interior' | 'exterior' | 'unknown' {\n  if (cell === 'exterior') return 'exterior'\n  if (cell === 'interior') return 'interior'\n  // Wall cells or out-of-bounds (undefined) are unknown\n  return 'unknown'\n}\n\n// ============================================================================\n// GRID UTILITIES\n// ============================================================================\n\n/**\n * Gets grid cell key from world coordinates\n */\nfunction getCellKey(grid: Grid, x: number, z: number): string | null {\n  const cellX = Math.floor((x - grid.minX) / grid.resolution)\n  const cellZ = Math.floor((z - grid.minZ) / grid.resolution)\n\n  if (cellX < 0 || cellX >= grid.width || cellZ < 0 || cellZ >= grid.height) {\n    return null\n  }\n\n  return `${cellX},${cellZ}`\n}\n\n/**\n * Gets cell key from grid indices\n */\nfunction getCellKeyFromIndex(x: number, z: number, width: number): string {\n  return `${x},${z}`\n}\n\n/**\n * Parses cell key back to indices\n */\nfunction parseCellKey(key: string): [number, number] {\n  const parts = key.split(',')\n  return [Number.parseInt(parts[0]!, 10), Number.parseInt(parts[1]!, 10)]\n}\n\n// ============================================================================\n// WALL CONNECTIVITY DETECTION\n// ============================================================================\n\n/**\n * Checks if a wall touches any other walls\n * Used to determine if space detection should run\n */\nexport function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean {\n  const threshold = 0.1 // 10cm connection threshold\n\n  for (const other of otherWalls) {\n    if (other.id === wall.id) continue\n\n    // Check if any endpoint of wall is close to any endpoint or segment of other\n    if (\n      distanceToSegment(wall.start, other.start, other.end) < threshold ||\n      distanceToSegment(wall.end, other.start, other.end) < threshold ||\n      distanceToSegment(other.start, wall.start, wall.end) < threshold ||\n      distanceToSegment(other.end, wall.start, wall.end) < threshold\n    ) {\n      return true\n    }\n  }\n\n  return false\n}\n\n/**\n * Distance from point to line segment\n */\nfunction distanceToSegment(\n  point: [number, number],\n  segStart: [number, number],\n  segEnd: [number, number],\n): number {\n  const [px, pz] = point\n  const [x1, z1] = segStart\n  const [x2, z2] = segEnd\n\n  const dx = x2 - x1\n  const dz = z2 - z1\n  const lenSq = dx * dx + dz * dz\n\n  if (lenSq < 0.0001) {\n    // Segment is a point\n    const dpx = px - x1\n    const dpz = pz - z1\n    return Math.sqrt(dpx * dpx + dpz * dpz)\n  }\n\n  // Project point onto line\n  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq))\n  const projX = x1 + t * dx\n  const projZ = z1 + t * dz\n\n  const distX = px - projX\n  const distZ = pz - projZ\n\n  return Math.sqrt(distX * distX + distZ * distZ)\n}\n"
  },
  {
    "path": "packages/core/src/schema/base.ts",
    "content": "import { customAlphabet } from 'nanoid'\nimport { z } from 'zod'\nimport { CameraSchema } from './camera'\n\nconst customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)\n\n/**\n * Material preset name reference\n * @example 'white', 'brick', 'wood', 'glass', 'preview-valid'\n */\nexport const Material = z.string().optional()\nexport const generateId = <T extends string>(prefix: T): `${T}_${string}` =>\n  `${prefix}_${customId()}` as `${T}_${string}`\nexport const objectId = <T extends string>(prefix: T) => {\n  const schema = z.templateLiteral([`${prefix}_`, z.string()])\n\n  return schema.default(() => generateId(prefix) as z.infer<typeof schema>)\n}\nexport const nodeType = <T extends string>(type: T) => z.literal(type).default(type)\n\nexport const BaseNode = z.object({\n  object: z.literal('node').default('node'),\n  id: z.string(), // objectId('node'), @Aymericr: Thing is if we specify objectId here, when using BaseNode.extend, TS complains that the id is not assignable to the more specific type in the extended node\n  type: nodeType('node'),\n  name: z.string().optional(),\n  parentId: z.string().nullable().default(null),\n  visible: z.boolean().optional().default(true),\n  camera: CameraSchema.optional(),\n  metadata: z.json().optional().default({}),\n})\n\nexport type BaseNode = z.infer<typeof BaseNode>\n"
  },
  {
    "path": "packages/core/src/schema/camera.ts",
    "content": "import { z } from 'zod'\n\nconst Vector3Schema = z.tuple([z.number(), z.number(), z.number()])\n\nexport const CameraSchema = z.object({\n  position: Vector3Schema,\n  target: Vector3Schema,\n  mode: z.enum(['perspective', 'orthographic']).default('perspective'),\n  fov: z.number().optional(), // For perspective\n  zoom: z.number().optional(), // For orthographic\n})\n\nexport type Camera = z.infer<typeof CameraSchema>\n"
  },
  {
    "path": "packages/core/src/schema/collections.ts",
    "content": "import { generateId } from './base'\nimport type { AnyNodeId } from './types'\n\nexport type CollectionId = `collection_${string}`\n\nexport type Collection = {\n  id: CollectionId\n  name: string\n  color?: string\n  nodeIds: AnyNodeId[]\n  controlNodeId?: AnyNodeId\n}\n\nexport const generateCollectionId = (): CollectionId => generateId('collection')\n"
  },
  {
    "path": "packages/core/src/schema/index.ts",
    "content": "// Base\nexport { BaseNode, generateId, nodeType, objectId } from './base'\n// Camera\nexport { CameraSchema } from './camera'\n// Collections\nexport { type Collection, type CollectionId, generateCollectionId } from './collections'\n// Material\nexport {\n  DEFAULT_MATERIALS,\n  MaterialPreset,\n  MaterialProperties,\n  MaterialSchema,\n  resolveMaterial,\n} from './material'\nexport { BuildingNode } from './nodes/building'\nexport { CeilingNode } from './nodes/ceiling'\nexport { DoorNode, DoorSegment } from './nodes/door'\nexport { GuideNode } from './nodes/guide'\nexport type {\n  AnimationEffect,\n  Asset,\n  AssetInput,\n  Control,\n  Effect,\n  Interactive,\n  LightEffect,\n  SliderControl,\n  TemperatureControl,\n  ToggleControl,\n} from './nodes/item'\nexport { getScaledDimensions, ItemNode } from './nodes/item'\nexport { LevelNode } from './nodes/level'\nexport { RoofNode } from './nodes/roof'\nexport { RoofSegmentNode, RoofType } from './nodes/roof-segment'\nexport { ScanNode } from './nodes/scan'\n// Nodes\nexport { SiteNode } from './nodes/site'\nexport { SlabNode } from './nodes/slab'\nexport { StairNode } from './nodes/stair'\nexport { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'\nexport { WallNode } from './nodes/wall'\nexport { WindowNode } from './nodes/window'\nexport { ZoneNode } from './nodes/zone'\nexport type { AnyNodeId, AnyNodeType } from './types'\n// Union types\nexport { AnyNode } from './types'\n"
  },
  {
    "path": "packages/core/src/schema/material.ts",
    "content": "import { z } from 'zod'\n\nexport const MaterialPreset = z.enum([\n  'white',\n  'brick',\n  'concrete',\n  'wood',\n  'glass',\n  'metal',\n  'plaster',\n  'tile',\n  'marble',\n  'custom',\n])\nexport type MaterialPreset = z.infer<typeof MaterialPreset>\n\nexport const MaterialProperties = z.object({\n  color: z.string().default('#ffffff'),\n  roughness: z.number().min(0).max(1).default(0.5),\n  metalness: z.number().min(0).max(1).default(0),\n  opacity: z.number().min(0).max(1).default(1),\n  transparent: z.boolean().default(false),\n  side: z.enum(['front', 'back', 'double']).default('front'),\n})\nexport type MaterialProperties = z.infer<typeof MaterialProperties>\n\nexport const MaterialSchema = z.object({\n  preset: MaterialPreset.optional(),\n  properties: MaterialProperties.optional(),\n  texture: z\n    .object({\n      url: z.string(),\n      repeat: z.tuple([z.number(), z.number()]).optional(),\n      scale: z.number().optional(),\n    })\n    .optional(),\n})\nexport type MaterialSchema = z.infer<typeof MaterialSchema>\n\nexport const DEFAULT_MATERIALS: Record<MaterialPreset, MaterialProperties> = {\n  white: {\n    color: '#ffffff',\n    roughness: 0.9,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  brick: {\n    color: '#8b4513',\n    roughness: 0.85,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  concrete: {\n    color: '#808080',\n    roughness: 0.8,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  wood: {\n    color: '#deb887',\n    roughness: 0.7,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  glass: {\n    color: '#87ceeb',\n    roughness: 0.1,\n    metalness: 0.1,\n    opacity: 0.3,\n    transparent: true,\n    side: 'double',\n  },\n  metal: {\n    color: '#c0c0c0',\n    roughness: 0.3,\n    metalness: 0.9,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  plaster: {\n    color: '#f5f5dc',\n    roughness: 0.95,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  tile: {\n    color: '#d3d3d3',\n    roughness: 0.4,\n    metalness: 0.1,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  marble: {\n    color: '#fafafa',\n    roughness: 0.2,\n    metalness: 0.1,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n  custom: {\n    color: '#ffffff',\n    roughness: 0.5,\n    metalness: 0,\n    opacity: 1,\n    transparent: false,\n    side: 'front',\n  },\n}\n\nexport function resolveMaterial(material?: MaterialSchema): MaterialProperties {\n  if (!material) {\n    return DEFAULT_MATERIALS.white\n  }\n\n  if (material.preset && material.preset !== 'custom') {\n    const presetProps = DEFAULT_MATERIALS[material.preset]\n    return {\n      ...presetProps,\n      ...material.properties,\n    }\n  }\n\n  return {\n    ...DEFAULT_MATERIALS.custom,\n    ...material.properties,\n  }\n}\n"
  },
  {
    "path": "packages/core/src/schema/nodes/building.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { LevelNode } from './level'\n\nexport const BuildingNode = BaseNode.extend({\n  id: objectId('building'),\n  type: nodeType('building'),\n  children: z.array(LevelNode.shape.id).default([]),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n}).describe(\n  dedent`\n  Building node - used to represent a building\n  - position: position in site coordinate system\n  - rotation: rotation in site coordinate system\n  - children: array of level nodes (each level is a tree of floor and wall nodes) \n  `,\n)\n\nexport type BuildingNode = z.infer<typeof BuildingNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/ceiling.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\nimport { ItemNode } from './item'\n\nexport const CeilingNode = BaseNode.extend({\n  id: objectId('ceiling'),\n  type: nodeType('ceiling'),\n  children: z.array(ItemNode.shape.id).default([]),\n  material: MaterialSchema.optional(),\n  polygon: z.array(z.tuple([z.number(), z.number()])),\n  holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),\n  height: z.number().default(2.5), // Height in meters\n}).describe(\n  dedent`\n  Ceiling node - used to represent a ceiling in the building\n  - polygon: array of [x, z] points defining the ceiling boundary\n  - holes: array of polygons representing holes in the ceiling\n  `,\n)\n\nexport type CeilingNode = z.infer<typeof CeilingNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/door.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\n\nexport const DoorSegment = z.object({\n  type: z.enum(['panel', 'glass', 'empty']),\n  heightRatio: z.number(),\n\n  // Each segment controls its own column split\n  columnRatios: z.array(z.number()).default([1]),\n  dividerThickness: z.number().default(0.03),\n\n  // panel-specific\n  panelDepth: z.number().default(0.01), // + raised, - recessed\n  panelInset: z.number().default(0.04),\n})\n\nexport type DoorSegment = z.infer<typeof DoorSegment>\n\nexport const DoorNode = BaseNode.extend({\n  id: objectId('door'),\n  type: nodeType('door'),\n  material: MaterialSchema.optional(),\n\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  side: z.enum(['front', 'back']).optional(),\n  wallId: z.string().optional(),\n\n  // Overall dimensions\n  width: z.number().default(0.9),\n  height: z.number().default(2.1),\n\n  // Frame\n  frameThickness: z.number().default(0.05),\n  frameDepth: z.number().default(0.07),\n  threshold: z.boolean().default(true),\n  thresholdHeight: z.number().default(0.02),\n\n  // Swing\n  hingesSide: z.enum(['left', 'right']).default('left'),\n  swingDirection: z.enum(['inward', 'outward']).default('inward'),\n\n  // Leaf segments — stacked top to bottom, each with its own column split\n  segments: z.array(DoorSegment).default([\n    {\n      type: 'panel',\n      heightRatio: 0.4,\n      columnRatios: [1],\n      dividerThickness: 0.03,\n      panelDepth: 0.01,\n      panelInset: 0.04,\n    },\n    {\n      type: 'panel',\n      heightRatio: 0.6,\n      columnRatios: [1],\n      dividerThickness: 0.03,\n      panelDepth: 0.01,\n      panelInset: 0.04,\n    },\n  ]),\n\n  // Handle\n  handle: z.boolean().default(true),\n  handleHeight: z.number().default(1.05),\n  handleSide: z.enum(['left', 'right']).default('right'),\n\n  // Leaf inner margin — space between leaf edge and segment content area [x, y]\n  contentPadding: z.tuple([z.number(), z.number()]).default([0.04, 0.04]),\n\n  // Emergency / commercial hardware\n  doorCloser: z.boolean().default(false),\n  panicBar: z.boolean().default(false),\n  panicBarHeight: z.number().default(1.0),\n}).describe(dedent`Door node - a parametric door placed on a wall\n  - position: center of the door in wall-local coordinate system (Y = height/2, always at floor)\n  - segments: rows stacked top to bottom, each defining its own columnRatios\n  - type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed\n  - hingesSide/swingDirection: which way the door opens\n  - doorCloser/panicBar: commercial and emergency hardware options\n`)\n\nexport type DoorNode = z.infer<typeof DoorNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/guide.ts",
    "content": "import { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\n\nexport const GuideNode = BaseNode.extend({\n  id: objectId('guide'),\n  type: nodeType('guide'),\n  url: z.string(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  scale: z.number().default(1),\n  opacity: z.number().min(0).max(100).default(50),\n})\n\nexport type GuideNode = z.infer<typeof GuideNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/item.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport type { CollectionId } from '../collections'\n\n// --- Control descriptors ---\n\nconst toggleControlSchema = z.object({\n  kind: z.literal('toggle'),\n  label: z.string().optional(),\n  default: z.boolean().optional(),\n})\n\nconst sliderControlSchema = z.object({\n  kind: z.literal('slider'),\n  label: z.string(),\n  min: z.number(),\n  max: z.number(),\n  step: z.number().default(1),\n  unit: z.string().optional(),\n  displayMode: z.enum(['slider', 'stepper', 'dial']).default('slider'),\n  default: z.number().optional(),\n})\n\nconst temperatureControlSchema = z.object({\n  kind: z.literal('temperature'),\n  label: z.string().default('Temperature'),\n  min: z.number().default(16),\n  max: z.number().default(30),\n  unit: z.enum(['C', 'F']).default('C'),\n  default: z.number().optional(),\n})\n\nconst controlSchema = z.discriminatedUnion('kind', [\n  toggleControlSchema,\n  sliderControlSchema,\n  temperatureControlSchema,\n])\n\n// --- Effect descriptors ---\n\nconst animationEffectSchema = z.object({\n  kind: z.literal('animation'),\n  clips: z.object({\n    on: z.string().optional(),\n    off: z.string().optional(),\n    loop: z.string().optional(),\n  }),\n})\n\nconst lightEffectSchema = z.object({\n  kind: z.literal('light'),\n  color: z.string().default('#ffffff'),\n  intensityRange: z.tuple([z.number(), z.number()]),\n  distance: z.number().optional(),\n  offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n})\n\nconst effectSchema = z.discriminatedUnion('kind', [animationEffectSchema, lightEffectSchema])\n\n// --- Interactive descriptor ---\n\nconst interactiveSchema = z.object({\n  controls: z.array(controlSchema).default([]),\n  effects: z.array(effectSchema).default([]),\n})\n\nexport type ToggleControl = z.infer<typeof toggleControlSchema>\nexport type SliderControl = z.infer<typeof sliderControlSchema>\nexport type TemperatureControl = z.infer<typeof temperatureControlSchema>\nexport type Control = z.infer<typeof controlSchema>\nexport type AnimationEffect = z.infer<typeof animationEffectSchema>\nexport type LightEffect = z.infer<typeof lightEffectSchema>\nexport type Effect = z.infer<typeof effectSchema>\nexport type Interactive = z.infer<typeof interactiveSchema>\n\nconst assetSchema = z.object({\n  id: z.string(),\n  category: z.string(),\n  name: z.string(),\n  thumbnail: z.string(),\n  src: z.string(),\n  dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]\n  attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),\n  tags: z.array(z.string()).optional(),\n  // These are \"Corrective\" transforms to normalize the GLB\n  offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),\n  surface: z\n    .object({\n      height: z.number(), // where things rest\n    })\n    .optional(), // undefined = can't place things on it\n  interactive: interactiveSchema.optional(),\n})\n\nexport type AssetInput = z.input<typeof assetSchema>\nexport type Asset = z.infer<typeof assetSchema>\n\nexport const ItemNode = BaseNode.extend({\n  id: objectId('item'),\n  type: nodeType('item'),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),\n  side: z.enum(['front', 'back']).optional(),\n  children: z.array(objectId('item')).default([]),\n\n  // Wall attachment properties (only used when asset.attachTo is \"wall\" or \"wall-side\")\n  wallId: z.string().optional(),\n  wallT: z.number().optional(), // 0-1 parametric position along wall\n\n  // Denormalized references to collections this node belongs to\n  collectionIds: z.array(z.custom<CollectionId>()).optional(),\n\n  asset: assetSchema,\n}).describe(dedent`Item node - used to represent a item in the building\n  - position: position in level coordinate system (or parent coordinate system if attached)\n  - rotation: rotation in level coordinate system (or parent coordinate system if attached)\n  - asset: asset data\n    - category: category of the item\n    - dimensions: size in level coordinate system\n    - src: url of the model\n    - attachTo: where to attach the item (wall, wall-side, ceiling)\n    - offset: corrective position offset for the model\n    - rotation: corrective rotation for the model\n    - scale: corrective scale for the model\n    - tags: tags associated with the item\n`)\n\nexport type ItemNode = z.infer<typeof ItemNode>\n\n/**\n * Returns the effective world-space dimensions of an item after applying its scale.\n * Use this everywhere item.asset.dimensions is used for spatial calculations.\n */\nexport function getScaledDimensions(item: ItemNode): [number, number, number] {\n  const [w, h, d] = item.asset.dimensions\n  const [sx, sy, sz] = item.scale\n  return [w * sx, h * sy, d * sz]\n}\n"
  },
  {
    "path": "packages/core/src/schema/nodes/level.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { CeilingNode } from './ceiling'\nimport { GuideNode } from './guide'\nimport { RoofNode } from './roof'\nimport { ScanNode } from './scan'\nimport { SlabNode } from './slab'\nimport { StairNode } from './stair'\nimport { WallNode } from './wall'\nimport { ZoneNode } from './zone'\n\nexport const LevelNode = BaseNode.extend({\n  id: objectId('level'),\n  type: nodeType('level'),\n  children: z\n    .array(\n      z.union([\n        WallNode.shape.id,\n        ZoneNode.shape.id,\n        SlabNode.shape.id,\n        CeilingNode.shape.id,\n        RoofNode.shape.id,\n        StairNode.shape.id,\n        ScanNode.shape.id,\n        GuideNode.shape.id,\n      ]),\n    )\n    .default([]),\n  // Specific props\n  level: z.number().default(0),\n}).describe(\n  dedent`\n  Level node - used to represent a level in the building\n  - children: array of floor, wall, ceiling, roof, item nodes\n  - level: level number\n  `,\n)\n\nexport type LevelNode = z.infer<typeof LevelNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/roof-segment.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\n\nexport const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'])\n\nexport type RoofType = z.infer<typeof RoofType>\n\nexport const RoofSegmentNode = BaseNode.extend({\n  id: objectId('rseg'),\n  type: nodeType('roof-segment'),\n  material: MaterialSchema.optional(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  // Rotation around Y axis in radians\n  rotation: z.number().default(0),\n  // Roof shape type\n  roofType: RoofType.default('gable'),\n  // Footprint dimensions\n  width: z.number().default(8),\n  depth: z.number().default(6),\n  // Vertical dimensions\n  wallHeight: z.number().default(0.5),\n  roofHeight: z.number().default(2.5),\n  // Structure thicknesses\n  wallThickness: z.number().default(0.1),\n  deckThickness: z.number().default(0.1),\n  overhang: z.number().default(0.3),\n  shingleThickness: z.number().default(0.05),\n}).describe(\n  dedent`\n  Roof segment node - an individual roof module within a roof group.\n  Each segment generates a complete architectural volume (walls + roof).\n  Multiple segments can be combined to form complex roof shapes.\n  - roofType: hip, gable, shed, gambrel, dutch, mansard, flat\n  - width/depth: footprint dimensions\n  - wallHeight: height of walls below the roof\n  - roofHeight: height of the roof peak above the walls\n  - wallThickness/deckThickness: structural thicknesses\n  - overhang: eave overhang distance\n  - shingleThickness: outer shingle layer thickness\n  `,\n)\n\nexport type RoofSegmentNode = z.infer<typeof RoofSegmentNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/roof.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\nimport { RoofSegmentNode } from './roof-segment'\n\nexport const RoofNode = BaseNode.extend({\n  id: objectId('roof'),\n  type: nodeType('roof'),\n  material: MaterialSchema.optional(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  // Rotation around Y axis in radians\n  rotation: z.number().default(0),\n  // Child roof segment IDs\n  children: z.array(RoofSegmentNode.shape.id).default([]),\n}).describe(\n  dedent`\n  Roof node - a container for roof segments.\n  Acts as a group that holds one or more RoofSegmentNodes.\n  When not being edited, segments are visually combined into a single solid.\n  - position: center position of the roof group\n  - rotation: rotation around Y axis\n  - children: array of RoofSegmentNode IDs\n  `,\n)\n\nexport type RoofNode = z.infer<typeof RoofNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/scan.ts",
    "content": "import { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\n\nexport const ScanNode = BaseNode.extend({\n  id: objectId('scan'),\n  type: nodeType('scan'),\n  url: z.string(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  scale: z.number().default(1),\n  opacity: z.number().min(0).max(100).default(100),\n})\n\nexport type ScanNode = z.infer<typeof ScanNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/site.ts",
    "content": "// lib/scenegraph/schema/nodes/site.ts\n\nimport dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { BuildingNode } from './building'\nimport { ItemNode } from './item'\n\n// 2D Polygon\nconst PropertyLineData = z.object({\n  type: z.literal('polygon'),\n  points: z.array(z.tuple([z.number(), z.number()])),\n})\n\n// 3D Polygon/Mesh\n// const TerrainData = z.object({\n//   type: z.literal('terrain'),\n//   points: z.array(z.tuple([z.number(), z.number(), z.number()])),\n// })\n\nexport const SiteNode = BaseNode.extend({\n  id: objectId('site'),\n  type: nodeType('site'),\n  // Specific props\n  polygon: PropertyLineData.optional().default({\n    type: 'polygon',\n    // Default 30x30 square centered at origin\n    points: [\n      [-15, -15],\n      [15, -15],\n      [15, 15],\n      [-15, 15],\n    ],\n  }),\n  // terrain: TerrainData,\n  children: z\n    .array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))\n    .default([BuildingNode.parse({})]),\n}).describe(\n  dedent`\n  Site node - used to represent a site\n  - polygon: polygon data\n  - children: array of building and item nodes\n  `,\n)\n\nexport type SiteNode = z.infer<typeof SiteNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/slab.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\n\nexport const SlabNode = BaseNode.extend({\n  id: objectId('slab'),\n  type: nodeType('slab'),\n  material: MaterialSchema.optional(),\n  polygon: z.array(z.tuple([z.number(), z.number()])),\n  holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),\n  elevation: z.number().default(0.05), // Elevation in meters\n}).describe(\n  dedent`\n  Slab node - used to represent a slab/floor in the building\n  - polygon: array of [x, z] points defining the slab boundary\n  - elevation: elevation in meters\n  `,\n)\n\nexport type SlabNode = z.infer<typeof SlabNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/stair-segment.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\n\nexport const StairSegmentType = z.enum(['stair', 'landing'])\n\nexport type StairSegmentType = z.infer<typeof StairSegmentType>\n\nexport const AttachmentSide = z.enum(['front', 'left', 'right'])\n\nexport type AttachmentSide = z.infer<typeof AttachmentSide>\n\nexport const StairSegmentNode = BaseNode.extend({\n  id: objectId('sseg'),\n  type: nodeType('stair-segment'),\n  material: MaterialSchema.optional(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  // Rotation around Y axis in radians\n  rotation: z.number().default(0),\n  // Stair or landing\n  segmentType: StairSegmentType.default('stair'),\n  // Width of the stair flight / landing\n  width: z.number().default(1.0),\n  // Horizontal run (depth along travel direction)\n  length: z.number().default(3.0),\n  // Vertical rise (0 for landings)\n  height: z.number().default(2.5),\n  // Number of steps (only used for stair type)\n  stepCount: z.number().default(10),\n  // Which side of the previous segment to attach to\n  attachmentSide: AttachmentSide.default('front'),\n  // Whether to fill the underside down to floor level\n  fillToFloor: z.boolean().default(true),\n  // Thickness of the stair slab when not filled to floor\n  thickness: z.number().default(0.25),\n}).describe(\n  dedent`\n  Stair segment node - an individual flight or landing within a stair group.\n  Each segment generates a complete stair/landing geometry.\n  Multiple segments chain together to form complex staircase shapes (L-shape, U-shape, etc.).\n  - segmentType: stair (with steps) or landing (flat platform)\n  - width: width of the flight/landing\n  - length: horizontal run distance\n  - height: vertical rise (0 for landings)\n  - stepCount: number of steps (stair type only)\n  - attachmentSide: front, left, or right - which side of the previous segment to attach to\n  - fillToFloor: whether to fill the underside down to the absolute floor level\n  - thickness: slab thickness when not filled to floor\n  `,\n)\n\nexport type StairSegmentNode = z.infer<typeof StairSegmentNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/stair.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\nimport { StairSegmentNode } from './stair-segment'\n\nexport const StairNode = BaseNode.extend({\n  id: objectId('stair'),\n  type: nodeType('stair'),\n  material: MaterialSchema.optional(),\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  // Rotation around Y axis in radians\n  rotation: z.number().default(0),\n  // Child stair segment IDs\n  children: z.array(StairSegmentNode.shape.id).default([]),\n}).describe(\n  dedent`\n  Stair node - a container for stair segments.\n  Acts as a group that holds one or more StairSegmentNodes (flights and landings).\n  Segments chain together based on their attachmentSide to form complex staircase shapes.\n  - position: center position of the stair group\n  - rotation: rotation around Y axis\n  - children: array of StairSegmentNode IDs\n  `,\n)\n\nexport type StairNode = z.infer<typeof StairNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/wall.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\nimport { ItemNode } from './item'\n// import { DoorNode } from \"./door\";\n// import { ItemNode } from \"./item\";\n// import { WindowNode } from \"./window\";\n\nexport const WallNode = BaseNode.extend({\n  id: objectId('wall'),\n  type: nodeType('wall'),\n  children: z.array(ItemNode.shape.id).default([]),\n  material: MaterialSchema.optional(),\n  thickness: z.number().optional(),\n  height: z.number().optional(),\n  // e.g., start/end points for path\n  start: z.tuple([z.number(), z.number()]),\n  end: z.tuple([z.number(), z.number()]),\n  // Space detection for cutaway mode\n  frontSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),\n  backSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'),\n}).describe(\n  dedent`\n  Wall node - used to represent a wall in the building\n  - thickness: thickness in meters\n  - height: height in meters\n  - start: start point of the wall in level coordinate system\n  - end: end point of the wall in level coordinate system\n  - size: size of the wall in grid units\n  - frontSide: whether the front side faces interior, exterior, or unknown\n  - backSide: whether the back side faces interior, exterior, or unknown\n  `,\n)\nexport type WallNode = z.infer<typeof WallNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/window.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\nimport { MaterialSchema } from '../material'\n\nexport const WindowNode = BaseNode.extend({\n  id: objectId('window'),\n  type: nodeType('window'),\n  material: MaterialSchema.optional(),\n\n  position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),\n  side: z.enum(['front', 'back']).optional(),\n\n  // Wall reference\n  wallId: z.string().optional(),\n\n  // Overall dimensions\n  width: z.number().default(1.5),\n  height: z.number().default(1.5),\n\n  // Frame\n  frameThickness: z.number().default(0.05),\n  frameDepth: z.number().default(0.07),\n\n  // Divisions — ratios allow non-uniform panes\n  // [0.5, 0.5] = two equal panes\n  // [0.6, 0.4] = one larger, one smaller\n  // [1] = single pane (no division)\n  columnRatios: z.array(z.number()).default([1]),\n  rowRatios: z.array(z.number()).default([1]),\n  columnDividerThickness: z.number().default(0.03),\n  rowDividerThickness: z.number().default(0.03),\n\n  // Sill\n  sill: z.boolean().default(true),\n  sillDepth: z.number().default(0.08),\n  sillThickness: z.number().default(0.03),\n}).describe(dedent`Window node - a parametric window placed on a wall\n  - position: center of the window in wall-local coordinate system\n  - width/height: overall outer dimensions\n  - frameThickness: width of the frame members\n  - frameDepth: how deep the frame sits within the wall\n  - columnRatios/rowRatios: pane division ratios\n  - sill: whether to show a window sill\n`)\n\nexport type WindowNode = z.infer<typeof WindowNode>\n"
  },
  {
    "path": "packages/core/src/schema/nodes/zone.ts",
    "content": "import dedent from 'dedent'\nimport { z } from 'zod'\nimport { BaseNode, nodeType, objectId } from '../base'\n\nexport const ZoneNode = BaseNode.extend({\n  id: objectId('zone'),\n  type: nodeType('zone'),\n  name: z.string(),\n  // Polygon boundary - array of [x, z] coordinates defining the zone\n  polygon: z.array(z.tuple([z.number(), z.number()])),\n  // Visual styling\n  color: z.string().default('#3b82f6'), // Default blue\n  metadata: z.json().optional().default({}),\n}).describe(\n  dedent`\n  Zone schema - a polygon zone attached to a level\n  - object: \"zone\"\n  - id: zone id\n  - levelId: level this zone is attached to\n  - name: zone name\n  - polygon: array of [x, z] points defining the zone boundary\n  - color: hex color for visual styling\n  - metadata: zone metadata (optional)\n  `,\n)\n\nexport type ZoneNode = z.infer<typeof ZoneNode>\n"
  },
  {
    "path": "packages/core/src/schema/types.ts",
    "content": "import z from 'zod'\nimport { BuildingNode } from './nodes/building'\nimport { CeilingNode } from './nodes/ceiling'\nimport { DoorNode } from './nodes/door'\nimport { GuideNode } from './nodes/guide'\nimport { ItemNode } from './nodes/item'\nimport { LevelNode } from './nodes/level'\nimport { RoofNode } from './nodes/roof'\nimport { RoofSegmentNode } from './nodes/roof-segment'\nimport { ScanNode } from './nodes/scan'\nimport { SiteNode } from './nodes/site'\nimport { SlabNode } from './nodes/slab'\nimport { StairNode } from './nodes/stair'\nimport { StairSegmentNode } from './nodes/stair-segment'\nimport { WallNode } from './nodes/wall'\nimport { WindowNode } from './nodes/window'\nimport { ZoneNode } from './nodes/zone'\n\nexport const AnyNode = z.discriminatedUnion('type', [\n  SiteNode,\n  BuildingNode,\n  LevelNode,\n  WallNode,\n  ItemNode,\n  ZoneNode,\n  SlabNode,\n  CeilingNode,\n  RoofNode,\n  RoofSegmentNode,\n  StairNode,\n  StairSegmentNode,\n  ScanNode,\n  GuideNode,\n  WindowNode,\n  DoorNode,\n])\n\nexport type AnyNode = z.infer<typeof AnyNode>\nexport type AnyNodeType = AnyNode['type']\nexport type AnyNodeId = AnyNode['id']\n"
  },
  {
    "path": "packages/core/src/store/actions/node-actions.ts",
    "content": "import type { AnyNode, AnyNodeId } from '../../schema'\nimport type { CollectionId } from '../../schema/collections'\nimport type { SceneState } from '../use-scene'\n\ntype AnyContainerNode = AnyNode & { children: string[] }\n\n// Track pending RAF for updateNodesAction to prevent multiple queued callbacks\nlet pendingRafId: number | null = null\nlet pendingUpdates: Set<AnyNodeId> = new Set()\n\nexport const createNodesAction = (\n  set: (fn: (state: SceneState) => Partial<SceneState>) => void,\n  get: () => SceneState,\n  ops: { node: AnyNode; parentId?: AnyNodeId }[],\n) => {\n  if (get().readOnly) return\n  set((state) => {\n    const nextNodes = { ...state.nodes }\n    const nextRootIds = [...state.rootNodeIds]\n\n    for (const { node, parentId } of ops) {\n      // 1. Assign parentId to the child (Safe because BaseNode has parentId)\n      const newNode = {\n        ...node,\n        parentId: parentId ?? null,\n      }\n\n      nextNodes[newNode.id] = newNode\n\n      // 2. Update the Parent's children list\n      if (parentId && nextNodes[parentId]) {\n        const parent = nextNodes[parentId]\n\n        // Type Guard: Check if the parent node is a container that supports children\n        if ('children' in parent && Array.isArray(parent.children)) {\n          nextNodes[parentId] = {\n            ...parent,\n            // Use Set to prevent duplicate IDs if createNode is called twice\n            children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here\n          }\n        }\n      } else if (!parentId) {\n        // 3. Handle Root nodes\n        if (!nextRootIds.includes(newNode.id)) {\n          nextRootIds.push(newNode.id)\n        }\n      }\n    }\n\n    return { nodes: nextNodes, rootNodeIds: nextRootIds }\n  })\n\n  // 4. System Sync\n  ops.forEach(({ node, parentId }) => {\n    get().markDirty(node.id)\n    if (parentId) get().markDirty(parentId)\n  })\n}\n\nexport const updateNodesAction = (\n  set: (fn: (state: SceneState) => Partial<SceneState>) => void,\n  get: () => SceneState,\n  updates: { id: AnyNodeId; data: Partial<AnyNode> }[],\n) => {\n  if (get().readOnly) return\n  const parentsToUpdate = new Set<AnyNodeId>()\n  const idsToMarkDirty = new Set<AnyNodeId>()\n\n  set((state) => {\n    const nextNodes = { ...state.nodes }\n\n    for (const { id, data } of updates) {\n      const currentNode = nextNodes[id]\n      if (!currentNode) continue\n\n      // Handle Reparenting Logic\n      if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {\n        // 1. Remove from old parent\n        const oldParentId = currentNode.parentId as AnyNodeId | null\n        if (oldParentId && nextNodes[oldParentId]) {\n          const oldParent = nextNodes[oldParentId] as AnyContainerNode\n          nextNodes[oldParent.id] = {\n            ...oldParent,\n            children: oldParent.children.filter((childId) => childId !== id),\n          } as AnyNode\n          parentsToUpdate.add(oldParent.id)\n        }\n\n        // 2. Add to new parent\n        const newParentId = data.parentId as AnyNodeId | null\n        if (newParentId && nextNodes[newParentId]) {\n          const newParent = nextNodes[newParentId] as AnyContainerNode\n          nextNodes[newParent.id] = {\n            ...newParent,\n            children: Array.from(new Set([...newParent.children, id])),\n          } as AnyNode\n          parentsToUpdate.add(newParent.id)\n        }\n      }\n\n      // Apply the update\n      nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode\n    }\n\n    return { nodes: nextNodes }\n  })\n\n  // Collect all IDs that need to be marked dirty\n  for (const u of updates) {\n    idsToMarkDirty.add(u.id)\n  }\n  for (const pId of parentsToUpdate) {\n    idsToMarkDirty.add(pId)\n  }\n\n  // Add to pending updates set\n  for (const id of idsToMarkDirty) {\n    pendingUpdates.add(id)\n  }\n\n  // Cancel any pending RAF and schedule a new one\n  if (pendingRafId !== null) {\n    cancelAnimationFrame(pendingRafId)\n  }\n\n  pendingRafId = requestAnimationFrame(() => {\n    // Mark all pending updates as dirty\n    pendingUpdates.forEach((id) => {\n      get().markDirty(id)\n    })\n    pendingUpdates.clear()\n    pendingRafId = null\n  })\n}\n\nexport const deleteNodesAction = (\n  set: (fn: (state: SceneState) => Partial<SceneState>) => void,\n  get: () => SceneState,\n  ids: AnyNodeId[],\n) => {\n  if (get().readOnly) return\n  const parentsToMarkDirty = new Set<AnyNodeId>()\n\n  set((state) => {\n    const nextNodes = { ...state.nodes }\n    const nextCollections = { ...state.collections }\n    let nextRootIds = [...state.rootNodeIds]\n\n    // Collect all IDs to delete (including descendants) in a first pass\n    // This avoids issues with recursive calls during state mutation\n    const allIdsToDelete = new Set<AnyNodeId>()\n    const collectDescendants = (id: AnyNodeId) => {\n      const node = nextNodes[id]\n      if (!node) return\n      allIdsToDelete.add(id)\n      if ('children' in node && node.children) {\n        for (const childId of node.children as AnyNodeId[]) {\n          collectDescendants(childId)\n        }\n      }\n    }\n\n    for (const id of ids) {\n      collectDescendants(id)\n    }\n\n    // Now process all nodes for deletion\n    for (const id of allIdsToDelete) {\n      const node = nextNodes[id]\n      if (!node) continue\n\n      // 1. Remove reference from Parent\n      const parentId = node.parentId as AnyNodeId | null\n      if (parentId && nextNodes[parentId]) {\n        const parent = nextNodes[parentId] as AnyContainerNode\n        if (parent.children) {\n          nextNodes[parent.id] = {\n            ...parent,\n            children: parent.children.filter((cid) => cid !== id),\n          } as AnyNode\n          parentsToMarkDirty.add(parent.id)\n        }\n      }\n\n      // 2. Remove from Root list\n      nextRootIds = nextRootIds.filter((rid) => rid !== id)\n\n      // 3. Remove from any collections it belongs to\n      if ('collectionIds' in node && node.collectionIds) {\n        for (const cid of node.collectionIds as CollectionId[]) {\n          const col = nextCollections[cid]\n          if (col) {\n            nextCollections[cid] = { ...col, nodeIds: col.nodeIds.filter((nid) => nid !== id) }\n          }\n        }\n      }\n\n      // 4. Delete the node itself\n      delete nextNodes[id]\n    }\n\n    return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }\n  })\n\n  // Mark affected nodes dirty: parents of deleted nodes and their remaining children\n  // (e.g. deleting a slab affects sibling walls via level elevation changes)\n  parentsToMarkDirty.forEach((parentId) => {\n    get().markDirty(parentId)\n    const parent = get().nodes[parentId]\n    if (parent && 'children' in parent && Array.isArray(parent.children)) {\n      for (const childId of parent.children) {\n        get().markDirty(childId as AnyNodeId)\n      }\n    }\n  })\n}\n"
  },
  {
    "path": "packages/core/src/store/use-interactive.ts",
    "content": "'use client'\n\nimport { create } from 'zustand'\nimport type { Interactive } from '../schema/nodes/item'\nimport type { AnyNodeId } from '../schema/types'\n\n// Runtime value for each control (matches discriminated union kinds)\nexport type ControlValue = boolean | number\n\nexport type ItemInteractiveState = {\n  // Indexed by control position in asset.interactive.controls[]\n  controlValues: ControlValue[]\n}\n\ntype InteractiveStore = {\n  items: Record<AnyNodeId, ItemInteractiveState>\n\n  /** Initialize a node's interactive state from its asset definition (idempotent) */\n  initItem: (itemId: AnyNodeId, interactive: Interactive) => void\n\n  /** Set a single control value */\n  setControlValue: (itemId: AnyNodeId, index: number, value: ControlValue) => void\n\n  /** Remove a node's state (e.g. on unmount) */\n  removeItem: (itemId: AnyNodeId) => void\n}\n\nconst defaultControlValue = (interactive: Interactive, index: number): ControlValue => {\n  const control = interactive.controls[index]\n  if (!control) return false\n  switch (control.kind) {\n    case 'toggle':\n      return control.default ?? false\n    case 'slider':\n      return control.default ?? control.min\n    case 'temperature':\n      return control.default ?? control.min\n  }\n}\n\nexport const useInteractive = create<InteractiveStore>((set, get) => ({\n  items: {},\n\n  initItem: (itemId, interactive) => {\n    const { controls } = interactive\n    if (controls.length === 0) return\n\n    // Don't overwrite existing state (idempotent)\n    if (get().items[itemId]) return\n\n    set((state) => ({\n      items: {\n        ...state.items,\n        [itemId]: {\n          controlValues: controls.map((_, i) => defaultControlValue(interactive, i)),\n        },\n      },\n    }))\n  },\n\n  setControlValue: (itemId, index, value) => {\n    set((state) => {\n      const item = state.items[itemId]\n      if (!item) return state\n      const next = [...item.controlValues]\n      next[index] = value\n      return { items: { ...state.items, [itemId]: { controlValues: next } } }\n    })\n  },\n\n  removeItem: (itemId) => {\n    set((state) => {\n      const { [itemId]: _, ...rest } = state.items\n      return { items: rest }\n    })\n  },\n}))\n"
  },
  {
    "path": "packages/core/src/store/use-scene.ts",
    "content": "'use client'\n\nimport type { TemporalState } from 'zundo'\nimport { temporal } from 'zundo'\nimport { create, type StoreApi, type UseBoundStore } from 'zustand'\nimport { BuildingNode } from '../schema'\nimport type { Collection, CollectionId } from '../schema/collections'\nimport { generateCollectionId } from '../schema/collections'\nimport { LevelNode } from '../schema/nodes/level'\nimport { SiteNode } from '../schema/nodes/site'\nimport type { AnyNode, AnyNodeId } from '../schema/types'\nimport * as nodeActions from './actions/node-actions'\n\nfunction migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {\n  const patchedNodes = { ...nodes }\n  for (const [id, node] of Object.entries(patchedNodes)) {\n    // 1. Item scale migration\n    if (node.type === 'item' && !('scale' in node)) {\n      patchedNodes[id] = { ...node, scale: [1, 1, 1] }\n    }\n    // 2. Old roof to new roof + segment migration\n    if (node.type === 'roof' && !('children' in node)) {\n      const oldRoof = node\n      const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2)\n      const segmentId = `rseg_${suffix}`\n\n      const segment = {\n        object: 'node',\n        id: segmentId,\n        type: 'roof-segment',\n        parentId: id,\n        visible: oldRoof.visible ?? true,\n        metadata: {},\n        position: [0, 0, 0],\n        rotation: 0,\n        roofType: 'gable',\n        width: oldRoof.length ?? 8,\n        depth: (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2),\n        wallHeight: 0,\n        roofHeight: oldRoof.height ?? 2.5,\n        wallThickness: 0.1,\n        deckThickness: 0.1,\n        overhang: 0.3,\n        shingleThickness: 0.05,\n      }\n\n      patchedNodes[segmentId] = segment\n      patchedNodes[id] = {\n        ...oldRoof,\n        children: [segmentId],\n      }\n    }\n  }\n  return patchedNodes as Record<string, AnyNode>\n}\n\nexport type SceneState = {\n  // 1. The Data: A flat dictionary of all nodes\n  nodes: Record<AnyNodeId, AnyNode>\n\n  // 2. The Root: Which nodes are at the top level?\n  rootNodeIds: AnyNodeId[]\n\n  // 3. The \"Dirty\" Set: For the Wall/Physics systems\n  dirtyNodes: Set<AnyNodeId>\n\n  // 4. Relational metadata — not nodes\n  collections: Record<CollectionId, Collection>\n\n  // 5. Read-only lock — when true all create/update/delete operations are no-ops\n  readOnly: boolean\n  setReadOnly: (readOnly: boolean) => void\n\n  // Actions\n  loadScene: () => void\n  clearScene: () => void\n  unloadScene: () => void\n  setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void\n\n  markDirty: (id: AnyNodeId) => void\n  clearDirty: (id: AnyNodeId) => void\n\n  createNode: (node: AnyNode, parentId?: AnyNodeId) => void\n  createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void\n\n  updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void\n  updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void\n\n  deleteNode: (id: AnyNodeId) => void\n  deleteNodes: (ids: AnyNodeId[]) => void\n\n  // Collection actions\n  createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId\n  deleteCollection: (id: CollectionId) => void\n  updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void\n  addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void\n  removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void\n}\n\n// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;\n\ntype UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {\n  temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>\n}\n\nconst useScene: UseSceneStore = create<SceneState>()(\n  temporal(\n    (set, get) => ({\n      // 1. Flat dictionary of all nodes\n      nodes: {},\n\n      // 2. Root node IDs\n      rootNodeIds: [],\n\n      // 3. Dirty set\n      dirtyNodes: new Set<AnyNodeId>(),\n\n      // 4. Collections\n      collections: {} as Record<CollectionId, Collection>,\n\n      // 5. Read-only lock\n      readOnly: false,\n      setReadOnly: (readOnly: boolean) => set({ readOnly }),\n\n      unloadScene: () => {\n        // Clear temporal tracking to prevent memory leaks from stale node references\n        prevPastLength = 0\n        prevFutureLength = 0\n        prevNodesSnapshot = null\n\n        set({\n          nodes: {},\n          rootNodeIds: [],\n          dirtyNodes: new Set<AnyNodeId>(),\n          collections: {},\n        })\n      },\n\n      clearScene: () => {\n        get().unloadScene()\n        get().loadScene() // Default scene\n      },\n\n      setScene: (nodes, rootNodeIds) => {\n        // Apply backward compatibility migrations\n        const patchedNodes = migrateNodes(nodes)\n\n        set({\n          nodes: patchedNodes,\n          rootNodeIds,\n          dirtyNodes: new Set<AnyNodeId>(),\n          collections: {},\n        })\n        // Mark all nodes as dirty to trigger re-validation\n        Object.values(patchedNodes).forEach((node) => {\n          get().markDirty(node.id)\n        })\n      },\n\n      loadScene: () => {\n        if (get().rootNodeIds.length > 0) {\n          // Assign all nodes as dirty to force re-validation\n          Object.values(get().nodes).forEach((node) => {\n            get().markDirty(node.id)\n          })\n          return // Scene already loaded\n        }\n\n        // Create hierarchy: Site → Building → Level\n        const level0 = LevelNode.parse({\n          level: 0,\n          children: [],\n        })\n\n        const building = BuildingNode.parse({\n          children: [level0.id],\n        })\n\n        const site = SiteNode.parse({\n          children: [building],\n        })\n\n        // Define all nodes flat\n        const nodes: Record<AnyNodeId, AnyNode> = {\n          [site.id]: site,\n          [building.id]: building,\n          [level0.id]: level0,\n        }\n\n        // Site is the root\n        const rootNodeIds = [site.id]\n\n        set({ nodes, rootNodeIds })\n      },\n\n      markDirty: (id) => {\n        get().dirtyNodes.add(id)\n      },\n\n      clearDirty: (id) => {\n        get().dirtyNodes.delete(id)\n      },\n\n      createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),\n      createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),\n\n      updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),\n      updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),\n\n      // --- DELETE ---\n\n      deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),\n\n      deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),\n\n      // --- COLLECTIONS ---\n\n      createCollection: (name, nodeIds = []) => {\n        if (get().readOnly) return '' as CollectionId\n        const id = generateCollectionId()\n        const collection: Collection = { id, name, nodeIds }\n        set((state) => {\n          const nextCollections = { ...state.collections, [id]: collection }\n          // Denormalize: stamp collectionId onto each node\n          const nextNodes = { ...state.nodes }\n          for (const nodeId of nodeIds) {\n            const node = nextNodes[nodeId]\n            if (!node) continue\n            const existing =\n              ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []\n            nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode\n          }\n          return { collections: nextCollections, nodes: nextNodes }\n        })\n        return id\n      },\n\n      deleteCollection: (id) => {\n        if (get().readOnly) return\n        set((state) => {\n          const col = state.collections[id]\n          const nextCollections = { ...state.collections }\n          delete nextCollections[id]\n          // Remove collectionId from all member nodes\n          const nextNodes = { ...state.nodes }\n          for (const nodeId of col?.nodeIds ?? []) {\n            const node = nextNodes[nodeId]\n            if (!(node && 'collectionIds' in node)) continue\n            nextNodes[nodeId] = {\n              ...node,\n              collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),\n            } as AnyNode\n          }\n          return { collections: nextCollections, nodes: nextNodes }\n        })\n      },\n\n      updateCollection: (id, data) => {\n        if (get().readOnly) return\n        set((state) => {\n          const col = state.collections[id]\n          if (!col) return state\n          return { collections: { ...state.collections, [id]: { ...col, ...data } } }\n        })\n      },\n\n      addToCollection: (id, nodeId) => {\n        if (get().readOnly) return\n        set((state) => {\n          const col = state.collections[id]\n          if (!col || col.nodeIds.includes(nodeId)) return state\n          const nextCollections = {\n            ...state.collections,\n            [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },\n          }\n          const node = state.nodes[nodeId]\n          if (!node) return { collections: nextCollections }\n          const existing =\n            ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []\n          const nextNodes = {\n            ...state.nodes,\n            [nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,\n          }\n          return { collections: nextCollections, nodes: nextNodes }\n        })\n      },\n\n      removeFromCollection: (id, nodeId) => {\n        if (get().readOnly) return\n        set((state) => {\n          const col = state.collections[id]\n          if (!col) return state\n          const nextCollections = {\n            ...state.collections,\n            [id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },\n          }\n          const node = state.nodes[nodeId]\n          if (!(node && 'collectionIds' in node)) return { collections: nextCollections }\n          const nextNodes = {\n            ...state.nodes,\n            [nodeId]: {\n              ...node,\n              collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),\n            } as AnyNode,\n          }\n          return { collections: nextCollections, nodes: nextNodes }\n        })\n      },\n    }),\n    {\n      partialize: (state) => {\n        const { nodes, rootNodeIds, collections } = state\n        return { nodes, rootNodeIds, collections }\n      },\n      limit: 50, // Limit to last 50 actions\n    },\n  ),\n)\n\nexport default useScene\n\n// Track previous temporal state lengths and node snapshot for diffing\nlet prevPastLength = 0\nlet prevFutureLength = 0\nlet prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null\n\n/**\n * Clears temporal history tracking variables to prevent memory leaks.\n * Should be called when unloading a scene to release node references.\n */\nexport function clearTemporalTracking() {\n  prevPastLength = 0\n  prevFutureLength = 0\n  prevNodesSnapshot = null\n}\n\nexport function clearSceneHistory() {\n  useScene.temporal.getState().clear()\n  clearTemporalTracking()\n}\n\n// Subscribe to the temporal store (Undo/Redo events)\nuseScene.temporal.subscribe((state) => {\n  const currentPastLength = state.pastStates.length\n  const currentFutureLength = state.futureStates.length\n\n  // Undo: futureStates increases (state moved from past to future)\n  // Redo: pastStates increases while futureStates decreases (state moved from future to past)\n  const didUndo = currentFutureLength > prevFutureLength\n  const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength\n\n  if (didUndo || didRedo) {\n    // Capture the previous snapshot before RAF fires\n    const snapshotBefore = prevNodesSnapshot\n\n    // Use RAF to ensure all middleware and store updates are complete\n    requestAnimationFrame(() => {\n      const currentNodes = useScene.getState().nodes\n      const { markDirty } = useScene.getState()\n\n      if (snapshotBefore) {\n        // Diff: only mark nodes that actually changed\n        for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) {\n          if (snapshotBefore[id] !== node) {\n            markDirty(id)\n            // Also mark parent so merged geometries update\n            if (node.parentId) markDirty(node.parentId as AnyNodeId)\n          }\n        }\n        // Nodes that were deleted (exist in prev but not current)\n        for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) {\n          if (!currentNodes[id]) {\n            const parentId = node.parentId as AnyNodeId | undefined\n            if (parentId) {\n              markDirty(parentId)\n              // Mark sibling nodes dirty so they can update their geometry\n              // (e.g. adjacent walls need to recalculate miter/junction geometry)\n              const parent = currentNodes[parentId]\n              if (parent && 'children' in parent) {\n                for (const childId of (parent as AnyNode & { children: string[] }).children) {\n                  markDirty(childId as AnyNodeId)\n                }\n              }\n            }\n          }\n        }\n      } else {\n        // No snapshot to diff against — fall back to marking all\n        for (const node of Object.values(currentNodes)) {\n          markDirty(node.id)\n        }\n      }\n    })\n  }\n\n  // Update tracked lengths and snapshot\n  prevPastLength = currentPastLength\n  prevFutureLength = currentFutureLength\n  prevNodesSnapshot = useScene.getState().nodes\n})\n"
  },
  {
    "path": "packages/core/src/systems/ceiling/ceiling-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNodeId, CeilingNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\n// ============================================================================\n// CEILING SYSTEM\n// ============================================================================\n\nexport const CeilingSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n\n    const nodes = useScene.getState().nodes\n    // Process dirty ceilings\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'ceiling') return\n\n      const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n      if (mesh) {\n        updateCeilingGeometry(node as CeilingNode, mesh)\n        clearDirty(id as AnyNodeId)\n      }\n      // If mesh not found, keep it dirty for next frame\n    })\n  })\n\n  return null\n}\n\n/**\n * Updates the geometry for a single ceiling\n */\nfunction updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {\n  const newGeo = generateCeilingGeometry(node)\n\n  mesh.geometry.dispose()\n  mesh.geometry = newGeo\n\n  const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh\n  if (gridMesh) {\n    gridMesh.geometry.dispose()\n    gridMesh.geometry = newGeo\n  }\n\n  // Position at the ceiling height\n  mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs\n}\n\n/**\n * Generates flat ceiling geometry from polygon (no extrusion)\n */\nexport function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry {\n  const polygon = ceilingNode.polygon\n\n  if (polygon.length < 3) {\n    return new THREE.BufferGeometry()\n  }\n\n  // Create shape from polygon\n  // Shape is in X-Y plane, we'll rotate to X-Z plane\n  const shape = new THREE.Shape()\n  const firstPt = polygon[0]!\n\n  // Negate Y (which becomes Z) to get correct orientation after rotation\n  shape.moveTo(firstPt[0], -firstPt[1])\n\n  for (let i = 1; i < polygon.length; i++) {\n    const pt = polygon[i]!\n    shape.lineTo(pt[0], -pt[1])\n  }\n  shape.closePath()\n\n  // Add holes to the shape\n  const holes = ceilingNode.holes || []\n  for (const holePolygon of holes) {\n    if (holePolygon.length < 3) continue\n\n    const holePath = new THREE.Path()\n    const holeFirstPt = holePolygon[0]!\n    holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])\n\n    for (let i = 1; i < holePolygon.length; i++) {\n      const pt = holePolygon[i]!\n      holePath.lineTo(pt[0], -pt[1])\n    }\n    holePath.closePath()\n\n    shape.holes.push(holePath)\n  }\n\n  // Create flat shape geometry (no extrusion)\n  const geometry = new THREE.ShapeGeometry(shape)\n\n  // Rotate so the shape lies flat in X-Z plane\n  geometry.rotateX(-Math.PI / 2)\n  geometry.computeVertexNormals()\n\n  return geometry\n}\n"
  },
  {
    "path": "packages/core/src/systems/door/door-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNodeId, DoorNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\nconst baseMaterial = new MeshStandardNodeMaterial({\n  name: 'door-base',\n  color: '#f2f0ed',\n  roughness: 0.5,\n  metalness: 0,\n})\n\nconst glassMaterial = new MeshStandardNodeMaterial({\n  name: 'door-glass',\n  color: 'lightblue',\n  roughness: 0.05,\n  metalness: 0.1,\n  transparent: true,\n  opacity: 0.35,\n  side: DoubleSide,\n  depthWrite: false,\n})\n\n// Invisible material for root mesh — used as selection hitbox only\nconst hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })\n\nexport const DoorSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'door') return\n\n      const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n      if (!mesh) return // Keep dirty until mesh mounts\n\n      updateDoorMesh(node as DoorNode, mesh)\n      clearDirty(id as AnyNodeId)\n\n      // Rebuild the parent wall so its cutout reflects the updated door geometry\n      if ((node as DoorNode).parentId) {\n        useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId)\n      }\n    })\n  }, 3)\n\n  return null\n}\n\nfunction addBox(\n  parent: THREE.Object3D,\n  material: THREE.Material,\n  w: number,\n  h: number,\n  d: number,\n  x: number,\n  y: number,\n  z: number,\n) {\n  const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)\n  m.position.set(x, y, z)\n  parent.add(m)\n}\n\nfunction updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {\n  // Root mesh is an invisible hitbox; all visuals live in child meshes\n  mesh.geometry.dispose()\n  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)\n  mesh.material = hitboxMaterial\n\n  // Sync transform from node (React may lag behind the system by a frame during drag)\n  mesh.position.set(node.position[0], node.position[1], node.position[2])\n  mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])\n\n  // Dispose and remove all old visual children; preserve 'cutout'\n  for (const child of [...mesh.children]) {\n    if (child.name === 'cutout') continue\n    if (child instanceof THREE.Mesh) child.geometry.dispose()\n    mesh.remove(child)\n  }\n\n  const {\n    width,\n    height,\n    frameThickness,\n    frameDepth,\n    threshold,\n    thresholdHeight,\n    segments,\n    handle,\n    handleHeight,\n    handleSide,\n    doorCloser,\n    panicBar,\n    panicBarHeight,\n    contentPadding,\n    hingesSide,\n  } = node\n\n  // Leaf occupies the full opening (no bottom frame bar — door opens to floor)\n  const leafW = width - 2 * frameThickness\n  const leafH = height - frameThickness // only top frame\n  const leafDepth = 0.04\n  // Leaf center is shifted down from door center by half the top frame\n  const leafCenterY = -frameThickness / 2\n\n  // ── Frame members ──\n  // Left post — full height\n  addBox(\n    mesh,\n    baseMaterial,\n    frameThickness,\n    height,\n    frameDepth,\n    -width / 2 + frameThickness / 2,\n    0,\n    0,\n  )\n  // Right post — full height\n  addBox(\n    mesh,\n    baseMaterial,\n    frameThickness,\n    height,\n    frameDepth,\n    width / 2 - frameThickness / 2,\n    0,\n    0,\n  )\n  // Head (top bar) — full width\n  addBox(\n    mesh,\n    baseMaterial,\n    width,\n    frameThickness,\n    frameDepth,\n    0,\n    height / 2 - frameThickness / 2,\n    0,\n  )\n\n  // ── Threshold (inside the frame) ──\n  if (threshold) {\n    addBox(\n      mesh,\n      baseMaterial,\n      leafW,\n      thresholdHeight,\n      frameDepth,\n      0,\n      -height / 2 + thresholdHeight / 2,\n      0,\n    )\n  }\n\n  // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──\n  const cpX = contentPadding[0]\n  const cpY = contentPadding[1]\n  if (cpY > 0) {\n    // Top strip\n    addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)\n    // Bottom strip\n    addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)\n  }\n  if (cpX > 0) {\n    const innerH = leafH - 2 * cpY\n    // Left strip\n    addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)\n    // Right strip\n    addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0)\n  }\n\n  // Content area inside padding\n  const contentW = leafW - 2 * cpX\n  const contentH = leafH - 2 * cpY\n\n  // ── Segments (stacked top to bottom within content area) ──\n  const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0)\n  const contentTop = leafCenterY + contentH / 2\n\n  let segY = contentTop\n  for (const seg of segments) {\n    const segH = (seg.heightRatio / totalRatio) * contentH\n    const segCenterY = segY - segH / 2\n\n    const numCols = seg.columnRatios.length\n    const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)\n    const usableW = contentW - (numCols - 1) * seg.dividerThickness\n    const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW)\n\n    // Column x-centers (relative to mesh center)\n    const colXCenters: number[] = []\n    let cx = -contentW / 2\n    for (let c = 0; c < numCols; c++) {\n      colXCenters.push(cx + colWidths[c]! / 2)\n      cx += colWidths[c]!\n      if (c < numCols - 1) cx += seg.dividerThickness\n    }\n\n    // Column dividers within this segment\n    cx = -contentW / 2\n    for (let c = 0; c < numCols - 1; c++) {\n      cx += colWidths[c]!\n      addBox(\n        mesh,\n        baseMaterial,\n        seg.dividerThickness,\n        segH,\n        leafDepth + 0.001,\n        cx + seg.dividerThickness / 2,\n        segCenterY,\n        0,\n      )\n      cx += seg.dividerThickness\n    }\n\n    // Segment content per column\n    for (let c = 0; c < numCols; c++) {\n      const colW = colWidths[c]!\n      const colX = colXCenters[c]!\n\n      if (seg.type === 'glass') {\n        // Glass only — no opaque backing so it's truly transparent\n        const glassDepth = Math.max(0.004, leafDepth * 0.15)\n        addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)\n      } else if (seg.type === 'panel') {\n        // Opaque leaf backing for this column\n        addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)\n        // Raised panel detail\n        const panelW = colW - 2 * seg.panelInset\n        const panelH = segH - 2 * seg.panelInset\n        if (panelW > 0.01 && panelH > 0.01) {\n          const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)\n          const panelZ = leafDepth / 2 + effectiveDepth / 2\n          addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)\n        }\n      } else {\n        // 'empty' — opaque backing, no detail\n        addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)\n      }\n    }\n\n    segY -= segH\n  }\n\n  // ── Handle ──\n  if (handle) {\n    // Convert from floor-based height to mesh-center-based Y\n    const handleY = handleHeight - height / 2\n    // Handle grip sits on the front face (+Z) of the leaf\n    const faceZ = leafDepth / 2\n\n    // X position: handleSide refers to which side the grip is on\n    const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045\n\n    // Backplate\n    addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)\n    // Grip lever\n    addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)\n  }\n\n  // ── Door closer (commercial hardware at top) ──\n  if (doorCloser) {\n    const closerY = leafCenterY + leafH / 2 - 0.04\n    // Body\n    addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)\n    // Arm (simplified as thin bar to frame side)\n    addBox(\n      mesh,\n      baseMaterial,\n      0.14,\n      0.015,\n      0.015,\n      leafW / 4,\n      closerY + 0.025,\n      leafDepth / 2 + 0.015,\n    )\n  }\n\n  // ── Panic bar ──\n  if (panicBar) {\n    const barY = panicBarHeight - height / 2\n    addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)\n  }\n\n  // ── Hinges (3 knuckle-style hinges on the hinge side) ──\n  {\n    const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012\n    const hingeZ = 0 // centered in leaf depth\n    const hingeH = 0.1\n    const hingeW = 0.024\n    const hingeD = leafDepth + 0.016\n    // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top\n    const leafBottom = leafCenterY - leafH / 2\n    const leafTop = leafCenterY + leafH / 2\n    addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ)\n    addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)\n    addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)\n  }\n\n  // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──\n  let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined\n  if (!cutout) {\n    cutout = new THREE.Mesh()\n    cutout.name = 'cutout'\n    mesh.add(cutout)\n  }\n  cutout.geometry.dispose()\n  cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)\n  cutout.visible = false\n}\n"
  },
  {
    "path": "packages/core/src/systems/item/item-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport type * as THREE from 'three'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'\nimport { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'\nimport { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\n// ============================================================================\n// ITEM SYSTEM\n// ============================================================================\n\nexport const ItemSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n    const nodes = useScene.getState().nodes\n\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'item') return\n\n      const item = node as ItemNode\n      const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D\n      if (!mesh) return\n\n      if (item.asset.attachTo === 'wall-side') {\n        // Wall-attached item: offset Z by half the parent wall's thickness\n        const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined\n        if (parentWall && parentWall.type === 'wall') {\n          const wallThickness = (parentWall as WallNode).thickness ?? 0.1\n          const side = item.side === 'front' ? 1 : -1\n          mesh.position.z = (wallThickness / 2) * side\n        }\n      } else if (!item.asset.attachTo) {\n        // If parented to another item (surface placement), R3F handles positioning via the hierarchy\n        const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined\n        if (parentNode?.type !== 'item') {\n          // Floor item: elevate by slab height (using full footprint overlap)\n          const levelId = resolveLevelId(item, nodes)\n          const slabElevation = spatialGridManager.getSlabElevationForItem(\n            levelId,\n            item.position,\n            getScaledDimensions(item),\n            item.rotation,\n          )\n          mesh.position.y = slabElevation + item.position[1]\n        }\n      }\n\n      clearDirty(id as AnyNodeId)\n    })\n  }, 2)\n\n  return null\n}\n"
  },
  {
    "path": "packages/core/src/systems/roof/roof-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'\nimport { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'\nimport { computeBoundsTree } from 'three-mesh-bvh'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '../../schema'\nimport type { RoofType } from '../../schema/nodes/roof-segment'\nimport useScene from '../../store/use-scene'\n\nconst csgEvaluator = new Evaluator()\ncsgEvaluator.useGroups = true\ncsgEvaluator.attributes = ['position', 'normal']\n\n// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry\nconst _matrix = new THREE.Matrix4()\nconst _position = new THREE.Vector3()\nconst _quaternion = new THREE.Quaternion()\nconst _scale = new THREE.Vector3(1, 1, 1)\nconst _yAxis = new THREE.Vector3(0, 1, 0)\n\n// Pending merged-roof updates carried across frames (for throttling)\nconst pendingRoofUpdates = new Set<AnyNodeId>()\nconst MAX_ROOFS_PER_FRAME = 1\nconst MAX_SEGMENTS_PER_FRAME = 3\n\n// ============================================================================\n// ROOF SYSTEM\n// ============================================================================\n\nexport const RoofSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n\n  useFrame(() => {\n    // Clear stale pending updates when the scene is unloaded\n    if (rootNodeIds.length === 0) {\n      pendingRoofUpdates.clear()\n      return\n    }\n\n    if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    // --- Pass 1: Process dirty roof-segments (throttled) ---\n    let segmentsProcessed = 0\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node) return\n\n      if (node.type === 'roof-segment') {\n        const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n        if (mesh) {\n          // Only compute expensive individual CSG when the segment is actually rendered\n          // (its parent group is visible = the roof is selected for editing)\n          const isVisible = mesh.parent?.visible !== false\n          if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {\n            updateRoofSegmentGeometry(node as RoofSegmentNode, mesh)\n            segmentsProcessed++\n          } else if (isVisible) {\n            return // Over budget — keep dirty, process next frame\n          } else {\n            // Just sync transform, skip CSG — the merged roof handles visuals.\n            // But replace the initial BoxGeometry once: it has 6 groups (materialIndex 0-5)\n            // while roofMaterials only has 4 entries. Three.js raycasts into invisible groups,\n            // so MeshBVH hits groups[4].materialIndex → undefined.side → crash.\n            if (mesh.geometry.type === 'BoxGeometry') {\n              mesh.geometry.dispose()\n              const placeholder = new THREE.BufferGeometry()\n              placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))\n              placeholder.computeBoundsTree = computeBoundsTree\n              placeholder.computeBoundsTree({ maxLeafSize: 10 })\n              mesh.geometry = placeholder\n            }\n            mesh.position.set(node.position[0], node.position[1], node.position[2])\n            mesh.rotation.y = node.rotation\n          }\n          clearDirty(id as AnyNodeId)\n        }\n        // Queue the parent roof for a merged geometry update\n        if (node.parentId) {\n          pendingRoofUpdates.add(node.parentId as AnyNodeId)\n        }\n      } else if (node.type === 'roof') {\n        pendingRoofUpdates.add(id as AnyNodeId)\n        clearDirty(id as AnyNodeId)\n      }\n    })\n\n    // --- Pass 2: Process pending merged-roof updates (max 1 per frame) ---\n    let roofsProcessed = 0\n    for (const id of pendingRoofUpdates) {\n      if (roofsProcessed >= MAX_ROOFS_PER_FRAME) break\n\n      const node = nodes[id]\n      if (!node || node.type !== 'roof') {\n        pendingRoofUpdates.delete(id)\n        continue\n      }\n      const group = sceneRegistry.nodes.get(id) as THREE.Group\n      if (group) {\n        const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined\n        if (mergedMesh?.visible !== false) {\n          // Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit\n          updateMergedRoofGeometry(node as RoofNode, group, nodes)\n          roofsProcessed++\n        }\n      }\n      pendingRoofUpdates.delete(id)\n    }\n  }, 5) // Priority 5: run after all other systems have settled\n\n  return null\n}\n\n// ============================================================================\n// GEOMETRY GENERATION\n// ============================================================================\n\nfunction updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {\n  const newGeo = generateRoofSegmentGeometry(node)\n\n  mesh.geometry.dispose()\n  mesh.geometry = newGeo\n  newGeo.computeBoundsTree = computeBoundsTree\n  newGeo.computeBoundsTree({ maxLeafSize: 10 })\n\n  mesh.position.set(node.position[0], node.position[1], node.position[2])\n  mesh.rotation.y = node.rotation\n}\n\nfunction updateMergedRoofGeometry(\n  roofNode: RoofNode,\n  group: THREE.Group,\n  nodes: Record<string, AnyNode>,\n) {\n  const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined\n  if (!mergedMesh) return\n\n  const children = (roofNode.children ?? [])\n    .map((id) => nodes[id] as RoofSegmentNode)\n    .filter(Boolean)\n\n  if (children.length === 0) {\n    mergedMesh.geometry.dispose()\n    // Keep a valid position attribute so Drei's BVH can index safely.\n    mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0)\n    return\n  }\n\n  let totalShinSlab: Brush | null = null\n  let totalDeckSlab: Brush | null = null\n  let totalWall: Brush | null = null\n  let totalInner: Brush | null = null\n\n  for (const child of children) {\n    const brushes = getRoofSegmentBrushes(child)\n    if (!brushes) continue\n\n    _matrix.compose(\n      _position.set(child.position[0], child.position[1], child.position[2]),\n      _quaternion.setFromAxisAngle(_yAxis, child.rotation),\n      _scale,\n    )\n\n    const applyTransform = (brush: Brush) => {\n      brush.geometry.applyMatrix4(_matrix)\n      brush.updateMatrixWorld()\n    }\n\n    applyTransform(brushes.shinSlab)\n    applyTransform(brushes.deckSlab)\n    applyTransform(brushes.wallBrush)\n    applyTransform(brushes.innerBrush)\n\n    if (totalShinSlab) {\n      const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush\n      totalShinSlab.geometry.dispose()\n      brushes.shinSlab.geometry.dispose()\n      totalShinSlab = next\n    } else {\n      totalShinSlab = brushes.shinSlab\n    }\n\n    if (totalDeckSlab) {\n      const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush\n      totalDeckSlab.geometry.dispose()\n      brushes.deckSlab.geometry.dispose()\n      totalDeckSlab = next\n    } else {\n      totalDeckSlab = brushes.deckSlab\n    }\n\n    if (totalWall) {\n      const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush\n      totalWall.geometry.dispose()\n      brushes.wallBrush.geometry.dispose()\n      totalWall = next\n    } else {\n      totalWall = brushes.wallBrush\n    }\n\n    if (totalInner) {\n      const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush\n      totalInner.geometry.dispose()\n      brushes.innerBrush.geometry.dispose()\n      totalInner = next\n    } else {\n      totalInner = brushes.innerBrush\n    }\n  }\n\n  if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {\n    try {\n      const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION)\n      const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION)\n      const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION)\n\n      const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION)\n      const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)\n\n      const resultGeo = combined.geometry\n\n      const resultMaterials: THREE.Material[] = Array.isArray(combined.material)\n        ? combined.material\n        : [combined.material]\n\n      const matToIndex = new Map<THREE.Material, number>([\n        [dummyMats[0], 0],\n        [dummyMats[1], 1],\n        [dummyMats[2], 2],\n        [dummyMats[3], 3],\n      ])\n\n      for (const g of resultGeo.groups) {\n        g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)\n      }\n\n      resultGeo.computeVertexNormals()\n      mergedMesh.geometry.dispose()\n      mergedMesh.geometry = resultGeo\n\n      finalShinTrimmed.geometry.dispose()\n      finalDeckTrimmed.geometry.dispose()\n      finalWallTrimmed.geometry.dispose()\n      shinDeck.geometry.dispose()\n    } catch (e) {\n      console.error('Merged roof CSG failed:', e)\n    }\n\n    totalShinSlab.geometry.dispose()\n    totalDeckSlab.geometry.dispose()\n    totalWall.geometry.dispose()\n    totalInner.geometry.dispose()\n  }\n}\n\nconst dummyMats: [\n  THREE.MeshBasicMaterial,\n  THREE.MeshBasicMaterial,\n  THREE.MeshBasicMaterial,\n  THREE.MeshBasicMaterial,\n] = [\n  new THREE.MeshBasicMaterial(),\n  new THREE.MeshBasicMaterial(),\n  new THREE.MeshBasicMaterial(),\n  new THREE.MeshBasicMaterial(),\n]\nconst ROOF_MATERIAL_SLOT_COUNT = 4\n\nfunction mapRoofGroupMaterialIndex(\n  groupMaterialIndex: number | undefined,\n  csgMaterials: THREE.Material[],\n  matToIndex: Map<THREE.Material, number>,\n): number {\n  if (groupMaterialIndex === undefined) return 0\n  const sourceMaterial = csgMaterials[groupMaterialIndex]\n  const mappedIndex = sourceMaterial ? matToIndex.get(sourceMaterial) : undefined\n  return mappedIndex ?? 0\n}\n\nfunction normalizeRoofMaterialIndex(materialIndex: number | undefined): number {\n  if (materialIndex === undefined || !Number.isFinite(materialIndex)) return 0\n  const normalized = Math.trunc(materialIndex)\n  if (normalized < 0 || normalized >= ROOF_MATERIAL_SLOT_COUNT) return 0\n  return normalized\n}\n\nconst SHINGLE_SURFACE_EPSILON = 0.02\nconst RAKE_FACE_NORMAL_EPSILON = 0.3\nconst RAKE_FACE_ALIGNMENT_EPSILON = 0.35\n\n/**\n * Generate complete hollow-shell geometry for a roof segment.\n * Ports the prototype's CSG approach using three-bvh-csg.\n */\nexport function getRoofSegmentBrushes(\n  node: RoofSegmentNode,\n): { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush } | null {\n  const {\n    roofType,\n    width,\n    depth,\n    wallHeight,\n    roofHeight,\n    wallThickness,\n    deckThickness,\n    overhang,\n    shingleThickness,\n  } = node\n\n  const activeRh = roofType === 'flat' ? 0 : roofHeight\n\n  let run = Math.min(width, depth) / 2\n  let rise = activeRh\n  if (roofType === 'shed') {\n    run = depth\n  }\n  if (roofType === 'gable') {\n    run = depth / 2\n  }\n  if (roofType === 'gambrel') {\n    run = depth / 4\n    rise = activeRh * 0.6\n  }\n  if (roofType === 'mansard') {\n    run = Math.min(width, depth) * 0.15\n    rise = activeRh * 0.7\n  }\n  if (roofType === 'dutch') {\n    run = Math.min(width, depth) * 0.25\n    rise = activeRh * 0.5\n  }\n\n  const tanTheta = run > 0 ? rise / run : 0\n  const cosTheta = Math.cos(Math.atan2(rise, run)) || 1\n  const sinTheta = Math.sin(Math.atan2(rise, run)) || 0\n\n  const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness\n  const baseI = Math.min(width, depth) * 0.25\n\n  const getVol = (\n    wExt: number,\n    vOffset: number,\n    baseY: number,\n    matIndex: number,\n    isVoid: boolean,\n  ) => {\n    const wV = Math.max(0.01, width + 2 * wExt)\n    const dV = Math.max(0.01, depth + 2 * wExt)\n\n    const autoDrop = wExt * tanTheta\n    const whV = wallHeight - autoDrop + vOffset\n\n    let rhV = activeRh\n    if (activeRh > 0) {\n      rhV = activeRh + autoDrop\n      if (roofType === 'shed') rhV = activeRh + 2 * autoDrop\n    }\n\n    const safeBaseY = Math.min(baseY, whV - 0.05)\n\n    let structuralI = baseI\n    if (isVoid) {\n      structuralI += deckThickness\n    }\n\n    const faces = getModuleFaces(\n      roofType,\n      wV,\n      dV,\n      whV,\n      rhV,\n      safeBaseY,\n      { dutchI: structuralI },\n      width,\n      depth,\n      tanTheta,\n    )\n    return createGeometryFromFaces(faces, matIndex)\n  }\n\n  const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false)\n  const innerGeo = getVol(-wallThickness / 2, 0, -5, 2, false)\n\n  const horizontalOverhang = overhang * cosTheta\n  const deckExt = wallThickness / 2 + horizontalOverhang\n\n  const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false)\n  const deckBotGeo = getVol(deckExt, 0, -5, 0, true)\n\n  const stSin = shingleThickness * sinTheta\n  const stCos = shingleThickness * cosTheta\n\n  const shinBotW = Math.max(0.01, width + 2 * deckExt)\n  const shinBotD = Math.max(0.01, depth + 2 * deckExt)\n\n  const deckDrop = deckExt * tanTheta\n  const shinBotWh = wallHeight - deckDrop + verticalRt\n\n  let shinBotRh = activeRh\n  if (activeRh > 0) {\n    shinBotRh = activeRh + deckDrop\n    if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop\n  }\n\n  let shinTopW = shinBotW\n  let shinTopD = shinBotD\n  let transZ = 0\n\n  if (['hip', 'mansard', 'dutch'].includes(roofType)) {\n    shinTopW += 2 * stSin\n    shinTopD += 2 * stSin\n  } else if (['gable', 'gambrel'].includes(roofType)) {\n    shinTopD += 2 * stSin\n  } else if (roofType === 'shed') {\n    shinTopD += stSin\n    transZ = stSin / 2\n  }\n\n  const shinTopWh = shinBotWh + stCos\n\n  let shinTopRh = shinBotRh\n  if (activeRh > 0) {\n    shinTopRh = shinBotRh + stSin * tanTheta\n  }\n\n  const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95\n  const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2.0\n  const dropTop = Math.min(1.0, maxDrop * 0.4)\n  const dropBot = Math.min(2.0, maxDrop * 0.8)\n\n  const topBaseY = shinBotWh - dropTop\n  const botBaseY = shinBotWh - dropBot\n\n  const getInsets = (wh: number, bY: number, isVoid: boolean, brushW: number, brushD: number) => {\n    let inset = (wh - bY) * tanTheta\n    const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005\n    if (inset > maxSafeInset) {\n      inset = maxSafeInset\n    }\n\n    let iF = 0,\n      iB = 0,\n      iL = 0,\n      iR = 0\n    if (['hip', 'mansard', 'dutch'].includes(roofType)) {\n      iF = inset\n      iB = inset\n      iL = inset\n      iR = inset\n    } else if (['gable', 'gambrel'].includes(roofType)) {\n      iF = inset\n      iB = inset\n    } else if (roofType === 'shed') {\n      iF = inset\n    }\n\n    let structuralI = baseI\n    if (isVoid) {\n      structuralI += shingleThickness\n    }\n    return { iF, iB, iL, iR, dutchI: structuralI }\n  }\n\n  const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD)\n  const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD)\n\n  const botFaces = getModuleFaces(\n    roofType,\n    shinBotW,\n    shinBotD,\n    shinBotWh,\n    shinBotRh,\n    botBaseY,\n    insetsBot,\n    width,\n    depth,\n    tanTheta,\n  )\n  const topFaces = getModuleFaces(\n    roofType,\n    shinTopW,\n    shinTopD,\n    shinTopWh,\n    shinTopRh,\n    topBaseY,\n    insetsTop,\n    width,\n    depth,\n    tanTheta,\n  )\n\n  const shinBotGeo = createGeometryFromFaces(botFaces, 1)\n  const shinTopGeo = createGeometryFromFaces(topFaces, (normal) =>\n    normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1,\n  )\n\n  if (transZ !== 0) {\n    shinTopGeo.translate(0, 0, transZ)\n  }\n\n  const toBrush = (geo: THREE.BufferGeometry): Brush | null => {\n    if (!geo?.attributes.position || geo.attributes.position.count === 0) return null\n    if (!geo.index) return null\n    geo.computeBoundsTree = computeBoundsTree\n    geo.computeBoundsTree({ maxLeafSize: 10 })\n    const brush = new Brush(geo, dummyMats)\n    brush.updateMatrixWorld()\n    return brush\n  }\n\n  const eps = 0.002\n\n  const wallBrush = toBrush(wallGeo)\n  const innerBrush = toBrush(innerGeo)\n  if (innerBrush) {\n    const wV = Math.max(0.01, width - wallThickness)\n    const dV = Math.max(0.01, depth - wallThickness)\n    innerBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV)\n    innerBrush.updateMatrixWorld()\n  }\n\n  const deckTopBrush = toBrush(deckTopGeo)\n  const deckBotBrush = toBrush(deckBotGeo)\n  if (deckBotBrush) {\n    const wV = Math.max(0.01, width + 2 * deckExt)\n    const dV = Math.max(0.01, depth + 2 * deckExt)\n    deckBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV)\n    deckBotBrush.updateMatrixWorld()\n  }\n\n  const shinTopBrush = toBrush(shinTopGeo)\n  const shinBotBrush = toBrush(shinBotGeo)\n  if (shinBotBrush) {\n    const wV = shinBotW\n    const dV = shinBotD\n    shinBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV)\n    shinBotBrush.updateMatrixWorld()\n  }\n\n  wallGeo.dispose()\n  innerGeo.dispose()\n  deckTopGeo.dispose()\n  deckBotGeo.dispose()\n  shinTopGeo.dispose()\n  shinBotGeo.dispose()\n\n  if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) {\n    try {\n      const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION)\n      const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION)\n\n      deckTopBrush.geometry.dispose()\n      deckBotBrush.geometry.dispose()\n      shinTopBrush.geometry.dispose()\n      shinBotBrush.geometry.dispose()\n\n      return { deckSlab, shinSlab, wallBrush, innerBrush }\n    } catch (e) {\n      console.error('CSG prep failed:', e)\n    }\n  }\n\n  if (deckTopBrush) deckTopBrush.geometry.dispose()\n  if (deckBotBrush) deckBotBrush.geometry.dispose()\n  if (shinTopBrush) shinTopBrush.geometry.dispose()\n  if (shinBotBrush) shinBotBrush.geometry.dispose()\n  if (wallBrush) wallBrush.geometry.dispose()\n  if (innerBrush) innerBrush.geometry.dispose()\n\n  return null\n}\n\nexport function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry {\n  const brushes = getRoofSegmentBrushes(node)\n  if (!brushes) {\n    // Fallback: simple box\n    return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth)\n  }\n\n  const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes\n  let resultGeo = new THREE.BufferGeometry()\n\n  try {\n    const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION)\n    const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION)\n    const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION)\n\n    resultGeo = combined.geometry\n\n    const resultMaterials: THREE.Material[] = Array.isArray(combined.material)\n      ? combined.material\n      : [combined.material]\n\n    const matToIndex = new Map<THREE.Material, number>([\n      [dummyMats[0], 0],\n      [dummyMats[1], 1],\n      [dummyMats[2], 2],\n      [dummyMats[3], 3],\n    ])\n\n    for (const group of resultGeo.groups) {\n      group.materialIndex = mapRoofGroupMaterialIndex(\n        group.materialIndex,\n        resultMaterials,\n        matToIndex,\n      )\n    }\n\n    remapRoofShellFaces(resultGeo, node)\n\n    hollowWall.geometry.dispose()\n    shinDeck.geometry.dispose()\n  } catch (e) {\n    console.error('Roof CSG failed:', e)\n    resultGeo = wallBrush.geometry.clone()\n  }\n\n  deckSlab.geometry.dispose()\n  shinSlab.geometry.dispose()\n  wallBrush.geometry.dispose()\n  innerBrush.geometry.dispose()\n\n  resultGeo.computeVertexNormals()\n  return resultGeo\n}\n\n// ============================================================================\n// FACE-BASED GEOMETRY HELPERS (ported from prototype)\n// ============================================================================\n\ntype Insets = {\n  iF?: number\n  iB?: number\n  iL?: number\n  iR?: number\n  dutchI?: number\n}\n\nfunction remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSegmentNode) {\n  const position = geometry.getAttribute('position')\n  const index = geometry.getIndex()\n\n  if (!(position && index) || index.count === 0 || geometry.groups.length === 0) return\n\n  geometry.computeBoundingBox()\n\n  const triangleCount = index.count / 3\n  const triangleMaterials = new Array<number>(triangleCount).fill(0)\n  const a = new THREE.Vector3()\n  const b = new THREE.Vector3()\n  const c = new THREE.Vector3()\n  const ab = new THREE.Vector3()\n  const ac = new THREE.Vector3()\n  const centroid = new THREE.Vector3()\n  const normal = new THREE.Vector3()\n\n  for (const group of geometry.groups) {\n    const startTriangle = Math.floor(group.start / 3)\n    const endTriangle = Math.min(triangleCount, Math.floor((group.start + group.count) / 3))\n\n    for (let triangleIndex = startTriangle; triangleIndex < endTriangle; triangleIndex++) {\n      const indexOffset = triangleIndex * 3\n      let materialIndex = normalizeRoofMaterialIndex(group.materialIndex)\n\n      if (materialIndex === 1 || materialIndex === 3) {\n        const ia = index.getX(indexOffset)\n        const ib = index.getX(indexOffset + 1)\n        const ic = index.getX(indexOffset + 2)\n\n        a.fromBufferAttribute(position, ia)\n        b.fromBufferAttribute(position, ib)\n        c.fromBufferAttribute(position, ic)\n\n        ab.subVectors(b, a)\n        ac.subVectors(c, a)\n        normal.crossVectors(ab, ac).normalize()\n\n        centroid\n          .copy(a)\n          .add(b)\n          .add(c)\n          .multiplyScalar(1 / 3)\n\n        if (normal.y > SHINGLE_SURFACE_EPSILON) {\n          materialIndex = 3\n        } else if (isRakeFace(node, geometry, centroid, normal)) {\n          materialIndex = 0\n        } else {\n          materialIndex = 1\n        }\n      }\n\n      triangleMaterials[triangleIndex] = materialIndex\n    }\n  }\n\n  geometry.clearGroups()\n\n  let currentMaterial = triangleMaterials[0] ?? 0\n  let groupStart = 0\n\n  for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex++) {\n    const materialIndex = triangleMaterials[triangleIndex] ?? 0\n    if (materialIndex === currentMaterial) continue\n\n    geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)\n    groupStart = triangleIndex\n    currentMaterial = materialIndex\n  }\n\n  geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)\n}\n\nfunction isRakeFace(\n  node: RoofSegmentNode,\n  geometry: THREE.BufferGeometry,\n  centroid: THREE.Vector3,\n  normal: THREE.Vector3,\n) {\n  const rakeAxis = getRakeAxis(node)\n  const bounds = geometry.boundingBox\n\n  if (!(rakeAxis && bounds)) return false\n  if (Math.abs(normal.y) > RAKE_FACE_NORMAL_EPSILON) return false\n\n  const axisNormal = rakeAxis === 'x' ? Math.abs(normal.x) : Math.abs(normal.z)\n  if (axisNormal < RAKE_FACE_ALIGNMENT_EPSILON) return false\n\n  const halfExtent =\n    rakeAxis === 'x'\n      ? Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x))\n      : Math.max(Math.abs(bounds.min.z), Math.abs(bounds.max.z))\n  const axisCoord = rakeAxis === 'x' ? Math.abs(centroid.x) : Math.abs(centroid.z)\n  const planeTolerance = Math.max(\n    node.overhang + node.wallThickness + node.deckThickness + node.shingleThickness,\n    0.25,\n  )\n\n  if (halfExtent - axisCoord > planeTolerance) return false\n\n  return true\n}\n\nfunction getRakeAxis(node: RoofSegmentNode): 'x' | 'z' | null {\n  if (node.roofType === 'gable' || node.roofType === 'gambrel') return 'x'\n  if (node.roofType === 'dutch') return node.width >= node.depth ? 'x' : 'z'\n  return null\n}\n\n/**\n * Generates faces for a roof module volume.\n * Supports: hip, gable, shed, gambrel, dutch, mansard, flat.\n */\nfunction getModuleFaces(\n  type: RoofType,\n  w: number,\n  d: number,\n  wh: number,\n  rh: number,\n  baseY: number,\n  insets: Insets,\n  baseW: number,\n  baseD: number,\n  tanTheta: number,\n): THREE.Vector3[][] {\n  const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z)\n  const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets\n\n  const b1 = v(-w / 2 + iL, baseY, d / 2 - iF)\n  const b2 = v(w / 2 - iR, baseY, d / 2 - iF)\n  const b3 = v(w / 2 - iR, baseY, -d / 2 + iB)\n  const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB)\n  const bottom = [b4, b3, b2, b1]\n\n  const e1 = v(-w / 2, wh, d / 2)\n  const e2 = v(w / 2, wh, d / 2)\n  const e3 = v(w / 2, wh, -d / 2)\n  const e4 = v(-w / 2, wh, -d / 2)\n\n  const faces: THREE.Vector3[][] = []\n  faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)\n\n  const h = wh + Math.max(0.001, rh)\n\n  if (type === 'flat' || rh === 0) {\n    faces.push([e1, e2, e3, e4])\n  } else if (type === 'gable') {\n    const r1 = v(-w / 2, h, 0)\n    const r2 = v(w / 2, h, 0)\n    faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])\n  } else if (type === 'hip') {\n    if (Math.abs(w - d) < 0.01) {\n      const r = v(0, h, 0)\n      faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r])\n    } else if (w >= d) {\n      const r1 = v(-w / 2 + d / 2, h, 0)\n      const r2 = v(w / 2 - d / 2, h, 0)\n      faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])\n    } else {\n      const r1 = v(0, h, d / 2 - w / 2)\n      const r2 = v(0, h, -d / 2 + w / 2)\n      faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])\n    }\n  } else if (type === 'shed') {\n    const t1 = v(-w / 2, h, -d / 2)\n    const t2 = v(w / 2, h, -d / 2)\n    faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])\n  } else if (type === 'gambrel') {\n    const mz = (baseD / 2) * 0.5\n    const dist = d / 2 - mz\n    const mh = wh + dist * (tanTheta || 0)\n\n    const m1 = v(-w / 2, mh, mz)\n    const m2 = v(w / 2, mh, mz)\n    const m3 = v(w / 2, mh, -mz)\n    const m4 = v(-w / 2, mh, -mz)\n    const r1 = v(-w / 2, h, 0)\n    const r2 = v(w / 2, h, 0)\n    faces.push(\n      [e4, e1, m1, r1, m4],\n      [e2, e3, m3, r2, m2],\n      [e1, e2, m2, m1],\n      [m1, m2, r2, r1],\n      [e3, e4, m4, m3],\n      [m3, m4, r1, r2],\n    )\n  } else if (type === 'mansard') {\n    const i = Math.min(baseW, baseD) * 0.15\n    const mh = wh + i * (tanTheta || 0)\n\n    const m1 = v(-w / 2 + i, mh, d / 2 - i)\n    const m2 = v(w / 2 - i, mh, d / 2 - i)\n    const m3 = v(w / 2 - i, mh, -d / 2 + i)\n    const m4 = v(-w / 2 + i, mh, -d / 2 + i)\n    const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2)\n    const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2)\n    const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2)\n    const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2)\n    if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {\n      if (w >= d) {\n        const r1 = v(-w / 2 + d / 2, h, 0)\n        const r2 = v(w / 2 - d / 2, h, 0)\n        faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])\n      } else {\n        const r1 = v(0, h, d / 2 - w / 2)\n        const r2 = v(0, h, -d / 2 + w / 2)\n        faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])\n      }\n    } else {\n      faces.push(\n        [t1, t2, t3, t4],\n        [e1, e2, m2, m1],\n        [e2, e3, m3, m2],\n        [e3, e4, m4, m3],\n        [e4, e1, m1, m4],\n        [m1, m2, t2, t1],\n        [m2, m3, t3, t2],\n        [m3, m4, t4, t3],\n        [m4, m1, t1, t4],\n      )\n    }\n  } else if (type === 'dutch') {\n    const i = insets.dutchI !== undefined ? insets.dutchI : Math.min(baseW, baseD) * 0.25\n    const mh = wh + i * (tanTheta || 0)\n\n    if (w >= d) {\n      const m1 = v(-w / 2 + i, mh, d / 2 - i)\n      const m2 = v(w / 2 - i, mh, d / 2 - i)\n      const m3 = v(w / 2 - i, mh, -d / 2 + i)\n      const m4 = v(-w / 2 + i, mh, -d / 2 + i)\n      const r1 = v(-w / 2 + i, h, 0)\n      const r2 = v(w / 2 - i, h, 0)\n\n      faces.push(\n        [e1, e2, m2, m1],\n        [e2, e3, m3, m2],\n        [e3, e4, m4, m3],\n        [e4, e1, m1, m4],\n        [m4, m1, r1],\n        [m2, m3, r2],\n        [m1, m2, r2, r1],\n        [m3, m4, r1, r2],\n      )\n    } else {\n      const m1 = v(-w / 2 + i, mh, d / 2 - i)\n      const m2 = v(w / 2 - i, mh, d / 2 - i)\n      const m3 = v(w / 2 - i, mh, -d / 2 + i)\n      const m4 = v(-w / 2 + i, mh, -d / 2 + i)\n      const r1 = v(0, h, d / 2 - i)\n      const r2 = v(0, h, -d / 2 + i)\n\n      faces.push(\n        [e1, e2, m2, m1],\n        [e2, e3, m3, m2],\n        [e3, e4, m4, m3],\n        [e4, e1, m1, m4],\n        [m1, m2, r1],\n        [m3, m4, r2],\n        [m2, m3, r2, r1],\n        [m4, m1, r1, r2],\n      )\n    }\n  }\n\n  return faces\n}\n\n/**\n * Converts an array of face polygons into a BufferGeometry.\n * Each face is triangulated via fan triangulation.\n */\nfunction createGeometryFromFaces(\n  faces: THREE.Vector3[][],\n  matRule: number | ((normal: THREE.Vector3) => number) | null = null,\n): THREE.BufferGeometry {\n  const positions: number[] = []\n  const normals: number[] = []\n  const indices: number[] = []\n  const groups: { start: number; count: number; materialIndex: number }[] = []\n  let vertexCount = 0\n\n  for (const face of faces) {\n    if (face.length < 3) continue\n\n    const p0 = face[0]!\n    const p1 = face[1]!\n    const p2 = face[2]!\n    const vA = new THREE.Vector3().subVectors(p1, p0)\n    const vB = new THREE.Vector3().subVectors(p2, p0)\n    const normal = new THREE.Vector3().crossVectors(vA, vB).normalize()\n\n    let assignedMatIndex = 0\n    if (typeof matRule === 'function') {\n      assignedMatIndex = matRule(normal)\n    } else if (matRule !== null && matRule !== undefined) {\n      assignedMatIndex = matRule\n    } else {\n      const isVertical = Math.abs(normal.y) < 0.01\n      assignedMatIndex = isVertical ? 0 : 1\n    }\n\n    let faceVertexCount = 0\n    const startVertexCount = vertexCount\n\n    for (let i = 1; i < face.length - 1; i++) {\n      const fi = face[i]!\n      const fi1 = face[i + 1]!\n      positions.push(p0.x, p0.y, p0.z)\n      positions.push(fi.x, fi.y, fi.z)\n      positions.push(fi1.x, fi1.y, fi1.z)\n\n      normals.push(normal.x, normal.y, normal.z)\n      normals.push(normal.x, normal.y, normal.z)\n      normals.push(normal.x, normal.y, normal.z)\n\n      indices.push(vertexCount, vertexCount + 1, vertexCount + 2)\n\n      faceVertexCount += 3\n      vertexCount += 3\n    }\n\n    groups.push({\n      start: startVertexCount,\n      count: faceVertexCount,\n      materialIndex: assignedMatIndex,\n    })\n  }\n\n  const geometry = new THREE.BufferGeometry()\n  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))\n  geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))\n  geometry.setIndex(indices)\n\n  for (const g of groups) {\n    geometry.addGroup(g.start, g.count, g.materialIndex)\n  }\n\n  // Merge identical vertices to optimize geometry for CSG and create clean topology\n  const mergedGeo = mergeVertices(geometry, 1e-4)\n  geometry.dispose()\n\n  return mergedGeo\n}\n"
  },
  {
    "path": "packages/core/src/systems/slab/slab-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNodeId, SlabNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\n// ============================================================================\n// SLAB SYSTEM\n// ============================================================================\n\nexport const SlabSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    // Process dirty slabs\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'slab') return\n\n      const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n      if (mesh) {\n        updateSlabGeometry(node as SlabNode, mesh)\n        clearDirty(id as AnyNodeId)\n      }\n      // If mesh not found, keep it dirty for next frame\n    })\n  }, 1)\n\n  return null\n}\n\n/**\n * Updates the geometry for a single slab\n */\nfunction updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {\n  const newGeo = generateSlabGeometry(node)\n\n  mesh.geometry.dispose()\n  mesh.geometry = newGeo\n}\n\n/** Half of default wall thickness — used to extend slab geometry under walls */\nconst SLAB_OUTSET = 0.05\n\n/**\n * Expand a polygon outward by a uniform distance.\n * Offsets each edge outward then intersects consecutive offset edges.\n */\nfunction outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> {\n  const n = polygon.length\n  if (n < 3) return polygon\n\n  // Determine winding via signed area\n  let area2 = 0\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1]\n  }\n  const s = area2 >= 0 ? 1 : -1\n\n  // Offset each edge outward by amount\n  const offEdges: Array<[number, number, number, number]> = []\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const dx = polygon[j]![0] - polygon[i]![0]\n    const dz = polygon[j]![1] - polygon[i]![1]\n    const len = Math.sqrt(dx * dx + dz * dz)\n    if (len < 1e-9) {\n      offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])\n      continue\n    }\n    const nx = ((s * dz) / len) * amount\n    const nz = ((s * -dx) / len) * amount\n    offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])\n  }\n\n  // Intersect consecutive offset edges to get new vertices\n  const result: Array<[number, number]> = []\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const [ax, az, adx, adz] = offEdges[i]!\n    const [bx, bz, bdx, bdz] = offEdges[j]!\n    const denom = adx * bdz - adz * bdx\n    if (Math.abs(denom) < 1e-9) {\n      // Parallel edges — use offset endpoint\n      result.push([ax + adx, az + adz])\n    } else {\n      const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom\n      result.push([ax + t * adx, az + t * adz])\n    }\n  }\n\n  return result\n}\n\n/**\n * Generates extruded slab geometry from polygon\n */\nexport function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {\n  const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)\n  const elevation = slabNode.elevation ?? 0.05\n\n  if (polygon.length < 3) {\n    return new THREE.BufferGeometry()\n  }\n\n  // Create shape from polygon\n  // Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion\n  const shape = new THREE.Shape()\n  const firstPt = polygon[0]!\n\n  // Negate Y (which becomes Z) to get correct orientation after rotation\n  shape.moveTo(firstPt[0], -firstPt[1])\n\n  for (let i = 1; i < polygon.length; i++) {\n    const pt = polygon[i]!\n    shape.lineTo(pt[0], -pt[1])\n  }\n  shape.closePath()\n\n  // Add holes to the shape\n  const holes = slabNode.holes || []\n  for (const holePolygon of holes) {\n    if (holePolygon.length < 3) continue\n\n    const holePath = new THREE.Path()\n    const holeFirstPt = holePolygon[0]!\n    holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])\n\n    for (let i = 1; i < holePolygon.length; i++) {\n      const pt = holePolygon[i]!\n      holePath.lineTo(pt[0], -pt[1])\n    }\n    holePath.closePath()\n\n    shape.holes.push(holePath)\n  }\n\n  // Extrude the shape by elevation\n  const geometry = new THREE.ExtrudeGeometry(shape, {\n    depth: elevation,\n    bevelEnabled: false,\n  })\n\n  // Rotate so extrusion direction (Z) becomes height direction (Y)\n  geometry.rotateX(-Math.PI / 2)\n  geometry.computeVertexNormals()\n\n  return geometry\n}\n"
  },
  {
    "path": "packages/core/src/systems/stair/stair-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\nconst pendingStairUpdates = new Set<AnyNodeId>()\nconst MAX_STAIRS_PER_FRAME = 2\nconst MAX_SEGMENTS_PER_FRAME = 4\n\n// ============================================================================\n// STAIR SYSTEM\n// ============================================================================\n\nexport const StairSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n\n  useFrame(() => {\n    if (rootNodeIds.length === 0) {\n      pendingStairUpdates.clear()\n      return\n    }\n\n    if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    // --- Pass 1: Process dirty stair-segments (throttled) ---\n    // Collect parent stair IDs that need segment transform recomputation\n    const parentsNeedingSegmentSync = new Set<AnyNodeId>()\n\n    let segmentsProcessed = 0\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node) return\n\n      if (node.type === 'stair-segment') {\n        const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n        if (mesh) {\n          const isVisible = mesh.parent?.visible !== false\n          if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {\n            // Geometry will be updated; chained position is applied in the parent sync pass below\n            updateStairSegmentGeometry(node as StairSegmentNode, mesh)\n            if (node.parentId) parentsNeedingSegmentSync.add(node.parentId as AnyNodeId)\n            segmentsProcessed++\n          } else if (isVisible) {\n            return // Over budget — keep dirty, process next frame\n          } else if (mesh.geometry.type === 'BoxGeometry') {\n            // Replace BoxGeometry placeholder with empty geometry\n            mesh.geometry.dispose()\n            const placeholder = new THREE.BufferGeometry()\n            placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))\n            mesh.geometry = placeholder\n          }\n          clearDirty(id as AnyNodeId)\n        } else {\n          clearDirty(id as AnyNodeId)\n        }\n        // Queue the parent stair for a merged geometry update\n        if (node.parentId) {\n          pendingStairUpdates.add(node.parentId as AnyNodeId)\n        }\n      } else if (node.type === 'stair') {\n        pendingStairUpdates.add(id as AnyNodeId)\n        // Also sync individual segment positions when in edit mode\n        parentsNeedingSegmentSync.add(id as AnyNodeId)\n        clearDirty(id as AnyNodeId)\n      }\n    })\n\n    // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---\n    for (const stairId of parentsNeedingSegmentSync) {\n      const stairNode = nodes[stairId]\n      if (!stairNode || stairNode.type !== 'stair') continue\n      syncSegmentMeshTransforms(stairNode as StairNode, nodes)\n    }\n\n    // --- Pass 2: Process pending merged-stair updates (throttled) ---\n    let stairsProcessed = 0\n    for (const id of pendingStairUpdates) {\n      if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break\n\n      const node = nodes[id]\n      if (!node || node.type !== 'stair') {\n        pendingStairUpdates.delete(id)\n        continue\n      }\n      const group = sceneRegistry.nodes.get(id) as THREE.Group\n      if (group) {\n        const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined\n        if (mergedMesh?.visible !== false) {\n          updateMergedStairGeometry(node as StairNode, group, nodes)\n          stairsProcessed++\n        }\n      }\n      pendingStairUpdates.delete(id)\n    }\n  }, 5)\n\n  return null\n}\n\n// ============================================================================\n// SEGMENT GEOMETRY\n// ============================================================================\n\n/**\n * Generates the step/landing profile as a THREE.Shape (in the XY plane),\n * then extrudes along Z for the segment width.\n */\nfunction generateStairSegmentGeometry(\n  segment: StairSegmentNode,\n  absoluteHeight: number,\n): THREE.BufferGeometry {\n  const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment\n\n  const shape = new THREE.Shape()\n\n  if (segmentType === 'landing') {\n    shape.moveTo(0, 0)\n    shape.lineTo(length, 0)\n\n    if (fillToFloor) {\n      shape.lineTo(length, -absoluteHeight)\n      shape.lineTo(0, -absoluteHeight)\n    } else {\n      shape.lineTo(length, -thickness)\n      shape.lineTo(0, -thickness)\n    }\n  } else {\n    const riserHeight = height / stepCount\n    const treadDepth = length / stepCount\n\n    shape.moveTo(0, 0)\n\n    // Draw step profile\n    for (let i = 0; i < stepCount; i++) {\n      shape.lineTo(i * treadDepth, (i + 1) * riserHeight)\n      shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)\n    }\n\n    if (fillToFloor) {\n      shape.lineTo(length, -absoluteHeight)\n      shape.lineTo(0, -absoluteHeight)\n    } else {\n      // Sloped bottom with consistent thickness\n      const angle = Math.atan(riserHeight / treadDepth)\n      const vOff = thickness / Math.cos(angle)\n\n      // Bottom-back corner\n      shape.lineTo(length, height - vOff)\n\n      if (absoluteHeight === 0) {\n        // Ground floor: slope hits the ground (y=0)\n        const m = riserHeight / treadDepth\n        const xGround = length - (height - vOff) / m\n\n        if (xGround > 0) {\n          shape.lineTo(xGround, 0)\n        }\n      } else {\n        // Floating: parallel slope\n        shape.lineTo(0, -vOff)\n      }\n    }\n  }\n\n  shape.lineTo(0, 0)\n\n  const geometry = new THREE.ExtrudeGeometry(shape, {\n    steps: 1,\n    depth: width,\n    bevelEnabled: false,\n  })\n\n  // Rotate so extrusion is along X (width), and the shape is in the XZ plane\n  // Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset\n  const matrix = new THREE.Matrix4()\n  matrix.makeRotationY(-Math.PI / 2)\n  matrix.setPosition(width / 2, 0, 0)\n  geometry.applyMatrix4(matrix)\n\n  return geometry\n}\n\nfunction updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {\n  // Compute absolute height from parent chain\n  const absoluteHeight = computeAbsoluteHeight(node)\n\n  const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)\n\n  mesh.geometry.dispose()\n  mesh.geometry = newGeometry\n\n  // NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms\n  // which computes the chained position based on segment order and attachmentSide.\n}\n\n/**\n * Applies chained transforms to individual segment meshes (edit mode).\n * Each segment's world position is determined by the chain of previous segments,\n * not by the node's stored position field.\n */\nfunction syncSegmentMeshTransforms(stairNode: StairNode, nodes: Record<string, AnyNode>) {\n  const segments = (stairNode.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)\n    .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')\n\n  if (segments.length === 0) return\n\n  const transforms = computeSegmentTransforms(segments)\n\n  for (let i = 0; i < segments.length; i++) {\n    const segment = segments[i]!\n    const transform = transforms[i]!\n    const mesh = sceneRegistry.nodes.get(segment.id) as THREE.Mesh | undefined\n    if (mesh) {\n      mesh.position.set(transform.position[0], transform.position[1], transform.position[2])\n      mesh.rotation.y = transform.rotation\n    }\n  }\n}\n\n// ============================================================================\n// MERGED STAIR GEOMETRY\n// ============================================================================\n\nconst _matrix = new THREE.Matrix4()\nconst _position = new THREE.Vector3()\nconst _quaternion = new THREE.Quaternion()\nconst _scale = new THREE.Vector3(1, 1, 1)\nconst _yAxis = new THREE.Vector3(0, 1, 0)\n\nfunction updateMergedStairGeometry(\n  stairNode: StairNode,\n  group: THREE.Group,\n  nodes: Record<string, AnyNode>,\n) {\n  const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined\n  if (!mergedMesh) return\n\n  const children = stairNode.children ?? []\n  const segments = children\n    .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)\n    .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')\n\n  if (segments.length === 0) {\n    mergedMesh.geometry.dispose()\n    mergedMesh.geometry = new THREE.BufferGeometry()\n    mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))\n    return\n  }\n\n  // Compute chained transforms for segments\n  const transforms = computeSegmentTransforms(segments)\n\n  const geometries: THREE.BufferGeometry[] = []\n\n  for (let i = 0; i < segments.length; i++) {\n    const segment = segments[i]!\n    const transform = transforms[i]!\n\n    const absoluteHeight = transform.position[1]\n    const geo = generateStairSegmentGeometry(segment, absoluteHeight)\n\n    // Apply segment transform (position + rotation) relative to parent stair\n    _position.set(transform.position[0], transform.position[1], transform.position[2])\n    _quaternion.setFromAxisAngle(_yAxis, transform.rotation)\n    _matrix.compose(_position, _quaternion, _scale)\n    geo.applyMatrix4(_matrix)\n\n    geometries.push(geo)\n  }\n\n  const merged = mergeGeometries(geometries, false)\n  if (merged) {\n    mergedMesh.geometry.dispose()\n    mergedMesh.geometry = merged\n  }\n\n  // Dispose individual geometries\n  for (const geo of geometries) {\n    geo.dispose()\n  }\n}\n\n// ============================================================================\n// SEGMENT CHAINING\n// ============================================================================\n\ninterface SegmentTransform {\n  position: [number, number, number]\n  rotation: number\n}\n\n/**\n * Computes world-relative transforms for each segment by chaining\n * based on attachmentSide. This mirrors the prototype's StairSystem logic.\n */\nfunction computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {\n  const transforms: SegmentTransform[] = []\n  let currentPos = new THREE.Vector3(0, 0, 0)\n  let currentRot = 0\n\n  for (let i = 0; i < segments.length; i++) {\n    const segment = segments[i]!\n\n    if (i === 0) {\n      transforms.push({\n        position: [currentPos.x, currentPos.y, currentPos.z],\n        rotation: currentRot,\n      })\n    } else {\n      const prev = segments[i - 1]!\n      const localAttachPos = new THREE.Vector3()\n      let rotChange = 0\n\n      switch (segment.attachmentSide) {\n        case 'front':\n          localAttachPos.set(0, prev.height, prev.length)\n          rotChange = 0\n          break\n        case 'left':\n          localAttachPos.set(prev.width / 2, prev.height, prev.length / 2)\n          rotChange = Math.PI / 2\n          break\n        case 'right':\n          localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2)\n          rotChange = -Math.PI / 2\n          break\n      }\n\n      // Rotate local attachment point by previous global rotation\n      localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot)\n      currentPos = currentPos.clone().add(localAttachPos)\n      currentRot += rotChange\n\n      transforms.push({\n        position: [currentPos.x, currentPos.y, currentPos.z],\n        rotation: currentRot,\n      })\n    }\n  }\n\n  return transforms\n}\n\n/**\n * Computes the absolute Y height of a segment by traversing the stair's segment chain.\n */\nfunction computeAbsoluteHeight(node: StairSegmentNode): number {\n  const nodes = useScene.getState().nodes\n  if (!node.parentId) return 0\n\n  const parent = nodes[node.parentId as AnyNodeId]\n  if (!parent || parent.type !== 'stair') return 0\n\n  const stair = parent as StairNode\n  const segments = (stair.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)\n    .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')\n\n  const transforms = computeSegmentTransforms(segments)\n  const index = segments.findIndex((s) => s.id === node.id)\n  if (index < 0) return 0\n\n  return transforms[index]?.position[1] ?? 0\n}\n"
  },
  {
    "path": "packages/core/src/systems/wall/wall-footprint.ts",
    "content": "import type { WallNode } from '../../schema'\nimport { type Point2D, pointToKey, type WallMiterData } from './wall-mitering'\n\nexport const DEFAULT_WALL_THICKNESS = 0.1\nexport const DEFAULT_WALL_HEIGHT = 2.5\n\nexport function getWallThickness(wallNode: WallNode): number {\n  return wallNode.thickness ?? DEFAULT_WALL_THICKNESS\n}\n\nexport function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {\n  const { junctionData } = miterData\n\n  const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }\n  const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }\n  const thickness = getWallThickness(wallNode)\n  const halfT = thickness / 2\n\n  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }\n  const L = Math.sqrt(v.x * v.x + v.y * v.y)\n  if (L < 1e-9) {\n    return []\n  }\n  const nUnit = { x: -v.y / L, y: v.x / L }\n\n  const keyStart = pointToKey(wallStart)\n  const keyEnd = pointToKey(wallEnd)\n\n  const startJunction = junctionData.get(keyStart)?.get(wallNode.id)\n  const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)\n\n  const pStartLeft: Point2D = startJunction?.left || {\n    x: wallStart.x + nUnit.x * halfT,\n    y: wallStart.y + nUnit.y * halfT,\n  }\n  const pStartRight: Point2D = startJunction?.right || {\n    x: wallStart.x - nUnit.x * halfT,\n    y: wallStart.y - nUnit.y * halfT,\n  }\n\n  // Junction offsets are stored relative to the outgoing direction.\n  const pEndLeft: Point2D = endJunction?.right || {\n    x: wallEnd.x + nUnit.x * halfT,\n    y: wallEnd.y + nUnit.y * halfT,\n  }\n  const pEndRight: Point2D = endJunction?.left || {\n    x: wallEnd.x - nUnit.x * halfT,\n    y: wallEnd.y - nUnit.y * halfT,\n  }\n\n  const polygon: Point2D[] = [pStartRight, pEndRight]\n  if (endJunction) {\n    polygon.push(wallEnd)\n  }\n  polygon.push(pEndLeft, pStartLeft)\n  if (startJunction) {\n    polygon.push(wallStart)\n  }\n\n  return polygon\n}\n"
  },
  {
    "path": "packages/core/src/systems/wall/wall-mitering.ts",
    "content": "import type { WallNode } from '../../schema'\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\nexport interface Point2D {\n  x: number\n  y: number\n}\n\ninterface LineEquation {\n  a: number\n  b: number\n  c: number // ax + by + c = 0\n}\n\n// Map of wallId -> { left?: Point2D, right?: Point2D } for each junction\ntype WallIntersections = Map<string, { left?: Point2D; right?: Point2D }>\n\n// Map of junctionKey -> WallIntersections\ntype JunctionData = Map<string, WallIntersections>\n\n// ============================================================================\n// UTILITY FUNCTIONS\n// ============================================================================\n\nconst TOLERANCE = 0.001\n\nfunction pointToKey(p: Point2D, tolerance = TOLERANCE): string {\n  const snap = 1 / tolerance\n  return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}`\n}\n\nfunction createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation {\n  const a = -v.y\n  const b = v.x\n  const c = -(a * p.x + b * p.y)\n  return { a, b, c }\n}\n\n/**\n * Checks if a point lies on a wall segment (not at its endpoints)\n */\nfunction pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean {\n  const start: Point2D = { x: wall.start[0], y: wall.start[1] }\n  const end: Point2D = { x: wall.end[0], y: wall.end[1] }\n\n  // Check if point is at endpoints (those are handled separately)\n  if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false\n  if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false\n\n  // Vector from start to end\n  const v = { x: end.x - start.x, y: end.y - start.y }\n  const L = Math.sqrt(v.x * v.x + v.y * v.y)\n  if (L < 1e-9) return false\n\n  // Vector from start to point\n  const w = { x: point.x - start.x, y: point.y - start.y }\n\n  // Project point onto wall line (t is parametric position along segment)\n  const t = (v.x * w.x + v.y * w.y) / (L * L)\n\n  // Check if projection is within segment (not at endpoints)\n  if (t < tolerance || t > 1 - tolerance) return false\n\n  // Check distance from point to line\n  const projX = start.x + t * v.x\n  const projY = start.y + t * v.y\n  const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2)\n\n  return dist < tolerance\n}\n\n// ============================================================================\n// JUNCTION DETECTION (exactly like demo)\n// ============================================================================\n\ninterface Junction {\n  meetingPoint: Point2D\n  connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }>\n}\n\nfunction findJunctions(walls: WallNode[]): Map<string, Junction> {\n  const junctions = new Map<string, Junction>()\n\n  // First pass: group walls by their endpoints\n  for (const wall of walls) {\n    const startPt: Point2D = { x: wall.start[0], y: wall.start[1] }\n    const endPt: Point2D = { x: wall.end[0], y: wall.end[1] }\n\n    const keyStart = pointToKey(startPt)\n    const keyEnd = pointToKey(endPt)\n\n    if (!junctions.has(keyStart)) {\n      junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] })\n    }\n    junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' })\n\n    if (!junctions.has(keyEnd)) {\n      junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] })\n    }\n    junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' })\n  }\n\n  // Second pass: detect T-junctions (walls passing through junction points)\n  for (const [_key, junction] of junctions.entries()) {\n    for (const wall of walls) {\n      // Skip if wall already in this junction\n      if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue\n\n      // Check if junction point lies on this wall's segment (not at endpoints)\n      if (pointOnWallSegment(junction.meetingPoint, wall)) {\n        junction.connectedWalls.push({ wall, endType: 'passthrough' })\n      }\n    }\n  }\n\n  // Filter to only junctions with 2+ walls\n  const actualJunctions = new Map<string, Junction>()\n  for (const [key, junction] of junctions.entries()) {\n    if (junction.connectedWalls.length >= 2) {\n      actualJunctions.set(key, junction)\n    }\n  }\n\n  return actualJunctions\n}\n\n// ============================================================================\n// MITER CALCULATION (exactly like demo)\n// ============================================================================\n\ninterface ProcessedWall {\n  wallId: string\n  angle: number\n  edgeA: LineEquation // Left edge\n  edgeB: LineEquation // Right edge\n  isPassthrough: boolean // True if wall passes through junction (T-junction)\n}\n\nfunction calculateJunctionIntersections(\n  junction: Junction,\n  getThickness: (wall: WallNode) => number,\n): WallIntersections {\n  const { meetingPoint, connectedWalls } = junction\n  const processedWalls: ProcessedWall[] = []\n\n  for (const { wall, endType } of connectedWalls) {\n    const halfT = getThickness(wall) / 2\n\n    if (endType === 'passthrough') {\n      // For passthrough walls (T-junctions), add both directions\n      // This allows walls meeting the middle of this wall to miter against it\n      const v1 = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }\n      const v2 = { x: -v1.x, y: -v1.y }\n\n      for (const v of [v1, v2]) {\n        const L = Math.sqrt(v.x * v.x + v.y * v.y)\n        if (L < 1e-9) continue\n\n        const nUnit = { x: -v.y / L, y: v.x / L }\n        const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }\n        const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }\n\n        const edgeA = createLineFromPointAndVector(pA, v)\n        const edgeB = createLineFromPointAndVector(pB, v)\n        const angle = Math.atan2(v.y, v.x)\n\n        processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true })\n      }\n    } else {\n      // Normal wall endpoint (start or end)\n      const v =\n        endType === 'start'\n          ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }\n          : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }\n\n      const L = Math.sqrt(v.x * v.x + v.y * v.y)\n      if (L < 1e-9) continue\n\n      const nUnit = { x: -v.y / L, y: v.x / L }\n      const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }\n      const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }\n\n      const edgeA = createLineFromPointAndVector(pA, v)\n      const edgeB = createLineFromPointAndVector(pB, v)\n      const angle = Math.atan2(v.y, v.x)\n\n      processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false })\n    }\n  }\n\n  // Sort by outgoing angle\n  processedWalls.sort((a, b) => a.angle - b.angle)\n\n  const wallIntersections = new Map<string, { left?: Point2D; right?: Point2D }>()\n  const n = processedWalls.length\n\n  if (n < 2) return wallIntersections\n\n  // Calculate intersections between adjacent walls (exactly like demo)\n  for (let i = 0; i < n; i++) {\n    const wall1 = processedWalls[i]!\n    const wall2 = processedWalls[(i + 1) % n]!\n\n    // Intersect left edge of wall1 with right edge of wall2\n    const det = wall1.edgeA.a * wall2.edgeB.b - wall2.edgeB.a * wall1.edgeA.b\n\n    // If lines are parallel (det ≈ 0), skip this intersection - walls will use defaults\n    if (Math.abs(det) < 1e-9) {\n      continue\n    }\n\n    const p = {\n      x: (wall1.edgeA.b * wall2.edgeB.c - wall2.edgeB.b * wall1.edgeA.c) / det,\n      y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det,\n    }\n\n    // Only assign intersection to non-passthrough walls\n    // Passthrough walls don't receive junction data (their geometry doesn't change)\n    if (!wall1.isPassthrough) {\n      if (!wallIntersections.has(wall1.wallId)) {\n        wallIntersections.set(wall1.wallId, {})\n      }\n      wallIntersections.get(wall1.wallId)!.left = p\n    }\n\n    if (!wall2.isPassthrough) {\n      if (!wallIntersections.has(wall2.wallId)) {\n        wallIntersections.set(wall2.wallId, {})\n      }\n      wallIntersections.get(wall2.wallId)!.right = p\n    }\n  }\n\n  return wallIntersections\n}\n\n// ============================================================================\n// MAIN EXPORT\n// ============================================================================\n\nexport interface WallMiterData {\n  // Junction data keyed by junction position key\n  junctionData: JunctionData\n  // All junctions for quick lookup\n  junctions: Map<string, Junction>\n}\n\n/**\n * Calculates miter data for all walls on a level\n */\nexport function calculateLevelMiters(walls: WallNode[]): WallMiterData {\n  const getThickness = (wall: WallNode) => wall.thickness ?? 0.1\n  const junctions = findJunctions(walls)\n  const junctionData: JunctionData = new Map()\n\n  for (const [key, junction] of junctions.entries()) {\n    const wallIntersections = calculateJunctionIntersections(junction, getThickness)\n    junctionData.set(key, wallIntersections)\n  }\n\n  return { junctionData, junctions }\n}\n\n/**\n * Gets wall IDs that share junctions with the given walls\n */\nexport function getAdjacentWallIds(allWalls: WallNode[], dirtyWallIds: Set<string>): Set<string> {\n  const adjacent = new Set<string>()\n\n  for (const dirtyId of dirtyWallIds) {\n    const dirtyWall = allWalls.find((w) => w.id === dirtyId)\n    if (!dirtyWall) continue\n\n    const dirtyStart: Point2D = { x: dirtyWall.start[0], y: dirtyWall.start[1] }\n    const dirtyEnd: Point2D = { x: dirtyWall.end[0], y: dirtyWall.end[1] }\n\n    for (const wall of allWalls) {\n      if (wall.id === dirtyId) continue\n\n      const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] }\n      const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] }\n\n      // Check corner connections (endpoints meeting)\n      const startKey = pointToKey(wallStart)\n      const endKey = pointToKey(wallEnd)\n      const dirtyStartKey = pointToKey(dirtyStart)\n      const dirtyEndKey = pointToKey(dirtyEnd)\n\n      if (\n        startKey === dirtyStartKey ||\n        startKey === dirtyEndKey ||\n        endKey === dirtyStartKey ||\n        endKey === dirtyEndKey\n      ) {\n        adjacent.add(wall.id)\n        continue\n      }\n\n      // Check T-junction connections (dirty wall endpoint on other wall's segment)\n      if (pointOnWallSegment(dirtyStart, wall) || pointOnWallSegment(dirtyEnd, wall)) {\n        adjacent.add(wall.id)\n        continue\n      }\n\n      // Check reverse T-junction (other wall endpoint on dirty wall's segment)\n      if (pointOnWallSegment(wallStart, dirtyWall) || pointOnWallSegment(wallEnd, dirtyWall)) {\n        adjacent.add(wall.id)\n      }\n    }\n  }\n\n  return adjacent\n}\n\n// Re-export for backwards compatibility\nexport { pointToKey }\n"
  },
  {
    "path": "packages/core/src/systems/wall/wall-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'\nimport { computeBoundsTree } from 'three-mesh-bvh'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'\nimport { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'\nimport type { AnyNode, AnyNodeId, WallNode } from '../../schema'\nimport useScene from '../../store/use-scene'\nimport { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'\nimport {\n  calculateLevelMiters,\n  getAdjacentWallIds,\n  type Point2D,\n  type WallMiterData,\n} from './wall-mitering'\n\n// Reusable CSG evaluator for better performance\nconst csgEvaluator = new Evaluator()\n\n// ============================================================================\n// WALL SYSTEM\n// ============================================================================\n\nexport const WallSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    // Collect dirty walls and their levels\n    const dirtyWallsByLevel = new Map<string, Set<string>>()\n\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'wall') return\n\n      const levelId = node.parentId\n      if (!levelId) return\n\n      if (!dirtyWallsByLevel.has(levelId)) {\n        dirtyWallsByLevel.set(levelId, new Set())\n      }\n      dirtyWallsByLevel.get(levelId)?.add(id)\n    })\n\n    // Process each level that has dirty walls\n    for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {\n      const levelWalls = getLevelWalls(levelId)\n      const miterData = calculateLevelMiters(levelWalls)\n\n      // Update dirty walls\n      for (const wallId of dirtyWallIds) {\n        const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh\n        if (mesh) {\n          updateWallGeometry(wallId, miterData)\n          clearDirty(wallId as AnyNodeId)\n        }\n        // If mesh not found, keep it dirty for next frame\n      }\n\n      // Update adjacent walls that share junctions\n      const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds)\n      for (const wallId of adjacentWallIds) {\n        if (!dirtyWallIds.has(wallId)) {\n          const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh\n          if (mesh) {\n            updateWallGeometry(wallId, miterData)\n          }\n        }\n      }\n    }\n  }, 4)\n\n  return null\n}\n\n/**\n * Gets all walls that belong to a level\n */\nfunction getLevelWalls(levelId: string): WallNode[] {\n  const { nodes } = useScene.getState()\n  const level = nodes[levelId as AnyNodeId]\n\n  if (!level || level.type !== 'level') return []\n\n  const walls: WallNode[] = []\n  for (const childId of level.children) {\n    const child = nodes[childId]\n    if (child?.type === 'wall') {\n      walls.push(child as WallNode)\n    }\n  }\n\n  return walls\n}\n\n/**\n * Updates the geometry for a single wall\n */\nfunction updateWallGeometry(wallId: string, miterData: WallMiterData) {\n  const nodes = useScene.getState().nodes\n  const node = nodes[wallId as WallNode['id']]\n  if (!node || node.type !== 'wall') return\n\n  const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh\n  if (!mesh) return\n\n  const levelId = resolveLevelId(node, nodes)\n  const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end)\n\n  const childrenIds = node.children || []\n  const childrenNodes = childrenIds\n    .map((childId) => nodes[childId])\n    .filter((n): n is AnyNode => n !== undefined)\n\n  const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)\n\n  mesh.geometry.dispose()\n  mesh.geometry = newGeo\n  // Update collision mesh\n  const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh\n  if (collisionMesh) {\n    const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation)\n    collisionMesh.geometry.dispose()\n    collisionMesh.geometry = collisionGeo\n  }\n\n  mesh.position.set(node.start[0], slabElevation, node.start[1])\n  const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])\n  mesh.rotation.y = -angle\n}\n\n/**\n * Generates extruded wall geometry with mitering and cutouts\n *\n * Key insight from demo: polygon is built in WORLD coordinates first,\n * then we transform to wall-local for the 3D mesh.\n */\nexport function generateExtrudedWall(\n  wallNode: WallNode,\n  childrenNodes: AnyNode[],\n  miterData: WallMiterData,\n  slabElevation = 0,\n) {\n  const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }\n  const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }\n  // Positive slab: shift the whole wall up (full height preserved)\n  // Negative slab: extend wall downward so top stays fixed at wallNode.height\n  const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT\n  const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation\n\n  const thickness = getWallThickness(wallNode)\n\n  // Wall direction and normal (exactly like demo)\n  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }\n  const L = Math.sqrt(v.x * v.x + v.y * v.y)\n  if (L < 1e-9) {\n    return new THREE.BufferGeometry()\n  }\n  const polyPoints = getWallPlanFootprint(wallNode, miterData)\n  if (polyPoints.length < 3) {\n    return new THREE.BufferGeometry()\n  }\n\n  // Transform world coordinates to wall-local coordinates\n  // Wall-local: x along wall, z perpendicular (thickness direction)\n  const wallAngle = Math.atan2(v.y, v.x)\n  const cosA = Math.cos(-wallAngle)\n  const sinA = Math.sin(-wallAngle)\n\n  const worldToLocal = (worldPt: Point2D): { x: number; z: number } => {\n    const dx = worldPt.x - wallStart.x\n    const dy = worldPt.y - wallStart.y\n    return {\n      x: dx * cosA - dy * sinA,\n      z: dx * sinA + dy * cosA,\n    }\n  }\n\n  // Convert polygon to local coordinates\n  const localPoints = polyPoints.map(worldToLocal)\n\n  // Build THREE.js shape\n  // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z\n  // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z\n  const footprint = new THREE.Shape()\n  footprint.moveTo(localPoints[0]!.x, -localPoints[0]!.z)\n  for (let i = 1; i < localPoints.length; i++) {\n    footprint.lineTo(localPoints[i]!.x, -localPoints[i]!.z)\n  }\n  footprint.closePath()\n\n  // Extrude along Z by height\n  const geometry = new THREE.ExtrudeGeometry(footprint, {\n    depth: height,\n    bevelEnabled: false,\n  })\n\n  // Rotate so extrusion direction (Z) becomes height direction (Y)\n  geometry.rotateX(-Math.PI / 2)\n  geometry.computeVertexNormals()\n\n  // Apply CSG subtraction for cutouts (doors/windows)\n  const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)\n  if (cutoutBrushes.length === 0) {\n    return geometry\n  }\n\n  // Create wall brush from geometry\n  // Pre-compute BVH with new API to avoid deprecation warning\n  geometry.computeBoundsTree = computeBoundsTree\n  geometry.computeBoundsTree({ maxLeafSize: 10 })\n\n  const wallBrush = new Brush(geometry)\n  wallBrush.updateMatrixWorld()\n\n  // Subtract each cutout from the wall\n  let resultBrush = wallBrush\n  for (const cutoutBrush of cutoutBrushes) {\n    cutoutBrush.updateMatrixWorld()\n    const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION)\n    if (resultBrush !== wallBrush) {\n      resultBrush.geometry.dispose()\n    }\n    resultBrush = newResult\n  }\n\n  // Clean up\n  wallBrush.geometry.dispose()\n  for (const brush of cutoutBrushes) {\n    brush.geometry.dispose()\n  }\n\n  const resultGeometry = resultBrush.geometry\n  resultGeometry.computeVertexNormals()\n\n  return resultGeometry\n}\n\n/**\n * Collects cutout brushes from child items for CSG subtraction\n * The cutout mesh is a plane, so we extrude it into a box that goes through the wall\n */\nfunction collectCutoutBrushes(\n  wallNode: WallNode,\n  childrenNodes: AnyNode[],\n  wallThickness: number,\n): Brush[] {\n  const brushes: Brush[] = []\n  const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh\n  if (!wallMesh) return brushes\n\n  // Get wall's world matrix inverse to transform cutouts to wall-local space\n  wallMesh.updateMatrixWorld()\n  const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()\n\n  for (const child of childrenNodes) {\n    if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue\n\n    const childMesh = sceneRegistry.nodes.get(child.id)\n    if (!childMesh) continue\n\n    const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh\n    if (!cutoutMesh) continue\n\n    // Get the cutout's bounding box in world space\n    cutoutMesh.updateMatrixWorld()\n    const positions = cutoutMesh.geometry?.attributes?.position\n    if (!positions) continue\n\n    // Calculate bounds in wall-local space\n    const v3 = new THREE.Vector3()\n    let minX = Number.POSITIVE_INFINITY,\n      maxX = Number.NEGATIVE_INFINITY\n    let minY = Number.POSITIVE_INFINITY,\n      maxY = Number.NEGATIVE_INFINITY\n\n    for (let i = 0; i < positions.count; i++) {\n      v3.fromBufferAttribute(positions, i)\n      v3.applyMatrix4(cutoutMesh.matrixWorld)\n      v3.applyMatrix4(wallMatrixInverse)\n\n      minX = Math.min(minX, v3.x)\n      maxX = Math.max(maxX, v3.x)\n      minY = Math.min(minY, v3.y)\n      maxY = Math.max(maxY, v3.y)\n    }\n\n    if (!Number.isFinite(minX)) continue\n\n    // Create a box geometry that extends through the wall thickness\n    const width = maxX - minX\n    const height = maxY - minY\n    const depth = wallThickness * 2 // Extend beyond wall to ensure clean cut\n\n    const boxGeo = new THREE.BoxGeometry(width, height, depth)\n    // Position box at the center of the cutout\n    boxGeo.translate(\n      minX + width / 2,\n      minY + height / 2,\n      0, // Center on Z axis (wall thickness direction)\n    )\n\n    // Pre-compute BVH with new API to avoid deprecation warning\n    boxGeo.computeBoundsTree = computeBoundsTree\n    boxGeo.computeBoundsTree({ maxLeafSize: 10 })\n\n    const brush = new Brush(boxGeo)\n    brushes.push(brush)\n  }\n\n  return brushes\n}\n"
  },
  {
    "path": "packages/core/src/systems/window/window-system.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport * as THREE from 'three'\nimport { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'\nimport { sceneRegistry } from '../../hooks/scene-registry/scene-registry'\nimport type { AnyNodeId, WindowNode } from '../../schema'\nimport useScene from '../../store/use-scene'\n\nconst glassMaterial = new MeshStandardNodeMaterial({\n  name: 'glass',\n  color: 'lightblue',\n  roughness: 0.05,\n  metalness: 0.1,\n  transparent: true,\n  opacity: 0.3,\n  side: DoubleSide,\n  depthWrite: false,\n})\n\nconst frameMaterial = new MeshStandardNodeMaterial({\n  name: 'window-frame',\n  color: '#e8e8e8',\n  roughness: 0.6,\n  metalness: 0,\n})\n\n// Invisible material for root mesh — used as selection hitbox only\nconst hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })\n\nexport const WindowSystem = () => {\n  const dirtyNodes = useScene((state) => state.dirtyNodes)\n  const clearDirty = useScene((state) => state.clearDirty)\n\n  useFrame(() => {\n    if (dirtyNodes.size === 0) return\n\n    const nodes = useScene.getState().nodes\n\n    dirtyNodes.forEach((id) => {\n      const node = nodes[id]\n      if (!node || node.type !== 'window') return\n\n      const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh\n      if (!mesh) return // Keep dirty until mesh mounts\n\n      updateWindowMesh(node as WindowNode, mesh)\n      clearDirty(id as AnyNodeId)\n\n      // Rebuild the parent wall so its cutout reflects the updated window geometry\n      if ((node as WindowNode).parentId) {\n        useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId)\n      }\n    })\n  }, 3)\n\n  return null\n}\n\nfunction addBox(\n  parent: THREE.Object3D,\n  material: THREE.Material,\n  w: number,\n  h: number,\n  d: number,\n  x: number,\n  y: number,\n  z: number,\n) {\n  const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)\n  m.position.set(x, y, z)\n  parent.add(m)\n}\n\nfunction updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {\n  // Root mesh is an invisible hitbox; all visuals live in child meshes\n  mesh.geometry.dispose()\n  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)\n  mesh.material = hitboxMaterial\n\n  // Sync transform from node (React may lag behind the system by a frame during drag)\n  mesh.position.set(node.position[0], node.position[1], node.position[2])\n  mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])\n\n  // Dispose and remove all old visual children; preserve 'cutout'\n  for (const child of [...mesh.children]) {\n    if (child.name === 'cutout') continue\n    if (child instanceof THREE.Mesh) child.geometry.dispose()\n    mesh.remove(child)\n  }\n\n  const {\n    width,\n    height,\n    frameDepth,\n    frameThickness,\n    columnRatios,\n    rowRatios,\n    columnDividerThickness,\n    rowDividerThickness,\n    sill,\n    sillDepth,\n    sillThickness,\n  } = node\n\n  const innerW = width - 2 * frameThickness\n  const innerH = height - 2 * frameThickness\n\n  // ── Frame members ──\n  // Top / bottom — full width\n  addBox(\n    mesh,\n    frameMaterial,\n    width,\n    frameThickness,\n    frameDepth,\n    0,\n    height / 2 - frameThickness / 2,\n    0,\n  )\n  addBox(\n    mesh,\n    frameMaterial,\n    width,\n    frameThickness,\n    frameDepth,\n    0,\n    -height / 2 + frameThickness / 2,\n    0,\n  )\n  // Left / right — inner height to avoid corner overlap\n  addBox(\n    mesh,\n    frameMaterial,\n    frameThickness,\n    innerH,\n    frameDepth,\n    -width / 2 + frameThickness / 2,\n    0,\n    0,\n  )\n  addBox(\n    mesh,\n    frameMaterial,\n    frameThickness,\n    innerH,\n    frameDepth,\n    width / 2 - frameThickness / 2,\n    0,\n    0,\n  )\n\n  // ── Pane grid ──\n  const numCols = columnRatios.length\n  const numRows = rowRatios.length\n\n  const usableW = innerW - (numCols - 1) * columnDividerThickness\n  const usableH = innerH - (numRows - 1) * rowDividerThickness\n\n  const colSum = columnRatios.reduce((a, b) => a + b, 0)\n  const rowSum = rowRatios.reduce((a, b) => a + b, 0)\n  const colWidths = columnRatios.map((r) => (r / colSum) * usableW)\n  const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)\n\n  // Compute column x-centers starting from left edge of inner area\n  const colXCenters: number[] = []\n  let cx = -innerW / 2\n  for (let c = 0; c < numCols; c++) {\n    colXCenters.push(cx + colWidths[c]! / 2)\n    cx += colWidths[c]!\n    if (c < numCols - 1) cx += columnDividerThickness\n  }\n\n  // Compute row y-centers starting from top edge of inner area (R1 = top)\n  const rowYCenters: number[] = []\n  let cy = innerH / 2\n  for (let r = 0; r < numRows; r++) {\n    rowYCenters.push(cy - rowHeights[r]! / 2)\n    cy -= rowHeights[r]!\n    if (r < numRows - 1) cy -= rowDividerThickness\n  }\n\n  // Column dividers — full inner height\n  cx = -innerW / 2\n  for (let c = 0; c < numCols - 1; c++) {\n    cx += colWidths[c]!\n    addBox(\n      mesh,\n      frameMaterial,\n      columnDividerThickness,\n      innerH,\n      frameDepth,\n      cx + columnDividerThickness / 2,\n      0,\n      0,\n    )\n    cx += columnDividerThickness\n  }\n\n  // Row dividers — per column width, so they don't overlap column dividers (top to bottom)\n  cy = innerH / 2\n  for (let r = 0; r < numRows - 1; r++) {\n    cy -= rowHeights[r]!\n    const divY = cy - rowDividerThickness / 2\n    for (let c = 0; c < numCols; c++) {\n      addBox(\n        mesh,\n        frameMaterial,\n        colWidths[c]!,\n        rowDividerThickness,\n        frameDepth,\n        colXCenters[c]!,\n        divY,\n        0,\n      )\n    }\n    cy -= rowDividerThickness\n  }\n\n  // Glass panes\n  const glassDepth = Math.max(0.004, frameDepth * 0.08)\n  for (let c = 0; c < numCols; c++) {\n    for (let r = 0; r < numRows; r++) {\n      addBox(\n        mesh,\n        glassMaterial,\n        colWidths[c]!,\n        rowHeights[r]!,\n        glassDepth,\n        colXCenters[c]!,\n        rowYCenters[r]!,\n        0,\n      )\n    }\n  }\n\n  // ── Sill ──\n  if (sill) {\n    const sillW = width + sillDepth * 0.4 // slightly wider than frame\n    // Protrudes from the front face of the frame (+Z)\n    const sillZ = frameDepth / 2 + sillDepth / 2\n    addBox(\n      mesh,\n      frameMaterial,\n      sillW,\n      sillThickness,\n      sillDepth,\n      0,\n      -height / 2 - sillThickness / 2,\n      sillZ,\n    )\n  }\n\n  // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──\n  let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined\n  if (!cutout) {\n    cutout = new THREE.Mesh()\n    cutout.name = 'cutout'\n    mesh.add(cutout)\n  }\n  cutout.geometry.dispose()\n  cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)\n  cutout.visible = false\n}\n"
  },
  {
    "path": "packages/core/src/utils/clone-scene-graph.ts",
    "content": "import type { AnyNode, AnyNodeId } from '../schema'\nimport { generateId } from '../schema/base'\nimport type { Collection, CollectionId } from '../schema/collections'\n\nexport type SceneGraph = {\n  nodes: Record<AnyNodeId, AnyNode>\n  rootNodeIds: AnyNodeId[]\n  collections?: Record<CollectionId, Collection>\n}\n\n/**\n * Extracts the type prefix from a node ID (e.g., \"wall_abc123\" -> \"wall\")\n */\nfunction extractIdPrefix(id: string): string {\n  const underscoreIndex = id.indexOf('_')\n  return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)\n}\n\n/**\n * Deep clones a scene graph with all node IDs regenerated while preserving\n * parent-child relationships and other internal references.\n *\n * This is useful for:\n * - Copying nodes between different projects\n * - Duplicating a subset of a scene within the same project\n * - Multi-scene in-memory scenarios\n */\nexport function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {\n  const { nodes, rootNodeIds, collections } = sceneGraph\n\n  // Build ID mapping: old ID -> new ID\n  const idMap = new Map<string, string>()\n\n  // Pass 1: Generate new IDs for all nodes\n  for (const nodeId of Object.keys(nodes)) {\n    const prefix = extractIdPrefix(nodeId)\n    idMap.set(nodeId, generateId(prefix))\n  }\n\n  // Pass 2: Deep clone nodes with remapped references\n  const clonedNodes = {} as Record<AnyNodeId, AnyNode>\n\n  for (const [oldId, node] of Object.entries(nodes)) {\n    const newId = idMap.get(oldId)! as AnyNodeId\n    // structuredClone to avoid shared references between original and clone\n    const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode\n\n    // Remap parentId\n    if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {\n      clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null\n    }\n\n    // Remap children array (walls, levels, buildings, sites, items can have children)\n    if ('children' in clonedNode && Array.isArray(clonedNode.children)) {\n      ;(clonedNode as Record<string, unknown>).children = (clonedNode.children as string[])\n        .map((childId) => idMap.get(childId))\n        .filter((id): id is string => id !== undefined)\n    }\n\n    // Remap wallId (items/doors/windows attached to walls)\n    if ('wallId' in clonedNode && typeof clonedNode.wallId === 'string') {\n      ;(clonedNode as Record<string, unknown>).wallId = idMap.get(clonedNode.wallId) as\n        | string\n        | undefined\n    }\n\n    clonedNodes[newId] = clonedNode\n  }\n\n  // Remap root node IDs\n  const clonedRootNodeIds = rootNodeIds\n    .map((id) => idMap.get(id))\n    .filter((id): id is string => id !== undefined) as AnyNodeId[]\n\n  // Clone and remap collections if present\n  let clonedCollections: Record<CollectionId, Collection> | undefined\n  if (collections) {\n    clonedCollections = {} as Record<CollectionId, Collection>\n    const collectionIdMap = new Map<string, CollectionId>()\n\n    // Generate new collection IDs\n    for (const collectionId of Object.keys(collections)) {\n      collectionIdMap.set(collectionId, generateId('collection'))\n    }\n\n    for (const [oldCollectionId, collection] of Object.entries(collections)) {\n      const newCollectionId = collectionIdMap.get(oldCollectionId)!\n      clonedCollections[newCollectionId] = {\n        ...collection,\n        id: newCollectionId,\n        nodeIds: collection.nodeIds\n          .map((nodeId) => idMap.get(nodeId))\n          .filter((id): id is string => id !== undefined) as AnyNodeId[],\n        controlNodeId: collection.controlNodeId\n          ? (idMap.get(collection.controlNodeId) as AnyNodeId | undefined)\n          : undefined,\n      }\n\n      // Update collectionIds on nodes that reference this collection\n      for (const oldNodeId of collection.nodeIds) {\n        const newNodeId = idMap.get(oldNodeId)\n        if (newNodeId && clonedNodes[newNodeId as AnyNodeId]) {\n          const node = clonedNodes[newNodeId as AnyNodeId] as Record<string, unknown>\n          if ('collectionIds' in node && Array.isArray(node.collectionIds)) {\n            const oldColIds = node.collectionIds as string[]\n            node.collectionIds = oldColIds\n              .map((cid) => collectionIdMap.get(cid))\n              .filter((id): id is CollectionId => id !== undefined)\n          }\n        }\n      }\n    }\n  }\n\n  return {\n    nodes: clonedNodes,\n    rootNodeIds: clonedRootNodeIds,\n    ...(clonedCollections && { collections: clonedCollections }),\n  }\n}\n\n/**\n * Deep clones a level node and all its descendants with fresh IDs.\n * All internal references (parentId, children, wallId) are remapped to the new IDs.\n * The cloned level node's parentId is preserved (building ID) — not remapped.\n *\n * Unlike `cloneSceneGraph` (which operates on serialized data), this function works\n * on live runtime nodes that may have non-serializable properties (Three.js objects,\n * etc.). It uses JSON roundtrip to safely strip them.\n *\n * @returns clonedNodes - flat array of all cloned nodes (level + descendants)\n * @returns newLevelId - the ID of the cloned level node\n * @returns idMap - old ID → new ID mapping\n */\nexport function cloneLevelSubtree(\n  nodes: Record<AnyNodeId, AnyNode>,\n  levelId: AnyNodeId,\n): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {\n  const levelNode = nodes[levelId]\n  if (!levelNode || levelNode.type !== 'level') {\n    throw new Error(`Node \"${levelId}\" is not a level`)\n  }\n\n  // Recursively collect the level node + all descendants via children arrays\n  const subtreeIds = new Set<AnyNodeId>()\n  const collect = (id: AnyNodeId) => {\n    if (subtreeIds.has(id)) return\n    const node = nodes[id]\n    if (!node) return\n    subtreeIds.add(id)\n    if ('children' in node && Array.isArray(node.children)) {\n      for (const childId of node.children as AnyNodeId[]) {\n        collect(childId)\n      }\n    }\n  }\n  collect(levelId)\n\n  // Build ID mapping: old → new\n  const idMap = new Map<string, string>()\n  for (const oldId of subtreeIds) {\n    const prefix = extractIdPrefix(oldId)\n    idMap.set(oldId, generateId(prefix))\n  }\n\n  const newLevelId = idMap.get(levelId)! as AnyNodeId\n\n  // Clone each node with remapped references.\n  const clonedNodes: AnyNode[] = []\n  for (const oldId of subtreeIds) {\n    const node = nodes[oldId]\n    if (!node) continue\n\n    const newId = idMap.get(oldId)! as AnyNodeId\n\n    // JSON roundtrip: safely strips functions, Object3D, circular refs, etc.\n    const cloned = JSON.parse(JSON.stringify(node)) as AnyNode\n    ;(cloned as Record<string, unknown>).id = newId\n\n    // Remap parentId — but only for descendants, not the level node itself\n    if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {\n      cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null\n    }\n\n    // Remap children array\n    if ('children' in cloned && Array.isArray(cloned.children)) {\n      ;(cloned as Record<string, unknown>).children = (cloned.children as unknown[])\n        .map((child) => {\n          if (typeof child === 'string') return idMap.get(child) ?? child\n          if (\n            child &&\n            typeof child === 'object' &&\n            'id' in child &&\n            typeof (child as any).id === 'string'\n          ) {\n            return idMap.get((child as any).id) ?? (child as any).id\n          }\n          return child\n        })\n        .filter((id): id is string => typeof id === 'string')\n    }\n\n    // Remap wallId (doors/windows attached to walls)\n    if ('wallId' in cloned && typeof cloned.wallId === 'string') {\n      ;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId\n    }\n\n    clonedNodes.push(cloned)\n  }\n\n  return { clonedNodes, newLevelId, idMap }\n}\n\n/**\n * Forks a scene graph for use as a new project: clones with new IDs and strips\n * scan and guide nodes (and their references) since those contain user-uploaded\n * imagery that shouldn't carry over to a forked project.\n */\nexport function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {\n  const { nodes, rootNodeIds, collections } = sceneGraph\n\n  const excludedNodeIds = new Set<string>()\n  for (const [nodeId, node] of Object.entries(nodes)) {\n    if (node.type === 'scan' || node.type === 'guide') {\n      excludedNodeIds.add(nodeId)\n    }\n  }\n\n  const filteredNodes = {} as Record<AnyNodeId, AnyNode>\n  for (const [nodeId, node] of Object.entries(nodes)) {\n    if (excludedNodeIds.has(nodeId)) continue\n\n    const clonedNode = structuredClone(node) as AnyNode\n\n    if ('children' in clonedNode && Array.isArray(clonedNode.children)) {\n      ;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(\n        (child) => {\n          const childId =\n            typeof child === 'string'\n              ? child\n              : child && typeof child === 'object' && 'id' in child\n                ? (child as any).id\n                : null\n          return childId ? !excludedNodeIds.has(childId) : true\n        },\n      )\n    }\n\n    filteredNodes[nodeId as AnyNodeId] = clonedNode\n  }\n\n  const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))\n\n  let filteredCollections: Record<CollectionId, Collection> | undefined\n  if (collections) {\n    filteredCollections = {} as Record<CollectionId, Collection>\n    for (const [collectionId, collection] of Object.entries(collections)) {\n      const filteredNodeIds = collection.nodeIds.filter((id) => !excludedNodeIds.has(id))\n      if (filteredNodeIds.length > 0) {\n        filteredCollections[collectionId as CollectionId] = {\n          ...collection,\n          nodeIds: filteredNodeIds as AnyNodeId[],\n          controlNodeId:\n            collection.controlNodeId && excludedNodeIds.has(collection.controlNodeId)\n              ? undefined\n              : collection.controlNodeId,\n        }\n      }\n    }\n  }\n\n  return cloneSceneGraph({\n    nodes: filteredNodes,\n    rootNodeIds: filteredRootNodeIds,\n    ...(filteredCollections && { collections: filteredCollections }),\n  })\n}\n"
  },
  {
    "path": "packages/core/src/utils/types.ts",
    "content": "/**\n * Type guard to check if a value is a plain object (and not null or an array).\n * Useful for narrowing down Zod's generic JSON types.\n */\nexport const isObject = (val: unknown): val is Record<string, any> => {\n  return val !== null && typeof val === 'object' && !Array.isArray(val)\n}\n"
  },
  {
    "path": "packages/core/tsconfig.json",
    "content": "{\n  \"extends\": \"@pascal/typescript-config/react-library.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"noEmit\": false,\n    \"composite\": true,\n    \"incremental\": true\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "packages/editor/package.json",
    "content": "{\n  \"name\": \"@pascal-app/editor\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Pascal building editor component\",\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/index.tsx\"\n  },\n  \"scripts\": {\n    \"check-types\": \"tsc --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"@pascal-app/core\": \"*\",\n    \"@pascal-app/viewer\": \"*\",\n    \"@react-three/drei\": \"^10\",\n    \"@react-three/fiber\": \"^9\",\n    \"next\": \">=15\",\n    \"react\": \"^18 || ^19\",\n    \"react-dom\": \"^18 || ^19\",\n    \"three\": \"^0.183\"\n  },\n  \"dependencies\": {\n    \"@iconify/react\": \"^6.0.2\",\n    \"@number-flow/react\": \"^0.5.14\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slider\": \"^1.3.6\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@react-three/uikit-lucide\": \"^1.0.62\",\n    \"@visual-json/react\": \"latest\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"howler\": \"^2.2.4\",\n    \"lucide-react\": \"^0.562.0\",\n    \"mitt\": \"^3.0.1\",\n    \"motion\": \"^12.34.3\",\n    \"nanoid\": \"^5.1.6\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"zod\": \"^4.3.6\",\n    \"zustand\": \"^5.0.11\"\n  },\n  \"devDependencies\": {\n    \"@pascal-app/core\": \"*\",\n    \"@pascal-app/viewer\": \"*\",\n    \"@pascal/typescript-config\": \"*\",\n    \"@types/howler\": \"^2.2.12\",\n    \"@types/react\": \"19.2.2\",\n    \"@types/react-dom\": \"19.2.2\",\n    \"@types/three\": \"^0.183.1\",\n    \"typescript\": \"5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/custom-camera-controls.tsx",
    "content": "'use client'\r\n\r\nimport { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'\r\nimport { useViewer, ZONE_LAYER } from '@pascal-app/viewer'\r\nimport { CameraControls, CameraControlsImpl } from '@react-three/drei'\r\nimport { useThree } from '@react-three/fiber'\r\nimport { useCallback, useEffect, useMemo, useRef } from 'react'\r\nimport { Box3, Vector3 } from 'three'\r\nimport { EDITOR_LAYER } from '../../lib/constants'\r\nimport useEditor from '../../store/use-editor'\r\n\r\nconst currentTarget = new Vector3()\r\nconst tempBox = new Box3()\r\nconst tempCenter = new Vector3()\r\nconst tempDelta = new Vector3()\r\nconst tempPosition = new Vector3()\r\nconst tempSize = new Vector3()\r\nconst tempTarget = new Vector3()\r\nconst DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1\r\nconst DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05\r\n\r\nexport const CustomCameraControls = () => {\r\n  const controls = useRef<CameraControlsImpl>(null!)\r\n  const isPreviewMode = useEditor((s) => s.isPreviewMode)\r\n  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)\r\n  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)\r\n  const selection = useViewer((s) => s.selection)\r\n  const currentLevelId = selection.levelId\r\n  const firstLoad = useRef(true)\r\n  const maxPolarAngle =\r\n    !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE\r\n\r\n  const camera = useThree((state) => state.camera)\r\n  const raycaster = useThree((state) => state.raycaster)\r\n  useEffect(() => {\r\n    camera.layers.enable(EDITOR_LAYER)\r\n    raycaster.layers.enable(EDITOR_LAYER)\r\n    raycaster.layers.enable(ZONE_LAYER)\r\n  }, [camera, raycaster])\r\n\r\n  useEffect(() => {\r\n    if (isPreviewMode || isFirstPersonMode) return\r\n    let targetY = 0\r\n    if (currentLevelId) {\r\n      const levelMesh = sceneRegistry.nodes.get(currentLevelId)\r\n      if (levelMesh) {\r\n        targetY = levelMesh.position.y\r\n      }\r\n    }\r\n    if (firstLoad.current) {\r\n      firstLoad.current = false\r\n      ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)\r\n    }\r\n    ;(controls.current as CameraControlsImpl).getTarget(currentTarget)\r\n    ;(controls.current as CameraControlsImpl).moveTo(\r\n      currentTarget.x,\r\n      targetY,\r\n      currentTarget.z,\r\n      true,\r\n    )\r\n  }, [currentLevelId, isPreviewMode, isFirstPersonMode])\r\n\r\n  useEffect(() => {\r\n    if (!controls.current || isFirstPersonMode) return\r\n\r\n    controls.current.maxPolarAngle = maxPolarAngle\r\n    controls.current.minPolarAngle = 0\r\n\r\n    if (controls.current.polarAngle > maxPolarAngle) {\r\n      controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)\r\n    }\r\n  }, [maxPolarAngle, isFirstPersonMode])\r\n\r\n  const focusNode = useCallback(\r\n    (nodeId: string) => {\r\n      if (isPreviewMode || !controls.current) return\r\n\r\n      const object3D = sceneRegistry.nodes.get(nodeId)\r\n      if (!object3D) return\r\n\r\n      tempBox.setFromObject(object3D)\r\n      if (tempBox.isEmpty()) return\r\n\r\n      tempBox.getCenter(tempCenter)\r\n      controls.current.getPosition(tempPosition)\r\n      controls.current.getTarget(tempTarget)\r\n      tempDelta.copy(tempCenter).sub(tempTarget)\r\n\r\n      controls.current.setLookAt(\r\n        tempPosition.x + tempDelta.x,\r\n        tempPosition.y + tempDelta.y,\r\n        tempPosition.z + tempDelta.z,\r\n        tempCenter.x,\r\n        tempCenter.y,\r\n        tempCenter.z,\r\n        true,\r\n      )\r\n    },\r\n    [isPreviewMode],\r\n  )\r\n\r\n  // Configure mouse buttons based on control mode and camera mode\r\n  const cameraMode = useViewer((state) => state.cameraMode)\r\n  const mouseButtons = useMemo(() => {\r\n    // Use ZOOM for orthographic camera, DOLLY for perspective camera\r\n    const wheelAction =\r\n      cameraMode === 'orthographic'\r\n        ? CameraControlsImpl.ACTION.ZOOM\r\n        : CameraControlsImpl.ACTION.DOLLY\r\n\r\n    return {\r\n      left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,\r\n      middle: CameraControlsImpl.ACTION.SCREEN_PAN,\r\n      right: CameraControlsImpl.ACTION.ROTATE,\r\n      wheel: wheelAction,\r\n    }\r\n  }, [cameraMode, isPreviewMode])\r\n\r\n  useEffect(() => {\r\n    if (isFirstPersonMode) return\r\n\r\n    const keyState = {\r\n      shiftRight: false,\r\n      shiftLeft: false,\r\n      controlRight: false,\r\n      controlLeft: false,\r\n      space: false,\r\n    }\r\n\r\n    const updateConfig = () => {\r\n      if (!controls.current) return\r\n\r\n      const shift = keyState.shiftRight || keyState.shiftLeft\r\n      const control = keyState.controlRight || keyState.controlLeft\r\n      const space = keyState.space\r\n\r\n      const wheelAction =\r\n        cameraMode === 'orthographic'\r\n          ? CameraControlsImpl.ACTION.ZOOM\r\n          : CameraControlsImpl.ACTION.DOLLY\r\n      controls.current.mouseButtons.wheel = wheelAction\r\n      controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN\r\n      controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE\r\n      if (isPreviewMode) {\r\n        // In preview mode, left-click is always pan (viewer-style)\r\n        controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN\r\n      } else if (space) {\r\n        controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN\r\n      } else {\r\n        controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE\r\n      }\r\n    }\r\n\r\n    const onKeyDown = (event: KeyboardEvent) => {\r\n      if (event.code === 'Space') {\r\n        keyState.space = true\r\n        document.body.style.cursor = 'grab'\r\n      }\r\n      if (event.code === 'ShiftRight') {\r\n        keyState.shiftRight = true\r\n      }\r\n      if (event.code === 'ShiftLeft') {\r\n        keyState.shiftLeft = true\r\n      }\r\n      if (event.code === 'ControlRight') {\r\n        keyState.controlRight = true\r\n      }\r\n      if (event.code === 'ControlLeft') {\r\n        keyState.controlLeft = true\r\n      }\r\n      updateConfig()\r\n    }\r\n\r\n    const onKeyUp = (event: KeyboardEvent) => {\r\n      if (event.code === 'Space') {\r\n        keyState.space = false\r\n        document.body.style.cursor = ''\r\n      }\r\n      if (event.code === 'ShiftRight') {\r\n        keyState.shiftRight = false\r\n      }\r\n      if (event.code === 'ShiftLeft') {\r\n        keyState.shiftLeft = false\r\n      }\r\n      if (event.code === 'ControlRight') {\r\n        keyState.controlRight = false\r\n      }\r\n      if (event.code === 'ControlLeft') {\r\n        keyState.controlLeft = false\r\n      }\r\n      updateConfig()\r\n    }\r\n\r\n    document.addEventListener('keydown', onKeyDown)\r\n    document.addEventListener('keyup', onKeyUp)\r\n    updateConfig()\r\n\r\n    return () => {\r\n      document.removeEventListener('keydown', onKeyDown)\r\n      document.removeEventListener('keyup', onKeyUp)\r\n    }\r\n  }, [cameraMode, isPreviewMode, isFirstPersonMode])\r\n\r\n  // Preview mode: auto-navigate camera to selected node (viewer behavior)\r\n  const previewTargetNodeId = isPreviewMode\r\n    ? (selection.zoneId ?? selection.levelId ?? selection.buildingId)\r\n    : null\r\n\r\n  useEffect(() => {\r\n    if (!(isPreviewMode && controls.current)) return\r\n\r\n    const nodes = useScene.getState().nodes\r\n    let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null\r\n\r\n    if (!previewTargetNodeId) {\r\n      const site = Object.values(nodes).find((n) => n.type === 'site')\r\n      node = site || null\r\n    }\r\n    if (!node) return\r\n\r\n    // Check if node has a saved camera\r\n    if (node.camera) {\r\n      const { position, target } = node.camera\r\n      requestAnimationFrame(() => {\r\n        if (!controls.current) return\r\n        controls.current.setLookAt(\r\n          position[0],\r\n          position[1],\r\n          position[2],\r\n          target[0],\r\n          target[1],\r\n          target[2],\r\n          true,\r\n        )\r\n      })\r\n      return\r\n    }\r\n\r\n    if (!previewTargetNodeId) return\r\n\r\n    // Calculate camera position from bounding box\r\n    const object3D = sceneRegistry.nodes.get(previewTargetNodeId)\r\n    if (!object3D) return\r\n\r\n    tempBox.setFromObject(object3D)\r\n    tempBox.getCenter(tempCenter)\r\n    tempBox.getSize(tempSize)\r\n\r\n    const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)\r\n    const distance = Math.max(maxDim * 2, 15)\r\n\r\n    controls.current.setLookAt(\r\n      tempCenter.x + distance * 0.7,\r\n      tempCenter.y + distance * 0.5,\r\n      tempCenter.z + distance * 0.7,\r\n      tempCenter.x,\r\n      tempCenter.y,\r\n      tempCenter.z,\r\n      true,\r\n    )\r\n  }, [isPreviewMode, previewTargetNodeId])\r\n\r\n  useEffect(() => {\r\n    if (isFirstPersonMode) return\r\n\r\n    const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {\r\n      if (!controls.current) return\r\n\r\n      const position = new Vector3()\r\n      const target = new Vector3()\r\n      controls.current.getPosition(position)\r\n      controls.current.getTarget(target)\r\n\r\n      const state = useScene.getState()\r\n\r\n      state.updateNode(nodeId, {\r\n        camera: {\r\n          position: [position.x, position.y, position.z],\r\n          target: [target.x, target.y, target.z],\r\n          mode: useViewer.getState().cameraMode,\r\n        },\r\n      })\r\n    }\r\n    const handleNodeView = ({ nodeId }: CameraControlEvent) => {\r\n      if (!controls.current) return\r\n\r\n      const node = useScene.getState().nodes[nodeId]\r\n      if (!node?.camera) return\r\n      const { position, target } = node.camera\r\n\r\n      controls.current.setLookAt(\r\n        position[0],\r\n        position[1],\r\n        position[2],\r\n        target[0],\r\n        target[1],\r\n        target[2],\r\n        true,\r\n      )\r\n    }\r\n\r\n    const handleTopView = () => {\r\n      if (!controls.current) return\r\n\r\n      const currentPolarAngle = controls.current.polarAngle\r\n\r\n      // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°\r\n      // Otherwise, go to top view (0°)\r\n      const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0\r\n\r\n      controls.current.rotatePolarTo(targetAngle, true)\r\n    }\r\n\r\n    const handleOrbitCW = () => {\r\n      if (!controls.current) return\r\n\r\n      const currentAzimuth = controls.current.azimuthAngle\r\n      const currentPolar = controls.current.polarAngle\r\n      // Round to nearest 90° increment, then rotate 90° clockwise\r\n      const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)\r\n      const target = rounded - Math.PI / 2\r\n\r\n      controls.current.rotateTo(target, currentPolar, true)\r\n    }\r\n\r\n    const handleOrbitCCW = () => {\r\n      if (!controls.current) return\r\n\r\n      const currentAzimuth = controls.current.azimuthAngle\r\n      const currentPolar = controls.current.polarAngle\r\n      // Round to nearest 90° increment, then rotate 90° counter-clockwise\r\n      const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)\r\n      const target = rounded + Math.PI / 2\r\n\r\n      controls.current.rotateTo(target, currentPolar, true)\r\n    }\r\n\r\n    const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {\r\n      focusNode(nodeId)\r\n    }\r\n\r\n    emitter.on('camera-controls:capture', handleNodeCapture)\r\n    emitter.on('camera-controls:focus', handleNodeFocus)\r\n    emitter.on('camera-controls:view', handleNodeView)\r\n    emitter.on('camera-controls:top-view', handleTopView)\r\n    emitter.on('camera-controls:orbit-cw', handleOrbitCW)\r\n    emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)\r\n\r\n    return () => {\r\n      emitter.off('camera-controls:capture', handleNodeCapture)\r\n      emitter.off('camera-controls:focus', handleNodeFocus)\r\n      emitter.off('camera-controls:view', handleNodeView)\r\n      emitter.off('camera-controls:top-view', handleTopView)\r\n      emitter.off('camera-controls:orbit-cw', handleOrbitCW)\r\n      emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)\r\n    }\r\n  }, [focusNode, isFirstPersonMode])\r\n\r\n  const onTransitionStart = useCallback(() => {\r\n    useViewer.getState().setCameraDragging(true)\r\n  }, [])\r\n\r\n  const onRest = useCallback(() => {\r\n    useViewer.getState().setCameraDragging(false)\r\n  }, [])\r\n\r\n  // In first-person mode, don't render orbit controls — FirstPersonControls takes over\r\n  if (isFirstPersonMode) {\r\n    return null\r\n  }\r\n\r\n  return (\r\n    <CameraControls\r\n      makeDefault\r\n      maxDistance={100}\r\n      maxPolarAngle={maxPolarAngle}\r\n      minDistance={10}\r\n      minPolarAngle={0}\r\n      mouseButtons={mouseButtons}\r\n      onRest={onRest}\r\n      onSleep={onRest}\r\n      onTransitionStart={onTransitionStart}\r\n      ref={controls}\r\n      restThreshold={0.01}\r\n    />\r\n  )\r\n}\r\n"
  },
  {
    "path": "packages/editor/src/components/editor/editor-layout-v2.tsx",
    "content": "'use client'\n\nimport { type ReactNode, useCallback, useEffect, useRef } from 'react'\nimport useEditor from '../../store/use-editor'\nimport { useSidebarStore } from '../ui/primitives/sidebar'\nimport { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'\n\nconst SIDEBAR_MIN_WIDTH = 300\nconst SIDEBAR_MAX_WIDTH = 800\nconst SIDEBAR_COLLAPSE_THRESHOLD = 220\n\n// ── Left column: resizable panel with tab bar ────────────────────────────────\n\nfunction LeftColumn({\n  tabs,\n  renderTabContent,\n}: {\n  tabs: SidebarTab[]\n  renderTabContent: (tabId: string) => ReactNode\n}) {\n  const width = useSidebarStore((s) => s.width)\n  const isCollapsed = useSidebarStore((s) => s.isCollapsed)\n  const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)\n  const setWidth = useSidebarStore((s) => s.setWidth)\n  const isDragging = useSidebarStore((s) => s.isDragging)\n  const setIsDragging = useSidebarStore((s) => s.setIsDragging)\n  const activePanel = useEditor((s) => s.activeSidebarPanel)\n  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)\n\n  const isResizing = useRef(false)\n  const isExpanding = useRef(false)\n\n  // Ensure active panel is a valid tab\n  useEffect(() => {\n    if (tabs.length > 0 && !tabs.some((t) => t.id === activePanel)) {\n      setActivePanel(tabs[0]!.id)\n    }\n  }, [tabs, activePanel, setActivePanel])\n\n  const handleResizerDown = useCallback(\n    (e: React.PointerEvent) => {\n      e.preventDefault()\n      isResizing.current = true\n      setIsDragging(true)\n      document.body.style.cursor = 'col-resize'\n      document.body.style.userSelect = 'none'\n    },\n    [setIsDragging],\n  )\n\n  const handleGrabDown = useCallback(\n    (e: React.PointerEvent) => {\n      e.preventDefault()\n      isExpanding.current = true\n      setIsDragging(true)\n      document.body.style.cursor = 'col-resize'\n      document.body.style.userSelect = 'none'\n    },\n    [setIsDragging],\n  )\n\n  useEffect(() => {\n    const handlePointerMove = (e: PointerEvent) => {\n      if (isResizing.current) {\n        const newWidth = e.clientX\n        if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {\n          setIsCollapsed(true)\n        } else {\n          setIsCollapsed(false)\n          setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))\n        }\n      } else if (isExpanding.current && e.clientX > 60) {\n        setIsCollapsed(false)\n        setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))\n      }\n    }\n    const handlePointerUp = () => {\n      isResizing.current = false\n      isExpanding.current = false\n      setIsDragging(false)\n      document.body.style.cursor = ''\n      document.body.style.userSelect = ''\n    }\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n    }\n  }, [setWidth, setIsCollapsed, setIsDragging])\n\n  if (isCollapsed) {\n    return (\n      <div\n        className=\"relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20\"\n        onPointerDown={handleGrabDown}\n        title=\"Expand sidebar\"\n      />\n    )\n  }\n\n  return (\n    <div\n      className=\"relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground\"\n      style={{\n        width,\n        transition: isDragging ? 'none' : 'width 150ms ease',\n      }}\n    >\n      <TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />\n      <div className=\"flex flex-1 flex-col overflow-hidden\">{renderTabContent(activePanel)}</div>\n\n      {/* Resize handle + hit area */}\n      <div\n        className=\"absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center\"\n        onPointerDown={handleResizerDown}\n      >\n        <div className=\"h-8 w-1 rounded-full bg-neutral-500\" />\n      </div>\n    </div>\n  )\n}\n\n// ── Right column: viewer area with toolbar ───────────────────────────────────\n\nfunction RightColumn({\n  toolbarLeft,\n  toolbarRight,\n  children,\n  overlays,\n}: {\n  toolbarLeft?: ReactNode\n  toolbarRight?: ReactNode\n  children: ReactNode\n  overlays?: ReactNode\n}) {\n  return (\n    <div\n      className=\"relative flex min-w-0 flex-1 flex-col overflow-hidden\"\n      style={{\n        borderTopLeftRadius: 16,\n        clipPath: 'inset(0 0 0 0 round 16px 0 0 0)',\n        boxShadow: '-4px -2px 16px rgba(0, 0, 0, 0.08), -1px 0 4px rgba(0, 0, 0, 0.04)',\n      }}\n    >\n      {/* Viewer toolbar */}\n      {(toolbarLeft || toolbarRight) && (\n        <div className=\"pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2\">\n          <div className=\"pointer-events-auto flex items-center gap-2\">{toolbarLeft}</div>\n          <div className=\"pointer-events-auto flex items-center gap-2\">{toolbarRight}</div>\n        </div>\n      )}\n      {/* Canvas area */}\n      <div className=\"relative flex-1 overflow-hidden\">{children}</div>\n      {/* Overlays scoped to the viewer column */}\n      {overlays && (\n        <div\n          className=\"pointer-events-none absolute inset-0 z-30\"\n          style={{ transform: 'translateZ(0)' }}\n        >\n          {overlays}\n        </div>\n      )}\n    </div>\n  )\n}\n\n// ── Main v2 layout ───────────────────────────────────────────────────────────\n\nexport interface EditorLayoutV2Props {\n  navbarSlot?: ReactNode\n  sidebarTabs?: SidebarTab[]\n  renderTabContent: (tabId: string) => ReactNode\n  viewerToolbarLeft?: ReactNode\n  viewerToolbarRight?: ReactNode\n  viewerContent: ReactNode\n  overlays?: ReactNode\n}\n\nexport function EditorLayoutV2({\n  navbarSlot,\n  sidebarTabs = [],\n  renderTabContent,\n  viewerToolbarLeft,\n  viewerToolbarRight,\n  viewerContent,\n  overlays,\n}: EditorLayoutV2Props) {\n  return (\n    <div className=\"dark flex h-full w-full flex-col bg-sidebar text-foreground\">\n      {/* Top navbar */}\n      {navbarSlot}\n\n      {/* Main content: left column + right column */}\n      <div className=\"flex min-h-0 flex-1\">\n        {sidebarTabs.length > 0 && (\n          <LeftColumn renderTabContent={renderTabContent} tabs={sidebarTabs} />\n        )}\n        <RightColumn\n          overlays={overlays}\n          toolbarLeft={viewerToolbarLeft}\n          toolbarRight={viewerToolbarRight}\n        >\n          {viewerContent}\n        </RightColumn>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/export-manager.tsx",
    "content": "'use client'\n\nimport { useViewer } from '@pascal-app/viewer'\nimport { useThree } from '@react-three/fiber'\nimport { useEffect } from 'react'\nimport { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'\nimport { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'\nimport { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'\n\nexport function ExportManager() {\n  const scene = useThree((state) => state.scene)\n  const setExportScene = useViewer((state) => state.setExportScene)\n\n  useEffect(() => {\n    const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {\n      // Find the scene renderer group by name\n      const sceneGroup = scene.getObjectByName('scene-renderer')\n      if (!sceneGroup) {\n        console.error('scene-renderer group not found')\n        return\n      }\n\n      const date = new Date().toISOString().split('T')[0]\n\n      if (format === 'stl') {\n        const exporter = new STLExporter()\n        const result = exporter.parse(sceneGroup, { binary: true })\n        const blob = new Blob([result], { type: 'model/stl' })\n        downloadBlob(blob, `model_${date}.stl`)\n        return\n      }\n\n      if (format === 'obj') {\n        const exporter = new OBJExporter()\n        const result = exporter.parse(sceneGroup)\n        const blob = new Blob([result], { type: 'model/obj' })\n        downloadBlob(blob, `model_${date}.obj`)\n        return\n      }\n\n      // Default: GLB export (existing behavior)\n      const exporter = new GLTFExporter()\n\n      return new Promise<void>((resolve, reject) => {\n        exporter.parse(\n          sceneGroup,\n          (gltf) => {\n            const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })\n            downloadBlob(blob, `model_${date}.glb`)\n            resolve()\n          },\n          (error) => {\n            console.error('Export error:', error)\n            reject(error)\n          },\n          { binary: true },\n        )\n      })\n    }\n\n    setExportScene(exportFn)\n\n    return () => {\n      setExportScene(null)\n    }\n  }, [scene, setExportScene])\n\n  return null\n}\n\nfunction downloadBlob(blob: Blob, filename: string) {\n  const url = URL.createObjectURL(blob)\n  const link = document.createElement('a')\n  link.href = url\n  link.download = filename\n  link.click()\n  URL.revokeObjectURL(url)\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/first-person-controls.tsx",
    "content": "'use client'\n\nimport { useFrame, useThree } from '@react-three/fiber'\nimport { useCallback, useEffect, useRef } from 'react'\nimport { Euler, Vector3 } from 'three'\nimport useEditor from '../../store/use-editor'\n\n// Average human eye height in meters\nconst EYE_HEIGHT = 1.65\n// Movement speed in meters per second\nconst MOVE_SPEED = 5\n// Sprint multiplier when holding Shift\nconst SPRINT_MULTIPLIER = 2\n// Vertical float speed in meters per second\nconst VERTICAL_SPEED = 3\n// Mouse look sensitivity\nconst MOUSE_SENSITIVITY = 0.002\n// Min Y position (eye height above ground)\nconst MIN_Y = EYE_HEIGHT\n\n// Reusable vectors to avoid allocations in the render loop\nconst _forward = new Vector3()\nconst _right = new Vector3()\nconst _moveVector = new Vector3()\nconst _euler = new Euler(0, 0, 0, 'YXZ')\n\nexport const FirstPersonControls = () => {\n  const { camera, gl } = useThree()\n  const keysRef = useRef<Set<string>>(new Set())\n  const yawRef = useRef(0)\n  const pitchRef = useRef(0)\n  const isLockedRef = useRef(false)\n  const initializedRef = useRef(false)\n\n  // Initialize camera for first-person view: start at center of scene, on the ground\n  useEffect(() => {\n    if (initializedRef.current) return\n    initializedRef.current = true\n\n    // Place camera at the origin (center of grid) at eye height, looking along +X\n    camera.position.set(0, EYE_HEIGHT, 0)\n    yawRef.current = 0\n    pitchRef.current = 0\n  }, [camera])\n\n  // Pointer lock and event handlers\n  useEffect(() => {\n    const canvas = gl.domElement\n\n    const requestLock = () => {\n      if (!isLockedRef.current) {\n        canvas.requestPointerLock()\n      }\n    }\n\n    const handlePointerLockChange = () => {\n      isLockedRef.current = document.pointerLockElement === canvas\n    }\n\n    const handleMouseMove = (e: MouseEvent) => {\n      if (!isLockedRef.current) return\n\n      yawRef.current -= e.movementX * MOUSE_SENSITIVITY\n      pitchRef.current -= e.movementY * MOUSE_SENSITIVITY\n      // Clamp pitch to prevent flipping (almost straight up/down)\n      pitchRef.current = Math.max(\n        -Math.PI / 2 + 0.05,\n        Math.min(Math.PI / 2 - 0.05, pitchRef.current),\n      )\n    }\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Skip if user is typing in an input\n      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {\n        return\n      }\n\n      const code = e.code\n\n      // Movement keys\n      if (\n        code === 'KeyW' ||\n        code === 'KeyA' ||\n        code === 'KeyS' ||\n        code === 'KeyD' ||\n        code === 'KeyQ' ||\n        code === 'KeyE' ||\n        code === 'ShiftLeft' ||\n        code === 'ShiftRight'\n      ) {\n        e.preventDefault()\n        e.stopPropagation()\n        keysRef.current.add(code)\n      }\n\n      // ESC exits first-person mode\n      if (code === 'Escape') {\n        e.preventDefault()\n        e.stopPropagation()\n        if (document.pointerLockElement === canvas) {\n          document.exitPointerLock()\n        }\n        useEditor.getState().setFirstPersonMode(false)\n      }\n    }\n\n    const handleKeyUp = (e: KeyboardEvent) => {\n      keysRef.current.delete(e.code)\n    }\n\n    canvas.addEventListener('click', requestLock)\n    document.addEventListener('pointerlockchange', handlePointerLockChange)\n    document.addEventListener('mousemove', handleMouseMove)\n    // Use capture phase so we intercept movement keys before the global keyboard handler\n    document.addEventListener('keydown', handleKeyDown, true)\n    document.addEventListener('keyup', handleKeyUp)\n\n    return () => {\n      canvas.removeEventListener('click', requestLock)\n      document.removeEventListener('pointerlockchange', handlePointerLockChange)\n      document.removeEventListener('mousemove', handleMouseMove)\n      document.removeEventListener('keydown', handleKeyDown, true)\n      document.removeEventListener('keyup', handleKeyUp)\n      if (document.pointerLockElement === canvas) {\n        document.exitPointerLock()\n      }\n      keysRef.current.clear()\n    }\n  }, [gl])\n\n  // Per-frame movement and camera rotation\n  useFrame((_, delta) => {\n    // Clamp delta to avoid huge jumps (e.g. tab switching)\n    const dt = Math.min(delta, 0.1)\n    const keys = keysRef.current\n\n    const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')\n    const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)\n\n    // Calculate forward and right vectors on the XZ plane (ignore pitch for movement)\n    _forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))\n    _right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))\n\n    _moveVector.set(0, 0, 0)\n\n    if (keys.has('KeyW')) _moveVector.add(_forward)\n    if (keys.has('KeyS')) _moveVector.sub(_forward)\n    if (keys.has('KeyA')) _moveVector.sub(_right)\n    if (keys.has('KeyD')) _moveVector.add(_right)\n\n    // Normalize diagonal movement so it's not faster\n    if (_moveVector.lengthSq() > 0) {\n      _moveVector.normalize().multiplyScalar(speed * dt)\n      camera.position.add(_moveVector)\n    }\n\n    // Vertical movement (Q = up, E = down)\n    if (keys.has('KeyQ')) {\n      camera.position.y += VERTICAL_SPEED * dt\n    }\n    if (keys.has('KeyE')) {\n      camera.position.y -= VERTICAL_SPEED * dt\n    }\n\n    // Clamp Y so camera never goes below ground level + eye height\n    if (camera.position.y < MIN_Y) {\n      camera.position.y = MIN_Y\n    }\n\n    // Apply look rotation\n    _euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')\n    camera.quaternion.setFromEuler(_euler)\n  })\n\n  return null\n}\n\n/**\n * Overlay UI for first-person mode: crosshair, controls hint, exit button.\n * Rendered as a regular DOM overlay (not inside the Canvas).\n */\nexport const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {\n  const handleExit = useCallback(() => {\n    if (document.pointerLockElement) {\n      document.exitPointerLock()\n    }\n    onExit()\n  }, [onExit])\n\n  return (\n    <>\n      {/* Crosshair */}\n      <div className=\"pointer-events-none fixed inset-0 z-40 flex items-center justify-center\">\n        <div className=\"relative h-6 w-6\">\n          <div className=\"absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60\" />\n          <div className=\"absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60\" />\n        </div>\n      </div>\n\n      {/* Exit button — top-right */}\n      <div className=\"fixed top-4 right-4 z-50\">\n        <button\n          className=\"pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background\"\n          onClick={handleExit}\n          type=\"button\"\n        >\n          <kbd className=\"rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground\">\n            ESC\n          </kbd>\n          Exit Street View\n        </button>\n      </div>\n\n      {/* Controls hint — bottom-center */}\n      <div className=\"pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2\">\n        <div className=\"flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 shadow-lg backdrop-blur-xl\">\n          <ControlHint label=\"Move\" keys={['W', 'A', 'S', 'D']} />\n          <div className=\"h-5 w-px bg-border/30\" />\n          <ControlHint label=\"Up\" keys={['Q']} />\n          <ControlHint label=\"Down\" keys={['E']} />\n          <div className=\"h-5 w-px bg-border/30\" />\n          <ControlHint label=\"Sprint\" keys={['Shift']} />\n          <div className=\"h-5 w-px bg-border/30\" />\n          <span className=\"text-muted-foreground/60 text-xs\">Click to look around</span>\n        </div>\n      </div>\n    </>\n  )\n}\n\nfunction ControlHint({ label, keys }: { label: string; keys: string[] }) {\n  return (\n    <div className=\"flex flex-col items-center gap-1.5\">\n      <span className=\"font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]\">\n        {label}\n      </span>\n      <div className=\"flex items-center gap-1\">\n        {keys.map((key) => (\n          <kbd\n            className=\"flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none\"\n            key={key}\n          >\n            {key}\n          </kbd>\n        ))}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/floating-action-menu.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  DoorNode,\n  ItemNode,\n  RoofNode,\n  RoofSegmentNode,\n  sceneRegistry,\n  useScene,\n  WindowNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Html } from '@react-three/drei'\nimport { useFrame } from '@react-three/fiber'\nimport { useCallback, useRef } from 'react'\nimport * as THREE from 'three'\nimport { sfxEmitter } from '../../lib/sfx-bus'\nimport useEditor from '../../store/use-editor'\nimport { NodeActionMenu } from './node-action-menu'\n\nconst ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']\nconst DELETE_ONLY_TYPES = ['wall', 'slab']\n\nexport function FloatingActionMenu() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const nodes = useScene((s) => s.nodes)\n  const mode = useEditor((s) => s.mode)\n  const setMode = useEditor((s) => s.setMode)\n  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n  const setSelection = useViewer((s) => s.setSelection)\n\n  const groupRef = useRef<THREE.Group>(null)\n\n  // Only show for single selection of specific types\n  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null\n  const node = selectedId ? nodes[selectedId as AnyNodeId] : null\n  const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false\n\n  useFrame(() => {\n    if (!(selectedId && isValidType && groupRef.current)) return\n\n    const obj = sceneRegistry.nodes.get(selectedId)\n    if (obj) {\n      // Calculate bounding box in world space\n      const box = new THREE.Box3().setFromObject(obj)\n      if (!box.isEmpty()) {\n        const center = box.getCenter(new THREE.Vector3())\n        // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels\n        const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)\n        const yOffset = isDeleteOnly ? 0.8 : 0.3\n        groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)\n      }\n    }\n  })\n\n  const handleMove = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation()\n      if (!node) return\n      sfxEmitter.emit('sfx:item-pick')\n      if (\n        node.type === 'item' ||\n        node.type === 'window' ||\n        node.type === 'door' ||\n        node.type === 'roof' ||\n        node.type === 'roof-segment'\n      ) {\n        setMovingNode(node as any)\n      }\n      setSelection({ selectedIds: [] })\n    },\n    [node, setMovingNode, setSelection],\n  )\n\n  const handleDuplicate = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation()\n      if (!node?.parentId) return\n      sfxEmitter.emit('sfx:item-pick')\n      useScene.temporal.getState().pause()\n\n      let duplicateInfo = structuredClone(node) as any\n      delete duplicateInfo.id\n      duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }\n\n      let duplicate: AnyNode | null = null\n      try {\n        if (node.type === 'door') {\n          duplicate = DoorNode.parse(duplicateInfo)\n        } else if (node.type === 'window') {\n          duplicate = WindowNode.parse(duplicateInfo)\n        } else if (node.type === 'item') {\n          duplicate = ItemNode.parse(duplicateInfo)\n        } else if (node.type === 'roof') {\n          duplicate = RoofNode.parse(duplicateInfo)\n        } else if (node.type === 'roof-segment') {\n          duplicate = RoofSegmentNode.parse(duplicateInfo)\n        }\n      } catch (error) {\n        console.error('Failed to parse duplicate', error)\n        return\n      }\n\n      if (duplicate) {\n        if (duplicate.type === 'door' || duplicate.type === 'window') {\n          useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n        } else if (duplicate.type === 'roof' || duplicate.type === 'roof-segment') {\n          // Add small offset to make it visible\n          if ('position' in duplicate) {\n            duplicate.position = [\n              duplicate.position[0] + 1,\n              duplicate.position[1],\n              duplicate.position[2] + 1,\n            ]\n          }\n          useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n\n          // Duplicate children for roof nodes\n          if (node.type === 'roof' && node.children) {\n            const nodesState = useScene.getState().nodes\n            for (const childId of node.children) {\n              const childNode = nodesState[childId]\n              if (childNode && childNode.type === 'roof-segment') {\n                let childDuplicateInfo = structuredClone(childNode) as any\n                delete childDuplicateInfo.id\n                childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }\n                try {\n                  const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)\n                  useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)\n                } catch (e) {\n                  console.error('Failed to duplicate roof segment', e)\n                }\n              }\n            }\n          }\n        }\n        if (\n          duplicate.type === 'item' ||\n          duplicate.type === 'window' ||\n          duplicate.type === 'door' ||\n          duplicate.type === 'roof' ||\n          duplicate.type === 'roof-segment'\n        ) {\n          setMovingNode(duplicate as any)\n        }\n        setSelection({ selectedIds: [] })\n      }\n    },\n    [node, setMovingNode, setSelection],\n  )\n\n  const handleDelete = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation()\n      // Activate delete mode (sledgehammer tool) instead of deleting directly\n      setSelection({ selectedIds: [] })\n      setMode('delete')\n    },\n    [setSelection, setMode],\n  )\n\n  if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null\n\n  return (\n    <group ref={groupRef}>\n      <Html\n        center\n        style={{\n          pointerEvents: 'auto',\n          touchAction: 'none',\n        }}\n        zIndexRange={[100, 0]}\n      >\n        <NodeActionMenu\n          onDelete={handleDelete}\n          onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}\n          onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}\n          onPointerDown={(e) => e.stopPropagation()}\n          onPointerUp={(e) => e.stopPropagation()}\n        />\n      </Html>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/floorplan-panel.tsx",
    "content": "'use client'\n\nimport { Icon } from '@iconify/react'\nimport {\n  type AnyNodeId,\n  type BuildingNode,\n  calculateLevelMiters,\n  DoorNode,\n  emitter,\n  type GuideNode,\n  getWallPlanFootprint,\n  type LevelNode,\n  loadAssetUrl,\n  type Point2D,\n  type SiteNode,\n  SlabNode,\n  useScene,\n  type WallNode,\n  WindowNode,\n  ZoneNode as ZoneNodeSchema,\n  type ZoneNode as ZoneNodeType,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Command } from 'lucide-react'\nimport {\n  memo,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\nimport { useShallow } from 'zustand/react/shallow'\nimport { sfxEmitter } from '../../lib/sfx-bus'\nimport { cn } from '../../lib/utils'\nimport useEditor from '../../store/use-editor'\nimport { snapToHalf } from '../tools/item/placement-math'\nimport {\n  createWallOnCurrentLevel,\n  isWallLongEnough,\n  snapWallDraftPoint,\n  WALL_GRID_STEP,\n  type WallPlanPoint,\n} from '../tools/wall/wall-drafting'\nimport { furnishTools } from '../ui/action-menu/furnish-tools'\nimport { tools as structureTools } from '../ui/action-menu/structure-tools'\n\nimport { PALETTE_COLORS } from '../ui/primitives/color-dot'\nimport { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'\nimport { NodeActionMenu } from './node-action-menu'\n\nconst FALLBACK_VIEW_SIZE = 12\nconst FLOORPLAN_PADDING = 2\nconst MIN_VIEWPORT_WIDTH_RATIO = 0.08\nconst MAX_VIEWPORT_WIDTH_RATIO = 40\nconst PANEL_MIN_WIDTH = 420\nconst PANEL_MIN_HEIGHT = 320\nconst PANEL_DEFAULT_WIDTH = 560\nconst PANEL_DEFAULT_HEIGHT = 360\nconst PANEL_MARGIN = 16\nconst PANEL_DEFAULT_BOTTOM_OFFSET = 96\nconst MIN_GRID_SCREEN_SPACING = 12\nconst GRID_COORDINATE_PRECISION = 6\nconst MAJOR_GRID_STEP = WALL_GRID_STEP * 2\nconst FLOORPLAN_WALL_THICKNESS_SCALE = 1.18\nconst FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13\nconst FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035\nconst FLOORPLAN_PANEL_LAYOUT_STORAGE_KEY = 'pascal-editor-floorplan-panel-layout'\nconst EMPTY_WALL_MITER_DATA = calculateLevelMiters([])\nconst EDITOR_CURSOR = \"url('/cursor.svg') 4 2, default\"\nconst FLOORPLAN_CURSOR_INDICATOR_OFFSET_X = 20\nconst FLOORPLAN_CURSOR_INDICATOR_OFFSET_Y = 14\nconst FLOORPLAN_CURSOR_MARKER_CORE_RADIUS = 0.06\nconst FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS = 0.2\nconst FLOORPLAN_HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'\nconst FLOORPLAN_WALL_HIT_STROKE_WIDTH = 18\nconst FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH = 18\nconst FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH = 8\nconst FLOORPLAN_OPENING_HIT_STROKE_WIDTH = 16\nconst FLOORPLAN_OPENING_STROKE_WIDTH = 0.05\nconst FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH = 0.02\nconst FLOORPLAN_OPENING_DASHED_STROKE_WIDTH = 0.02\nconst FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH = 18\nconst FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH = 16\nconst FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH = 7\nconst FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX = 4\nconst FLOORPLAN_MEASUREMENT_OFFSET = 0.46\nconst FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08\nconst FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.2\nconst FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 2.8\nconst FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.72\nconst FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0.9\nconst FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15\nconst FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.82\nconst FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0.05\nconst FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56\nconst FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14\nconst FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING = 60\nconst FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56\nconst FLOORPLAN_ACTION_MENU_OFFSET_Y = 10\nconst FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y = 1.5\n\n// Match the guide plane footprint used in the 3D renderer so the 2D overlay aligns.\nconst FLOORPLAN_GUIDE_BASE_WIDTH = 10\nconst FLOORPLAN_GUIDE_MIN_SCALE = 0.01\nconst FLOORPLAN_GUIDE_HANDLE_SIZE = 0.22\nconst FLOORPLAN_GUIDE_HANDLE_HIT_RADIUS = 0.3\nconst FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH = 0.05\nconst FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72\nconst FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92\nconst FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48\nconst FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45\nconst FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1\nconst FLOORPLAN_SITE_COLOR = '#10b981'\n\ntype FloorplanViewport = {\n  centerX: number\n  centerY: number\n  width: number\n}\n\ntype SvgPoint = {\n  x: number\n  y: number\n}\n\ntype PanState = {\n  pointerId: number\n  clientX: number\n  clientY: number\n}\n\ntype GestureLikeEvent = Event & {\n  clientX?: number\n  clientY?: number\n  scale?: number\n}\n\ntype PanelRect = {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\ntype ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'\n\ntype PanelInteractionState = {\n  pointerId: number\n  startClientX: number\n  startClientY: number\n  initialRect: PanelRect\n  type: 'drag' | 'resize'\n  direction?: ResizeDirection\n}\n\ntype ViewportBounds = {\n  width: number\n  height: number\n}\n\ntype OpeningNode = WindowNode | DoorNode\n\ntype WallEndpoint = 'start' | 'end'\n\ntype FloorplanCursorIndicator =\n  | {\n      kind: 'asset'\n      iconSrc: string\n    }\n  | {\n      kind: 'icon'\n      icon: string\n    }\n\ntype PersistedPanelLayout = {\n  rect: PanelRect\n  viewport: ViewportBounds\n}\n\ntype FloorplanSelectionBounds = {\n  minX: number\n  maxX: number\n  minY: number\n  maxY: number\n}\n\ntype FloorplanMarqueeState = {\n  pointerId: number\n  startClientX: number\n  startClientY: number\n  startPlanPoint: WallPlanPoint\n  currentPlanPoint: WallPlanPoint\n}\n\ntype WallEndpointDragState = {\n  pointerId: number\n  wallId: WallNode['id']\n  endpoint: WallEndpoint\n  fixedPoint: WallPlanPoint\n  currentPoint: WallPlanPoint\n}\n\nconst GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const\n\ntype GuideCorner = (typeof GUIDE_CORNERS)[number]\n\ntype GuideInteractionMode = 'resize' | 'rotate' | 'translate'\n\ntype GuideTransformDraft = {\n  guideId: GuideNode['id']\n  position: WallPlanPoint\n  scale: number\n  rotation: number\n}\n\ntype GuideHandleHintAnchor = {\n  x: number\n  y: number\n  directionX: number\n  directionY: number\n}\n\ntype GuideInteractionState = {\n  pointerId: number\n  guideId: GuideNode['id']\n  corner: GuideCorner\n  mode: GuideInteractionMode\n  aspectRatio: number\n  centerSvg: SvgPoint\n  oppositeCornerSvg: SvgPoint | null\n  pointerOffsetSvg: WallPlanPoint\n  rotationSvg: number\n  cornerBaseAngle: number\n  scale: number\n}\n\ntype WallEndpointDraft = {\n  wallId: WallNode['id']\n  endpoint: WallEndpoint\n  start: WallPlanPoint\n  end: WallPlanPoint\n}\n\ntype SlabBoundaryDraft = {\n  slabId: SlabNode['id']\n  polygon: WallPlanPoint[]\n}\n\ntype SlabVertexDragState = {\n  pointerId: number\n  slabId: SlabNode['id']\n  vertexIndex: number\n}\n\ntype SiteBoundaryDraft = {\n  siteId: SiteNode['id']\n  polygon: WallPlanPoint[]\n}\n\ntype SiteVertexDragState = {\n  pointerId: number\n  siteId: SiteNode['id']\n  vertexIndex: number\n}\n\ntype ZoneBoundaryDraft = {\n  zoneId: ZoneNodeType['id']\n  polygon: WallPlanPoint[]\n}\n\ntype ZoneVertexDragState = {\n  pointerId: number\n  zoneId: ZoneNodeType['id']\n  vertexIndex: number\n}\n\ntype WallPolygonEntry = {\n  wall: WallNode\n  polygon: Point2D[]\n  points: string\n}\n\ntype OpeningPolygonEntry = {\n  opening: OpeningNode\n  polygon: Point2D[]\n  points: string\n}\n\ntype SlabPolygonEntry = {\n  slab: SlabNode\n  polygon: Point2D[]\n  holes: Point2D[][]\n  path: string\n}\n\ntype SitePolygonEntry = {\n  site: SiteNode\n  polygon: Point2D[]\n  points: string\n}\n\ntype ZonePolygonEntry = {\n  zone: ZoneNodeType\n  polygon: Point2D[]\n  points: string\n}\n\ntype FloorplanPalette = {\n  surface: string\n  minorGrid: string\n  majorGrid: string\n  minorGridOpacity: number\n  majorGridOpacity: number\n  slabFill: string\n  slabStroke: string\n  selectedSlabFill: string\n  wallFill: string\n  wallStroke: string\n  wallHoverStroke: string\n  selectedFill: string\n  selectedStroke: string\n  draftFill: string\n  draftStroke: string\n  cursor: string\n  editCursor: string\n  anchor: string\n  openingFill: string\n  openingStroke: string\n  measurementStroke: string\n  endpointHandleFill: string\n  endpointHandleStroke: string\n  endpointHandleHoverStroke: string\n  endpointHandleActiveFill: string\n  endpointHandleActiveStroke: string\n}\n\nconst resizeCursorByDirection: Record<ResizeDirection, string> = {\n  n: 'ns-resize',\n  s: 'ns-resize',\n  e: 'ew-resize',\n  w: 'ew-resize',\n  ne: 'nesw-resize',\n  nw: 'nwse-resize',\n  se: 'nwse-resize',\n  sw: 'nesw-resize',\n}\n\nconst resizeHandleConfigurations: Array<{\n  direction: ResizeDirection\n  className: string\n}> = [\n  { direction: 'n', className: 'absolute top-0 left-4 right-4 z-20 h-2 cursor-ns-resize' },\n  { direction: 's', className: 'absolute right-4 bottom-0 left-4 z-20 h-2 cursor-ns-resize' },\n  { direction: 'e', className: 'absolute top-4 right-0 bottom-4 z-20 w-2 cursor-ew-resize' },\n  { direction: 'w', className: 'absolute top-4 bottom-4 left-0 z-20 w-2 cursor-ew-resize' },\n  { direction: 'ne', className: 'absolute top-0 right-0 z-20 h-4 w-4 cursor-nesw-resize' },\n  { direction: 'nw', className: 'absolute top-0 left-0 z-20 h-4 w-4 cursor-nwse-resize' },\n  { direction: 'se', className: 'absolute right-0 bottom-0 z-20 h-4 w-4 cursor-nwse-resize' },\n  { direction: 'sw', className: 'absolute bottom-0 left-0 z-20 h-4 w-4 cursor-nesw-resize' },\n]\n\nconst guideCornerSigns: Record<GuideCorner, { x: -1 | 1; y: -1 | 1 }> = {\n  nw: { x: -1, y: -1 },\n  ne: { x: 1, y: -1 },\n  se: { x: 1, y: 1 },\n  sw: { x: -1, y: 1 },\n}\n\nconst oppositeGuideCorner: Record<GuideCorner, GuideCorner> = {\n  nw: 'se',\n  ne: 'sw',\n  se: 'nw',\n  sw: 'ne',\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n\nfunction getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {\n  return {\n    meta: Boolean(event?.metaKey),\n    ctrl: Boolean(event?.ctrlKey),\n  }\n}\n\nfunction toPoint2D(point: WallPlanPoint): Point2D {\n  return { x: point[0], y: point[1] }\n}\n\nfunction toWallPlanPoint(point: Point2D): WallPlanPoint {\n  return [point.x, point.y]\n}\n\nfunction toSvgX(value: number): number {\n  return -value\n}\n\nfunction toSvgY(value: number): number {\n  return -value\n}\n\nfunction toSvgPoint(point: Point2D): SvgPoint {\n  return {\n    x: toSvgX(point.x),\n    y: toSvgY(point.y),\n  }\n}\n\nfunction toSvgPlanPoint(point: WallPlanPoint): SvgPoint {\n  return {\n    x: toSvgX(point[0]),\n    y: toSvgY(point[1]),\n  }\n}\n\nfunction toPlanPointFromSvgPoint(svgPoint: SvgPoint): WallPlanPoint {\n  return [toSvgX(svgPoint.x), toSvgY(svgPoint.y)]\n}\n\nfunction rotateVector([x, y]: WallPlanPoint, angle: number): WallPlanPoint {\n  const cos = Math.cos(angle)\n  const sin = Math.sin(angle)\n  return [x * cos - y * sin, x * sin + y * cos]\n}\n\nfunction addVectorToSvgPoint(point: SvgPoint, [dx, dy]: WallPlanPoint): SvgPoint {\n  return {\n    x: point.x + dx,\n    y: point.y + dy,\n  }\n}\n\nfunction subtractSvgPoints(point: SvgPoint, origin: SvgPoint): WallPlanPoint {\n  return [point.x - origin.x, point.y - origin.y]\n}\n\nfunction midpointBetweenSvgPoints(start: SvgPoint, end: SvgPoint): SvgPoint {\n  return {\n    x: (start.x + end.x) / 2,\n    y: (start.y + end.y) / 2,\n  }\n}\n\nfunction getGuideWidth(scale: number) {\n  return FLOORPLAN_GUIDE_BASE_WIDTH * scale\n}\n\nfunction getGuideHeight(width: number, aspectRatio: number) {\n  return width / aspectRatio\n}\n\nfunction getGuideCenterSvgPoint(guide: GuideNode): SvgPoint {\n  return {\n    x: toSvgX(guide.position[0]),\n    y: toSvgY(guide.position[2]),\n  }\n}\n\nfunction getGuideCornerLocalOffset(\n  width: number,\n  height: number,\n  corner: GuideCorner,\n): WallPlanPoint {\n  const signs = guideCornerSigns[corner]\n  return [(width / 2) * signs.x, (height / 2) * signs.y]\n}\n\nfunction getGuideCornerSvgPoint(\n  centerSvg: SvgPoint,\n  width: number,\n  height: number,\n  rotationSvg: number,\n  corner: GuideCorner,\n): SvgPoint {\n  return addVectorToSvgPoint(\n    centerSvg,\n    rotateVector(getGuideCornerLocalOffset(width, height, corner), rotationSvg),\n  )\n}\n\nfunction snapAngleToIncrement(angle: number, incrementDegrees: number) {\n  const incrementRadians = (incrementDegrees * Math.PI) / 180\n  return Math.round(angle / incrementRadians) * incrementRadians\n}\n\nfunction toPositiveAngleDegrees(angle: number) {\n  const angleDegrees = (angle * 180) / Math.PI\n  return ((angleDegrees % 180) + 180) % 180\n}\n\nfunction getResizeCursorForAngle(angle: number) {\n  const normalizedDegrees = toPositiveAngleDegrees(angle)\n\n  if (normalizedDegrees < 22.5 || normalizedDegrees >= 157.5) {\n    return 'ew-resize'\n  }\n\n  if (normalizedDegrees < 67.5) {\n    return 'nwse-resize'\n  }\n\n  if (normalizedDegrees < 112.5) {\n    return 'ns-resize'\n  }\n\n  return 'nesw-resize'\n}\n\nfunction getGuideResizeCursor(corner: GuideCorner, rotationSvg: number) {\n  const signs = guideCornerSigns[corner]\n  return getResizeCursorForAngle(Math.atan2(signs.y, signs.x) + rotationSvg)\n}\n\nfunction buildCursorUrl(svgMarkup: string, hotspotX: number, hotspotY: number, fallback: string) {\n  return `url(\"data:image/svg+xml,${encodeURIComponent(svgMarkup)}\") ${hotspotX} ${hotspotY}, ${fallback}`\n}\n\nfunction getGuideRotateCursor(isDarkMode: boolean) {\n  const strokeColor = isDarkMode ? '#ffffff' : '#09090b'\n  const outlineColor = isDarkMode ? '#0a0e1b' : '#ffffff'\n  const svgMarkup = `\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n      <path d=\"M7 15.75a6 6 0 1 0 1.9-8.28\" stroke=\"${outlineColor}\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n      <path d=\"M7 5.5v4.5h4.5\" stroke=\"${outlineColor}\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n      <path d=\"M7 15.75a6 6 0 1 0 1.9-8.28\" stroke=\"${strokeColor}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n      <path d=\"M7 5.5v4.5h4.5\" stroke=\"${strokeColor}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n    </svg>\n  `.trim()\n\n  return buildCursorUrl(svgMarkup, 12, 12, 'pointer')\n}\n\nfunction buildGuideTranslateDraft(\n  interaction: GuideInteractionState,\n  pointerSvg: SvgPoint,\n): GuideTransformDraft {\n  const centerSvg = addVectorToSvgPoint(pointerSvg, [\n    -interaction.pointerOffsetSvg[0],\n    -interaction.pointerOffsetSvg[1],\n  ])\n\n  return {\n    guideId: interaction.guideId,\n    position: toPlanPointFromSvgPoint(centerSvg),\n    scale: interaction.scale,\n    rotation: normalizeAngle(-interaction.rotationSvg),\n  }\n}\n\nfunction normalizeAngle(angle: number) {\n  let nextAngle = angle\n\n  while (nextAngle <= -Math.PI) {\n    nextAngle += Math.PI * 2\n  }\n\n  while (nextAngle > Math.PI) {\n    nextAngle -= Math.PI * 2\n  }\n\n  return nextAngle\n}\n\nfunction areGuideTransformDraftsEqual(\n  previousDraft: GuideTransformDraft | null,\n  nextDraft: GuideTransformDraft | null,\n  epsilon = 1e-6,\n) {\n  if (previousDraft === nextDraft) {\n    return true\n  }\n\n  if (!(previousDraft && nextDraft)) {\n    return false\n  }\n\n  return (\n    previousDraft.guideId === nextDraft.guideId &&\n    Math.abs(previousDraft.position[0] - nextDraft.position[0]) <= epsilon &&\n    Math.abs(previousDraft.position[1] - nextDraft.position[1]) <= epsilon &&\n    Math.abs(previousDraft.scale - nextDraft.scale) <= epsilon &&\n    Math.abs(previousDraft.rotation - nextDraft.rotation) <= epsilon\n  )\n}\n\nfunction doesGuideMatchDraft(guide: GuideNode, draft: GuideTransformDraft, epsilon = 1e-6) {\n  return (\n    Math.abs(guide.position[0] - draft.position[0]) <= epsilon &&\n    Math.abs(guide.position[2] - draft.position[1]) <= epsilon &&\n    Math.abs(guide.scale - draft.scale) <= epsilon &&\n    Math.abs(normalizeAngle(guide.rotation[1] - draft.rotation)) <= epsilon\n  )\n}\n\nfunction buildGuideResizeDraft(\n  interaction: GuideInteractionState,\n  pointerSvg: SvgPoint,\n): GuideTransformDraft {\n  const signs = guideCornerSigns[interaction.corner]\n  const minWidth = FLOORPLAN_GUIDE_BASE_WIDTH * FLOORPLAN_GUIDE_MIN_SCALE\n  const diagonal = [signs.x * interaction.aspectRatio, signs.y] as WallPlanPoint\n  const oppositeCornerSvg = interaction.oppositeCornerSvg ?? interaction.centerSvg\n  const relativePointer = rotateVector(\n    subtractSvgPoints(pointerSvg, oppositeCornerSvg),\n    -interaction.rotationSvg,\n  )\n  const projectedHeight =\n    (relativePointer[0] * diagonal[0] + relativePointer[1] * diagonal[1]) /\n    (interaction.aspectRatio ** 2 + 1)\n  const width = Math.max(minWidth, projectedHeight * interaction.aspectRatio)\n  const height = getGuideHeight(width, interaction.aspectRatio)\n  const draggedCornerSvg = addVectorToSvgPoint(\n    oppositeCornerSvg,\n    rotateVector([signs.x * width, signs.y * height], interaction.rotationSvg),\n  )\n  const centerSvg = midpointBetweenSvgPoints(oppositeCornerSvg, draggedCornerSvg)\n\n  return {\n    guideId: interaction.guideId,\n    position: toPlanPointFromSvgPoint(centerSvg),\n    scale: width / FLOORPLAN_GUIDE_BASE_WIDTH,\n    rotation: normalizeAngle(-interaction.rotationSvg),\n  }\n}\n\nfunction buildGuideRotationDraft(\n  interaction: GuideInteractionState,\n  pointerSvg: SvgPoint,\n  useFineIncrement: boolean,\n): GuideTransformDraft {\n  const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg)\n\n  if (pointerVector[0] ** 2 + pointerVector[1] ** 2 <= 1e-6) {\n    return {\n      guideId: interaction.guideId,\n      position: toPlanPointFromSvgPoint(interaction.centerSvg),\n      scale: interaction.scale,\n      rotation: normalizeAngle(-interaction.rotationSvg),\n    }\n  }\n\n  const rawRotationSvg =\n    Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle\n  const snappedRotationSvg = snapAngleToIncrement(\n    rawRotationSvg,\n    useFineIncrement\n      ? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES\n      : FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES,\n  )\n\n  return {\n    guideId: interaction.guideId,\n    position: toPlanPointFromSvgPoint(interaction.centerSvg),\n    scale: interaction.scale,\n    rotation: normalizeAngle(-snappedRotationSvg),\n  }\n}\n\nfunction toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {\n  return {\n    x: toSvgX(bounds.maxX),\n    y: toSvgY(bounds.maxY),\n    width: bounds.maxX - bounds.minX,\n    height: bounds.maxY - bounds.minY,\n  }\n}\n\nfunction getFloorplanSelectionBounds(\n  start: WallPlanPoint,\n  end: WallPlanPoint,\n): FloorplanSelectionBounds {\n  return {\n    minX: Math.min(start[0], end[0]),\n    maxX: Math.max(start[0], end[0]),\n    minY: Math.min(start[1], end[1]),\n    maxY: Math.max(start[1], end[1]),\n  }\n}\n\nfunction isPointInsideSelectionBounds(point: Point2D, bounds: FloorplanSelectionBounds) {\n  return (\n    point.x >= bounds.minX &&\n    point.x <= bounds.maxX &&\n    point.y >= bounds.minY &&\n    point.y <= bounds.maxY\n  )\n}\n\nfunction isPointInsidePolygon(point: Point2D, polygon: Point2D[]) {\n  let isInside = false\n\n  for (\n    let currentIndex = 0, previousIndex = polygon.length - 1;\n    currentIndex < polygon.length;\n    previousIndex = currentIndex, currentIndex += 1\n  ) {\n    const current = polygon[currentIndex]\n    const previous = polygon[previousIndex]\n\n    if (!(current && previous)) {\n      continue\n    }\n\n    const intersects =\n      current.y > point.y !== previous.y > point.y &&\n      point.x <\n        ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x\n\n    if (intersects) {\n      isInside = !isInside\n    }\n  }\n\n  return isInside\n}\n\nfunction getLineOrientation(start: Point2D, end: Point2D, point: Point2D) {\n  return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)\n}\n\nfunction isPointOnSegment(point: Point2D, start: Point2D, end: Point2D) {\n  const epsilon = 1e-9\n\n  return (\n    Math.abs(getLineOrientation(start, end, point)) <= epsilon &&\n    point.x >= Math.min(start.x, end.x) - epsilon &&\n    point.x <= Math.max(start.x, end.x) + epsilon &&\n    point.y >= Math.min(start.y, end.y) - epsilon &&\n    point.y <= Math.max(start.y, end.y) + epsilon\n  )\n}\n\nfunction doSegmentsIntersect(\n  firstStart: Point2D,\n  firstEnd: Point2D,\n  secondStart: Point2D,\n  secondEnd: Point2D,\n) {\n  const orientation1 = getLineOrientation(firstStart, firstEnd, secondStart)\n  const orientation2 = getLineOrientation(firstStart, firstEnd, secondEnd)\n  const orientation3 = getLineOrientation(secondStart, secondEnd, firstStart)\n  const orientation4 = getLineOrientation(secondStart, secondEnd, firstEnd)\n\n  const hasProperIntersection =\n    ((orientation1 > 0 && orientation2 < 0) || (orientation1 < 0 && orientation2 > 0)) &&\n    ((orientation3 > 0 && orientation4 < 0) || (orientation3 < 0 && orientation4 > 0))\n\n  if (hasProperIntersection) {\n    return true\n  }\n\n  return (\n    isPointOnSegment(secondStart, firstStart, firstEnd) ||\n    isPointOnSegment(secondEnd, firstStart, firstEnd) ||\n    isPointOnSegment(firstStart, secondStart, secondEnd) ||\n    isPointOnSegment(firstEnd, secondStart, secondEnd)\n  )\n}\n\nfunction doesPolygonIntersectSelectionBounds(polygon: Point2D[], bounds: FloorplanSelectionBounds) {\n  if (polygon.length === 0) {\n    return false\n  }\n\n  if (polygon.some((point) => isPointInsideSelectionBounds(point, bounds))) {\n    return true\n  }\n\n  const boundsCorners: [Point2D, Point2D, Point2D, Point2D] = [\n    { x: bounds.minX, y: bounds.minY },\n    { x: bounds.maxX, y: bounds.minY },\n    { x: bounds.maxX, y: bounds.maxY },\n    { x: bounds.minX, y: bounds.maxY },\n  ]\n\n  if (boundsCorners.some((corner) => isPointInsidePolygon(corner, polygon))) {\n    return true\n  }\n\n  const boundsEdges = [\n    [boundsCorners[0], boundsCorners[1]],\n    [boundsCorners[1], boundsCorners[2]],\n    [boundsCorners[2], boundsCorners[3]],\n    [boundsCorners[3], boundsCorners[0]],\n  ] as const\n\n  for (let index = 0; index < polygon.length; index += 1) {\n    const start = polygon[index]\n    const end = polygon[(index + 1) % polygon.length]\n\n    if (!(start && end)) {\n      continue\n    }\n\n    for (const [edgeStart, edgeEnd] of boundsEdges) {\n      if (doSegmentsIntersect(start, end, edgeStart, edgeEnd)) {\n        return true\n      }\n    }\n  }\n\n  return false\n}\n\nfunction getDistanceToWallSegment(point: Point2D, start: WallPlanPoint, end: WallPlanPoint) {\n  const dx = end[0] - start[0]\n  const dy = end[1] - start[1]\n  const lengthSquared = dx * dx + dy * dy\n\n  if (lengthSquared <= Number.EPSILON) {\n    return Math.hypot(point.x - start[0], point.y - start[1])\n  }\n\n  const projection = clamp(\n    ((point.x - start[0]) * dx + (point.y - start[1]) * dy) / lengthSquared,\n    0,\n    1,\n  )\n  const projectedX = start[0] + dx * projection\n  const projectedY = start[1] + dy * projection\n\n  return Math.hypot(point.x - projectedX, point.y - projectedY)\n}\n\nfunction getViewportBounds(): ViewportBounds {\n  if (typeof window === 'undefined') {\n    return {\n      width: PANEL_DEFAULT_WIDTH + PANEL_MARGIN * 2,\n      height: PANEL_DEFAULT_HEIGHT + PANEL_MARGIN * 2,\n    }\n  }\n\n  return {\n    width: window.innerWidth,\n    height: window.innerHeight,\n  }\n}\n\nfunction getPanelSizeLimits(bounds: ViewportBounds) {\n  const maxWidth = Math.max(1, bounds.width - PANEL_MARGIN * 2)\n  const maxHeight = Math.max(1, bounds.height - PANEL_MARGIN * 2)\n\n  return {\n    maxHeight,\n    maxWidth,\n    minHeight: Math.min(PANEL_MIN_HEIGHT, maxHeight),\n    minWidth: Math.min(PANEL_MIN_WIDTH, maxWidth),\n  }\n}\n\nfunction constrainPanelRect(rect: PanelRect, bounds: ViewportBounds): PanelRect {\n  const { minWidth, maxWidth, minHeight, maxHeight } = getPanelSizeLimits(bounds)\n  const width = clamp(rect.width, minWidth, maxWidth)\n  const height = clamp(rect.height, minHeight, maxHeight)\n  const x = clamp(rect.x, PANEL_MARGIN, Math.max(PANEL_MARGIN, bounds.width - PANEL_MARGIN - width))\n  const y = clamp(\n    rect.y,\n    PANEL_MARGIN,\n    Math.max(PANEL_MARGIN, bounds.height - PANEL_MARGIN - height),\n  )\n\n  return { x, y, width, height }\n}\n\nfunction getPanelPositionRatios(rect: PanelRect, bounds: ViewportBounds) {\n  const availableX = Math.max(bounds.width - rect.width - PANEL_MARGIN * 2, 0)\n  const availableY = Math.max(bounds.height - rect.height - PANEL_MARGIN * 2, 0)\n\n  return {\n    xRatio: availableX > 0 ? (rect.x - PANEL_MARGIN) / availableX : 0.5,\n    yRatio: availableY > 0 ? (rect.y - PANEL_MARGIN) / availableY : 0.5,\n  }\n}\n\nfunction adaptPanelRectToBounds(\n  rect: PanelRect,\n  previousBounds: ViewportBounds,\n  nextBounds: ViewportBounds,\n): PanelRect {\n  const normalizedRect = constrainPanelRect(rect, previousBounds)\n  const { xRatio, yRatio } = getPanelPositionRatios(normalizedRect, previousBounds)\n  const { minWidth, maxWidth, minHeight, maxHeight } = getPanelSizeLimits(nextBounds)\n  const width = clamp(normalizedRect.width, minWidth, maxWidth)\n  const height = clamp(normalizedRect.height, minHeight, maxHeight)\n  const availableX = Math.max(nextBounds.width - width - PANEL_MARGIN * 2, 0)\n  const availableY = Math.max(nextBounds.height - height - PANEL_MARGIN * 2, 0)\n\n  return constrainPanelRect(\n    {\n      x: PANEL_MARGIN + availableX * xRatio,\n      y: PANEL_MARGIN + availableY * yRatio,\n      width,\n      height,\n    },\n    nextBounds,\n  )\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value)\n}\n\nfunction isValidPanelRect(value: unknown): value is PanelRect {\n  return (\n    typeof value === 'object' &&\n    value !== null &&\n    isFiniteNumber((value as PanelRect).x) &&\n    isFiniteNumber((value as PanelRect).y) &&\n    isFiniteNumber((value as PanelRect).width) &&\n    isFiniteNumber((value as PanelRect).height)\n  )\n}\n\nfunction isValidViewportBounds(value: unknown): value is ViewportBounds {\n  return (\n    typeof value === 'object' &&\n    value !== null &&\n    isFiniteNumber((value as ViewportBounds).width) &&\n    isFiniteNumber((value as ViewportBounds).height)\n  )\n}\n\nfunction readPersistedPanelLayout(currentBounds: ViewportBounds): PanelRect | null {\n  if (typeof window === 'undefined') {\n    return null\n  }\n\n  try {\n    const rawLayout = window.localStorage.getItem(FLOORPLAN_PANEL_LAYOUT_STORAGE_KEY)\n    if (!rawLayout) {\n      return null\n    }\n\n    const parsedLayout = JSON.parse(rawLayout) as Partial<PersistedPanelLayout>\n    if (!(isValidPanelRect(parsedLayout.rect) && isValidViewportBounds(parsedLayout.viewport))) {\n      return null\n    }\n\n    return adaptPanelRectToBounds(parsedLayout.rect, parsedLayout.viewport, currentBounds)\n  } catch {\n    return null\n  }\n}\n\nfunction writePersistedPanelLayout(layout: PersistedPanelLayout) {\n  if (typeof window === 'undefined') {\n    return\n  }\n\n  window.localStorage.setItem(FLOORPLAN_PANEL_LAYOUT_STORAGE_KEY, JSON.stringify(layout))\n}\n\nfunction getInitialPanelRect(bounds: ViewportBounds): PanelRect {\n  return constrainPanelRect(\n    {\n      x: bounds.width - PANEL_DEFAULT_WIDTH - PANEL_MARGIN,\n      y: bounds.height - PANEL_DEFAULT_HEIGHT - PANEL_DEFAULT_BOTTOM_OFFSET,\n      width: PANEL_DEFAULT_WIDTH,\n      height: PANEL_DEFAULT_HEIGHT,\n    },\n    bounds,\n  )\n}\n\nfunction movePanelRect(\n  initialRect: PanelRect,\n  dx: number,\n  dy: number,\n  bounds: ViewportBounds,\n): PanelRect {\n  return constrainPanelRect(\n    {\n      ...initialRect,\n      x: initialRect.x + dx,\n      y: initialRect.y + dy,\n    },\n    bounds,\n  )\n}\n\nfunction resizePanelRect(\n  initialRect: PanelRect,\n  direction: ResizeDirection,\n  dx: number,\n  dy: number,\n  bounds: ViewportBounds,\n): PanelRect {\n  const right = initialRect.x + initialRect.width\n  const bottom = initialRect.y + initialRect.height\n\n  let x = initialRect.x\n  let y = initialRect.y\n  let width = initialRect.width\n  let height = initialRect.height\n\n  if (direction.includes('e')) width = initialRect.width + dx\n  if (direction.includes('s')) height = initialRect.height + dy\n  if (direction.includes('w')) width = initialRect.width - dx\n  if (direction.includes('n')) height = initialRect.height - dy\n\n  const maxWidth = Math.max(PANEL_MIN_WIDTH, bounds.width - PANEL_MARGIN * 2)\n  const maxHeight = Math.max(PANEL_MIN_HEIGHT, bounds.height - PANEL_MARGIN * 2)\n  width = clamp(width, PANEL_MIN_WIDTH, maxWidth)\n  height = clamp(height, PANEL_MIN_HEIGHT, maxHeight)\n\n  if (direction.includes('w')) {\n    x = right - width\n  }\n  if (direction.includes('n')) {\n    y = bottom - height\n  }\n\n  x = clamp(x, PANEL_MARGIN, Math.max(PANEL_MARGIN, bounds.width - PANEL_MARGIN - width))\n  y = clamp(y, PANEL_MARGIN, Math.max(PANEL_MARGIN, bounds.height - PANEL_MARGIN - height))\n\n  if (direction.includes('w')) {\n    width = right - x\n  } else {\n    width = Math.min(width, bounds.width - PANEL_MARGIN - x)\n  }\n\n  if (direction.includes('n')) {\n    height = bottom - y\n  } else {\n    height = Math.min(height, bounds.height - PANEL_MARGIN - y)\n  }\n\n  return constrainPanelRect({ x, y, width, height }, bounds)\n}\n\nfunction formatPolygonPoints(points: Point2D[]): string {\n  return points\n    .map((point) => {\n      const svgPoint = toSvgPoint(point)\n      return `${svgPoint.x},${svgPoint.y}`\n    })\n    .join(' ')\n}\n\nfunction formatPolygonPath(points: Point2D[], holes: Point2D[][] = []): string {\n  const formatSubpath = (subpathPoints: Point2D[]) => {\n    const [firstPoint, ...restPoints] = subpathPoints\n    if (!firstPoint) {\n      return null\n    }\n\n    const firstSvgPoint = toSvgPoint(firstPoint)\n\n    return [\n      `M ${firstSvgPoint.x} ${firstSvgPoint.y}`,\n      ...restPoints.map((point) => {\n        const svgPoint = toSvgPoint(point)\n        return `L ${svgPoint.x} ${svgPoint.y}`\n      }),\n      'Z',\n    ].join(' ')\n  }\n\n  return [points, ...holes].map(formatSubpath).filter(Boolean).join(' ')\n}\n\nfunction toFloorplanPolygon(points: Array<[number, number]>): Point2D[] {\n  return points.map(([x, y]) => ({ x, y }))\n}\n\nfunction isPointInsidePolygonWithHoles(\n  point: Point2D,\n  polygon: Point2D[],\n  holes: Point2D[][] = [],\n) {\n  return (\n    isPointInsidePolygon(point, polygon) && !holes.some((hole) => isPointInsidePolygon(point, hole))\n  )\n}\n\nfunction isPointNearPlanPoint(a: WallPlanPoint, b: WallPlanPoint, threshold = 0.25) {\n  return Math.abs(a[0] - b[0]) < threshold && Math.abs(a[1] - b[1]) < threshold\n}\n\nfunction calculatePolygonSnapPoint(\n  lastPoint: WallPlanPoint,\n  currentPoint: WallPlanPoint,\n): WallPlanPoint {\n  const [x1, y1] = lastPoint\n  const [x, y] = currentPoint\n  const dx = x - x1\n  const dy = y - y1\n  const absDx = Math.abs(dx)\n  const absDy = Math.abs(dy)\n  const horizontalDist = absDy\n  const verticalDist = absDx\n  const diagonalDist = Math.abs(absDx - absDy)\n  const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)\n\n  if (minDist === diagonalDist) {\n    const diagonalLength = Math.min(absDx, absDy)\n    return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]\n  }\n\n  if (minDist === horizontalDist) {\n    return [x, y1]\n  }\n\n  return [x1, y]\n}\n\nfunction snapPolygonDraftPoint({\n  point,\n  start,\n  angleSnap,\n}: {\n  point: WallPlanPoint\n  start?: WallPlanPoint\n  angleSnap: boolean\n}): WallPlanPoint {\n  const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])]\n\n  if (!(start && angleSnap)) {\n    return snappedPoint\n  }\n\n  return calculatePolygonSnapPoint(start, snappedPoint)\n}\n\nfunction pointMatchesWallPlanPoint(\n  point: Point2D | undefined,\n  planPoint: WallPlanPoint,\n  epsilon = 1e-6,\n): boolean {\n  if (!point) {\n    return false\n  }\n\n  return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon\n}\n\nfunction getWallHoverSidePaths(polygon: Point2D[], wall: WallNode): [string, string] | null {\n  if (polygon.length < 4) {\n    return null\n  }\n\n  const startRight = polygon[0]\n  const endRight = polygon[1]\n  const hasEndCenterPoint = pointMatchesWallPlanPoint(polygon[2], wall.end)\n  const endLeft = polygon[hasEndCenterPoint ? 3 : 2]\n  const lastPoint = polygon[polygon.length - 1]\n  const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)\n  const startLeft = polygon[hasStartCenterPoint ? polygon.length - 2 : polygon.length - 1]\n\n  if (!(startRight && endRight && endLeft && startLeft)) {\n    return null\n  }\n\n  const svgStartRight = toSvgPoint(startRight)\n  const svgEndRight = toSvgPoint(endRight)\n  const svgStartLeft = toSvgPoint(startLeft)\n  const svgEndLeft = toSvgPoint(endLeft)\n\n  return [\n    `M ${svgStartRight.x} ${svgStartRight.y} L ${svgEndRight.x} ${svgEndRight.y}`,\n    `M ${svgStartLeft.x} ${svgStartLeft.y} L ${svgEndLeft.x} ${svgEndLeft.y}`,\n  ]\n}\n\nfunction buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoint): WallNode {\n  return {\n    object: 'node',\n    id: 'wall_draft' as WallNode['id'],\n    type: 'wall',\n    name: 'Draft wall',\n    parentId: levelId,\n    visible: true,\n    metadata: {},\n    children: [],\n    start,\n    end,\n    frontSide: 'unknown',\n    backSide: 'unknown',\n  }\n}\n\nfunction pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {\n  return a[0] === b[0] && a[1] === b[1]\n}\n\nfunction polygonsEqual(a: WallPlanPoint[], b: Array<[number, number]>): boolean {\n  return (\n    a.length === b.length &&\n    a.every((point, index) => {\n      const otherPoint = b[index]\n      if (!otherPoint) {\n        return false\n      }\n\n      return pointsEqual(point, otherPoint)\n    })\n  )\n}\n\nfunction buildWallEndpointDraft(\n  wallId: WallNode['id'],\n  endpoint: WallEndpoint,\n  fixedPoint: WallPlanPoint,\n  movingPoint: WallPlanPoint,\n): WallEndpointDraft {\n  return {\n    wallId,\n    endpoint,\n    start: endpoint === 'start' ? movingPoint : fixedPoint,\n    end: endpoint === 'end' ? movingPoint : fixedPoint,\n  }\n}\n\nfunction buildWallWithUpdatedEndpoints(\n  wall: WallNode,\n  start: WallPlanPoint,\n  end: WallPlanPoint,\n): WallNode {\n  return {\n    ...wall,\n    start,\n    end,\n  }\n}\n\nfunction getFloorplanWallThickness(wall: WallNode): number {\n  const baseThickness = wall.thickness ?? 0.1\n  const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE\n\n  return Math.min(\n    baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,\n    Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS),\n  )\n}\n\nfunction getFloorplanWall(wall: WallNode): WallNode {\n  return {\n    ...wall,\n    // Slightly exaggerate thin walls so the 2D blueprint reads clearly without drifting far from BIM.\n    thickness: getFloorplanWallThickness(wall),\n  }\n}\n\ntype WallMeasurementOverlay = {\n  wallId: WallNode['id']\n  dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number }\n  dimensionLineStart: { x1: number; y1: number; x2: number; y2: number }\n  extensionStart: { x1: number; y1: number; x2: number; y2: number }\n  extensionEnd: { x1: number; y1: number; x2: number; y2: number }\n  label: string\n  labelX: number\n  labelY: number\n  labelAngleDeg: number\n  isSelected?: boolean\n}\n\nfunction formatMeasurement(value: number, unit: 'metric' | 'imperial') {\n  if (unit === 'imperial') {\n    const feet = value * 3.280_84\n    const wholeFeet = Math.floor(feet)\n    const inches = Math.round((feet - wholeFeet) * 12)\n    if (inches === 12) return `${wholeFeet + 1}'0\"`\n    return `${wholeFeet}'${inches}\"`\n  }\n  return `${Number.parseFloat(value.toFixed(2))}m`\n}\n\nfunction getPolygonAreaAndCentroid(polygon: Point2D[]) {\n  let cx = 0\n  let cy = 0\n  let area = 0\n\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const p1 = polygon[j]!\n    const p2 = polygon[i]!\n    const f = p1.x * p2.y - p2.x * p1.y\n    cx += (p1.x + p2.x) * f\n    cy += (p1.y + p2.y) * f\n    area += f\n  }\n\n  area /= 2\n\n  if (Math.abs(area) < 1e-9) {\n    return { area: 0, centroid: polygon[0] ?? { x: 0, y: 0 } }\n  }\n\n  cx /= 6 * area\n  cy /= 6 * area\n\n  return { area: Math.abs(area), centroid: { x: cx, y: cy } }\n}\n\nfunction getSlabArea(polygon: Point2D[], holes: Point2D[][]) {\n  const outer = getPolygonAreaAndCentroid(polygon)\n  let totalArea = outer.area\n  for (const hole of holes) {\n    totalArea -= getPolygonAreaAndCentroid(hole).area\n  }\n  return { area: Math.max(0, totalArea), centroid: outer.centroid }\n}\n\nfunction formatArea(areaSqM: number, unit: 'metric' | 'imperial') {\n  if (unit === 'imperial') {\n    const areaSqFt = areaSqM * 10.763_910_4\n    return (\n      <>\n        {Math.round(areaSqFt).toLocaleString()} ft\n        <tspan baselineShift=\"super\" fontSize=\"0.75em\">\n          2\n        </tspan>\n      </>\n    )\n  }\n  return (\n    <>\n      {Number.parseFloat(areaSqM.toFixed(1))} m\n      <tspan baselineShift=\"super\" fontSize=\"0.75em\">\n        2\n      </tspan>\n    </>\n  )\n}\n\nfunction FloorplanMeasurementLine({\n  palette,\n  segment,\n  isSelected,\n}: {\n  palette: FloorplanPalette\n  segment: { x1: number; y1: number; x2: number; y2: number }\n  isSelected?: boolean\n}) {\n  const lineOpacity = isSelected\n    ? FLOORPLAN_MEASUREMENT_LINE_OPACITY\n    : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4\n  const outlineOpacity = isSelected\n    ? FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY\n    : FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY * 0.4\n\n  return (\n    <>\n      <line\n        shapeRendering=\"geometricPrecision\"\n        stroke={palette.surface}\n        strokeLinecap=\"round\"\n        strokeOpacity={outlineOpacity}\n        strokeWidth={FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH}\n        vectorEffect=\"non-scaling-stroke\"\n        x1={segment.x1}\n        x2={segment.x2}\n        y1={segment.y1}\n        y2={segment.y2}\n      />\n      <line\n        shapeRendering=\"geometricPrecision\"\n        stroke={palette.measurementStroke}\n        strokeLinecap=\"round\"\n        strokeOpacity={lineOpacity}\n        strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}\n        vectorEffect=\"non-scaling-stroke\"\n        x1={segment.x1}\n        x2={segment.x2}\n        y1={segment.y1}\n        y2={segment.y2}\n      />\n    </>\n  )\n}\n\nfunction getWallMeasurementOverlay(\n  wall: WallNode,\n  centerX: number,\n  centerZ: number,\n  unit: 'metric' | 'imperial',\n): WallMeasurementOverlay | null {\n  const dx = wall.end[0] - wall.start[0]\n  const dz = wall.end[1] - wall.start[1]\n  const length = Math.hypot(dx, dz)\n\n  if (length < 0.1) {\n    return null\n  }\n\n  const nx = -dz / length\n  const nz = dx / length\n  const midX = (wall.start[0] + wall.end[0]) / 2\n  const midZ = (wall.start[1] + wall.end[1]) / 2\n  const cx = midX - centerX\n  const cz = midZ - centerZ\n  const dot = cx * nx + cz * nz\n  const outX = dot >= 0 ? nx : -nx\n  const outZ = dot >= 0 ? nz : -nz\n  const label = formatMeasurement(length, unit)\n  const dimensionLine = {\n    x1: toSvgX(wall.start[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET),\n    y1: toSvgY(wall.start[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET),\n    x2: toSvgX(wall.end[0] + outX * FLOORPLAN_MEASUREMENT_OFFSET),\n    y2: toSvgY(wall.end[1] + outZ * FLOORPLAN_MEASUREMENT_OFFSET),\n  }\n\n  const extensionStart = {\n    x1: toSvgX(wall.start[0]),\n    y1: toSvgY(wall.start[1]),\n    x2: toSvgX(\n      wall.start[0] +\n        outX * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT),\n    ),\n    y2: toSvgY(\n      wall.start[1] +\n        outZ * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT),\n    ),\n  }\n\n  const extensionEnd = {\n    x1: toSvgX(wall.end[0]),\n    y1: toSvgY(wall.end[1]),\n    x2: toSvgX(\n      wall.end[0] +\n        outX * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT),\n    ),\n    y2: toSvgY(\n      wall.end[1] +\n        outZ * (FLOORPLAN_MEASUREMENT_OFFSET + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT),\n    ),\n  }\n\n  const svgDx = dimensionLine.x2 - dimensionLine.x1\n  const svgDy = dimensionLine.y2 - dimensionLine.y1\n  const svgLength = Math.hypot(svgDx, svgDy)\n  let labelAngleDeg = (Math.atan2(svgDy, svgDx) * 180) / Math.PI\n\n  if (labelAngleDeg > 90) {\n    labelAngleDeg -= 180\n  } else if (labelAngleDeg <= -90) {\n    labelAngleDeg += 180\n  }\n\n  if (svgLength < 1e-6) {\n    return null\n  }\n\n  const dirSvgX = svgDx / svgLength\n  const dirSvgY = svgDy / svgLength\n  const labelGapHalf = Math.min(\n    FLOORPLAN_MEASUREMENT_LABEL_GAP / 2,\n    Math.max(0, svgLength / 2 - FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING),\n  )\n  const labelX = (dimensionLine.x1 + dimensionLine.x2) / 2\n  const labelY = (dimensionLine.y1 + dimensionLine.y2) / 2\n  const dimensionLineStart = {\n    x1: dimensionLine.x1,\n    y1: dimensionLine.y1,\n    x2: labelX - dirSvgX * labelGapHalf,\n    y2: labelY - dirSvgY * labelGapHalf,\n  }\n  const dimensionLineEnd = {\n    x1: labelX + dirSvgX * labelGapHalf,\n    y1: labelY + dirSvgY * labelGapHalf,\n    x2: dimensionLine.x2,\n    y2: dimensionLine.y2,\n  }\n\n  return {\n    wallId: wall.id,\n    dimensionLineEnd,\n    dimensionLineStart,\n    extensionStart,\n    extensionEnd,\n    label,\n    labelX,\n    labelY,\n    labelAngleDeg,\n  }\n}\n\nfunction getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point2D[] {\n  const [x1, z1] = wall.start\n  const [x2, z2] = wall.end\n\n  const dx = x2 - x1\n  const dz = z2 - z1\n  const length = Math.sqrt(dx * dx + dz * dz)\n\n  if (length < 1e-9) {\n    return []\n  }\n\n  const dirX = dx / length\n  const dirZ = dz / length\n\n  const perpX = -dirZ\n  const perpZ = dirX\n\n  const distance = node.position[0]\n  const width = node.width\n  const depth = wall.thickness ?? 0.1\n\n  const cx = x1 + dirX * distance\n  const cz = z1 + dirZ * distance\n\n  const halfWidth = width / 2\n  const halfDepth = depth / 2\n\n  return [\n    { x: cx - dirX * halfWidth + perpX * halfDepth, y: cz - dirZ * halfWidth + perpZ * halfDepth },\n    { x: cx + dirX * halfWidth + perpX * halfDepth, y: cz + dirZ * halfWidth + perpZ * halfDepth },\n    { x: cx + dirX * halfWidth - perpX * halfDepth, y: cz + dirZ * halfWidth - perpZ * halfDepth },\n    { x: cx - dirX * halfWidth - perpX * halfDepth, y: cz - dirZ * halfWidth - perpZ * halfDepth },\n  ]\n}\n\nfunction getOpeningCenterLine(polygon: Point2D[]) {\n  if (polygon.length < 4) {\n    return null\n  }\n\n  const [p1, p2, p3, p4] = polygon\n\n  return {\n    start: {\n      x: (p1!.x + p4!.x) / 2,\n      y: (p1!.y + p4!.y) / 2,\n    },\n    end: {\n      x: (p2!.x + p3!.x) / 2,\n      y: (p2!.y + p3!.y) / 2,\n    },\n  }\n}\n\nfunction normalizeGridCoordinate(value: number): number {\n  return Number(value.toFixed(GRID_COORDINATE_PRECISION))\n}\n\nfunction isGridAligned(value: number, step: number): boolean {\n  if (!(Number.isFinite(step) && step > 0)) {\n    return false\n  }\n\n  const normalizedValue = normalizeGridCoordinate(value / step)\n  return Math.abs(normalizedValue - Math.round(normalizedValue)) < 1e-4\n}\n\n// Keep visible grid spacing above a minimum pixel size so zooming stays evenly distributed.\nfunction getVisibleGridSteps(\n  viewportWidth: number,\n  surfaceWidth: number,\n): {\n  minorStep: number\n  majorStep: number\n} {\n  const pixelsPerUnit = surfaceWidth / Math.max(viewportWidth, Number.EPSILON)\n  let minorStep = WALL_GRID_STEP\n\n  while (minorStep * pixelsPerUnit < MIN_GRID_SCREEN_SPACING) {\n    minorStep *= 2\n  }\n\n  return {\n    minorStep,\n    majorStep: Math.max(MAJOR_GRID_STEP, minorStep * 2),\n  }\n}\n\nfunction buildGridPath(\n  minX: number,\n  maxX: number,\n  minY: number,\n  maxY: number,\n  step: number,\n  options?: {\n    excludeStep?: number\n  },\n): string {\n  if (!(Number.isFinite(step) && step > 0)) {\n    return ''\n  }\n\n  const commands: string[] = []\n  const startXIndex = Math.floor(minX / step)\n  const endXIndex = Math.ceil(maxX / step)\n  const startYIndex = Math.floor(minY / step)\n  const endYIndex = Math.ceil(maxY / step)\n  const gridMinX = normalizeGridCoordinate(minX)\n  const gridMaxX = normalizeGridCoordinate(maxX)\n  const gridMinY = normalizeGridCoordinate(minY)\n  const gridMaxY = normalizeGridCoordinate(maxY)\n\n  for (let index = startXIndex; index <= endXIndex; index += 1) {\n    const x = index * step\n    if (options?.excludeStep && isGridAligned(x, options.excludeStep)) {\n      continue\n    }\n\n    const gridX = normalizeGridCoordinate(x)\n    commands.push(`M ${gridX} ${gridMinY} L ${gridX} ${gridMaxY}`)\n  }\n\n  for (let index = startYIndex; index <= endYIndex; index += 1) {\n    const y = index * step\n    if (options?.excludeStep && isGridAligned(y, options.excludeStep)) {\n      continue\n    }\n\n    const gridY = normalizeGridCoordinate(y)\n    commands.push(`M ${gridMinX} ${gridY} L ${gridMaxX} ${gridY}`)\n  }\n\n  return commands.join(' ')\n}\n\nfunction findClosestWallPoint(\n  point: WallPlanPoint,\n  walls: WallNode[],\n  maxDistance = 0.5,\n): { wall: WallNode; point: WallPlanPoint; t: number; normal: [number, number, number] } | null {\n  let best: {\n    wall: WallNode\n    point: WallPlanPoint\n    t: number\n    normal: [number, number, number]\n  } | null = null\n  let bestDistSq = maxDistance * maxDistance\n\n  for (const wall of walls) {\n    const [x1, z1] = wall.start\n    const [x2, z2] = wall.end\n    const dx = x2 - x1\n    const dz = z2 - z1\n    const lengthSq = dx * dx + dz * dz\n    if (lengthSq < 1e-9) continue\n\n    let t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSq\n    t = Math.max(0, Math.min(1, t))\n\n    const px = x1 + t * dx\n    const pz = z1 + t * dz\n\n    const distSq = (point[0] - px) ** 2 + (point[1] - pz) ** 2\n    if (distSq < bestDistSq) {\n      bestDistSq = distSq\n      // Provide an arbitrary front-facing normal so the tool knows it's a valid wall side\n      best = { wall, point: [px, pz], t, normal: [0, 0, 1] }\n    }\n  }\n\n  return best\n}\n\ntype GuideImageDimensions = {\n  width: number\n  height: number\n}\n\nfunction useResolvedAssetUrl(url: string) {\n  const [resolvedUrl, setResolvedUrl] = useState<string | null>(null)\n\n  useEffect(() => {\n    if (!url) {\n      setResolvedUrl(null)\n      return\n    }\n\n    let cancelled = false\n    setResolvedUrl(null)\n\n    loadAssetUrl(url).then((nextUrl) => {\n      if (!cancelled) {\n        setResolvedUrl(nextUrl)\n      }\n    })\n\n    return () => {\n      cancelled = true\n    }\n  }, [url])\n\n  return resolvedUrl\n}\n\nfunction useGuideImageDimensions(url: string | null) {\n  const [dimensions, setDimensions] = useState<GuideImageDimensions | null>(null)\n\n  useEffect(() => {\n    if (!url) {\n      setDimensions(null)\n      return\n    }\n\n    let cancelled = false\n    const image = new globalThis.Image()\n\n    image.onload = () => {\n      if (cancelled) {\n        return\n      }\n\n      const width = image.naturalWidth || image.width\n      const height = image.naturalHeight || image.height\n\n      if (!(width > 0 && height > 0)) {\n        setDimensions(null)\n        return\n      }\n\n      setDimensions({ width, height })\n    }\n\n    image.onerror = () => {\n      if (!cancelled) {\n        setDimensions(null)\n      }\n    }\n\n    image.src = url\n\n    return () => {\n      cancelled = true\n    }\n  }, [url])\n\n  return dimensions\n}\n\nfunction FloorplanGuideImage({\n  guide,\n  isInteractive,\n  isSelected,\n  activeInteractionMode,\n  onGuideSelect,\n  onGuideTranslateStart,\n}: {\n  guide: GuideNode\n  isInteractive: boolean\n  isSelected: boolean\n  activeInteractionMode: GuideInteractionMode | null\n  onGuideSelect: (guideId: GuideNode['id']) => void\n  onGuideTranslateStart: (guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => void\n}) {\n  const resolvedUrl = useResolvedAssetUrl(guide.url)\n  const dimensions = useGuideImageDimensions(resolvedUrl)\n\n  if (!(guide.opacity > 0 && guide.scale > 0 && resolvedUrl && dimensions)) {\n    return null\n  }\n\n  const aspectRatio = dimensions.width / dimensions.height\n  const planWidth = getGuideWidth(guide.scale)\n  const planHeight = getGuideHeight(planWidth, aspectRatio)\n  const centerX = toSvgX(guide.position[0])\n  const centerY = toSvgY(guide.position[2])\n  const rotationDeg = (-guide.rotation[1] * 180) / Math.PI\n\n  return (\n    <g\n      opacity={clamp(guide.opacity / 100, 0, 1)}\n      transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`}\n    >\n      {isInteractive ? (\n        <rect\n          fill=\"transparent\"\n          height={planHeight}\n          onClick={(event) => {\n            event.stopPropagation()\n            onGuideSelect(guide.id)\n          }}\n          onPointerDown={(event) => {\n            if (event.button === 0) {\n              event.stopPropagation()\n              if (isSelected) {\n                onGuideTranslateStart(guide, event)\n              }\n            }\n          }}\n          pointerEvents=\"all\"\n          style={{\n            cursor:\n              isSelected && activeInteractionMode === 'translate'\n                ? 'grabbing'\n                : isSelected\n                  ? 'grab'\n                  : 'pointer',\n          }}\n          width={planWidth}\n          x={-planWidth / 2}\n          y={-planHeight / 2}\n        />\n      ) : null}\n      <image\n        height={planHeight}\n        href={resolvedUrl}\n        pointerEvents=\"none\"\n        preserveAspectRatio=\"none\"\n        width={planWidth}\n        x={-planWidth / 2}\n        y={-planHeight / 2}\n      />\n    </g>\n  )\n}\n\nconst FloorplanGridLayer = memo(function FloorplanGridLayer({\n  majorGridPath,\n  minorGridPath,\n  palette,\n  showGrid,\n}: {\n  majorGridPath: string\n  minorGridPath: string\n  palette: FloorplanPalette\n  showGrid: boolean\n}) {\n  if (!showGrid) {\n    return null\n  }\n\n  return (\n    <>\n      <path\n        d={minorGridPath}\n        fill=\"none\"\n        opacity={palette.minorGridOpacity}\n        shapeRendering=\"crispEdges\"\n        stroke={palette.minorGrid}\n        strokeWidth=\"0.02\"\n        vectorEffect=\"non-scaling-stroke\"\n      />\n\n      <path\n        d={majorGridPath}\n        fill=\"none\"\n        opacity={palette.majorGridOpacity}\n        shapeRendering=\"crispEdges\"\n        stroke={palette.majorGrid}\n        strokeWidth=\"0.04\"\n        vectorEffect=\"non-scaling-stroke\"\n      />\n    </>\n  )\n})\n\nconst FloorplanGuideLayer = memo(function FloorplanGuideLayer({\n  guides,\n  isInteractive,\n  selectedGuideId,\n  activeGuideInteractionGuideId,\n  activeGuideInteractionMode,\n  onGuideSelect,\n  onGuideTranslateStart,\n}: {\n  guides: GuideNode[]\n  isInteractive: boolean\n  selectedGuideId: GuideNode['id'] | null\n  activeGuideInteractionGuideId: GuideNode['id'] | null\n  activeGuideInteractionMode: GuideInteractionMode | null\n  onGuideSelect: (guideId: GuideNode['id']) => void\n  onGuideTranslateStart: (guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => void\n}) {\n  if (!guides.length) {\n    return null\n  }\n\n  const orderedGuides =\n    selectedGuideId && guides.some((guide) => guide.id === selectedGuideId)\n      ? [\n          ...guides.filter((guide) => guide.id !== selectedGuideId),\n          guides.find((guide) => guide.id === selectedGuideId)!,\n        ]\n      : guides\n\n  return (\n    <>\n      {orderedGuides.map((guide) => (\n        <FloorplanGuideImage\n          activeInteractionMode={\n            activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null\n          }\n          guide={guide}\n          isInteractive={isInteractive}\n          isSelected={selectedGuideId === guide.id}\n          key={guide.id}\n          onGuideSelect={onGuideSelect}\n          onGuideTranslateStart={onGuideTranslateStart}\n        />\n      ))}\n    </>\n  )\n})\n\nfunction FloorplanGuideSelectionOverlay({\n  guide,\n  isDarkMode,\n  rotationModifierPressed,\n  showHandles,\n  onCornerHoverChange,\n  onCornerPointerDown,\n}: {\n  guide: GuideNode | null\n  isDarkMode: boolean\n  rotationModifierPressed: boolean\n  showHandles: boolean\n  onCornerHoverChange: (corner: GuideCorner | null) => void\n  onCornerPointerDown: (\n    guide: GuideNode,\n    dimensions: GuideImageDimensions,\n    corner: GuideCorner,\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => void\n}) {\n  const resolvedUrl = useResolvedAssetUrl(guide?.url ?? '')\n  const dimensions = useGuideImageDimensions(resolvedUrl)\n\n  if (!(guide && guide.opacity > 0 && guide.scale > 0 && resolvedUrl && dimensions)) {\n    return null\n  }\n\n  const aspectRatio = dimensions.width / dimensions.height\n  const planWidth = getGuideWidth(guide.scale)\n  const planHeight = getGuideHeight(planWidth, aspectRatio)\n  const centerX = toSvgX(guide.position[0])\n  const centerY = toSvgY(guide.position[2])\n  const rotationDeg = (-guide.rotation[1] * 180) / Math.PI\n  const selectionStroke = isDarkMode ? '#ffffff' : '#09090b'\n  const handleFill = isDarkMode ? '#ffffff' : '#09090b'\n  const handleStroke = isDarkMode ? '#0a0e1b' : '#ffffff'\n\n  return (\n    <g transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`}>\n      <rect\n        fill=\"none\"\n        height={planHeight}\n        pointerEvents=\"none\"\n        stroke={selectionStroke}\n        strokeDasharray=\"none\"\n        strokeLinejoin=\"round\"\n        strokeWidth={FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH}\n        vectorEffect=\"non-scaling-stroke\"\n        width={planWidth}\n        x={-planWidth / 2}\n        y={-planHeight / 2}\n      />\n\n      {showHandles\n        ? GUIDE_CORNERS.map((corner) => {\n            const [x, y] = getGuideCornerLocalOffset(planWidth, planHeight, corner)\n\n            return (\n              <g key={corner}>\n                <rect\n                  fill={handleFill}\n                  height={FLOORPLAN_GUIDE_HANDLE_SIZE}\n                  pointerEvents=\"none\"\n                  rx={FLOORPLAN_GUIDE_HANDLE_SIZE * 0.22}\n                  ry={FLOORPLAN_GUIDE_HANDLE_SIZE * 0.22}\n                  stroke={handleStroke}\n                  strokeWidth=\"0.04\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  width={FLOORPLAN_GUIDE_HANDLE_SIZE}\n                  x={x - FLOORPLAN_GUIDE_HANDLE_SIZE / 2}\n                  y={y - FLOORPLAN_GUIDE_HANDLE_SIZE / 2}\n                />\n                <circle\n                  cx={x}\n                  cy={y}\n                  fill=\"transparent\"\n                  onClick={(event) => {\n                    event.preventDefault()\n                    event.stopPropagation()\n                  }}\n                  onPointerDown={(event) => onCornerPointerDown(guide, dimensions, corner, event)}\n                  onPointerEnter={() => onCornerHoverChange(corner)}\n                  onPointerLeave={() => onCornerHoverChange(null)}\n                  pointerEvents=\"all\"\n                  r={FLOORPLAN_GUIDE_HANDLE_HIT_RADIUS}\n                  stroke=\"transparent\"\n                  strokeWidth={FLOORPLAN_GUIDE_HANDLE_HIT_RADIUS * 2}\n                  style={{\n                    cursor: rotationModifierPressed\n                      ? getGuideRotateCursor(isDarkMode)\n                      : getGuideResizeCursor(corner, -guide.rotation[1]),\n                  }}\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              </g>\n            )\n          })\n        : null}\n    </g>\n  )\n}\n\nfunction FloorplanGuideHandleHint({\n  anchor,\n  isDarkMode,\n  isMacPlatform,\n  rotationModifierPressed,\n}: {\n  anchor: GuideHandleHintAnchor | null\n  isDarkMode: boolean\n  isMacPlatform: boolean\n  rotationModifierPressed: boolean\n}) {\n  if (!anchor) {\n    return null\n  }\n\n  const primaryToneClass = isDarkMode\n    ? 'text-white drop-shadow-[0_1px_1.5px_rgba(0,0,0,0.5)]'\n    : 'text-[#09090b] drop-shadow-[0_1px_1.5px_rgba(255,255,255,0.8)]'\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      className={cn('pointer-events-none absolute z-20 select-none', primaryToneClass)}\n      style={{\n        left: anchor.x,\n        top: anchor.y,\n        transform: `translate(calc(-50% + ${anchor.directionX * 12}px), calc(-50% + ${anchor.directionY * 12}px))`,\n      }}\n    >\n      <div className=\"flex flex-col gap-0.5\">\n        <div\n          className={cn(\n            'flex items-center gap-1.5 transition-opacity duration-150',\n            rotationModifierPressed ? 'opacity-40' : 'opacity-100',\n          )}\n        >\n          <span className=\"font-medium text-[11px] lowercase leading-none\">resize</span>\n          <Icon\n            aria-hidden=\"true\"\n            className=\"h-3.5 w-3.5 shrink-0\"\n            color=\"currentColor\"\n            icon=\"ph:mouse-left-click-fill\"\n          />\n        </div>\n\n        <div\n          className={cn(\n            'flex items-center gap-1.5 transition-opacity duration-150',\n            rotationModifierPressed ? 'opacity-100' : 'opacity-40',\n          )}\n        >\n          <span className=\"font-medium text-[11px] lowercase leading-none\">rotate</span>\n          {isMacPlatform ? (\n            <Command aria-hidden=\"true\" className=\"h-3.5 w-3.5 shrink-0\" strokeWidth={2.2} />\n          ) : (\n            <span className=\"font-mono text-[10px] uppercase leading-none\">ctrl</span>\n          )}\n          <Icon\n            aria-hidden=\"true\"\n            className=\"h-3.5 w-3.5 shrink-0\"\n            color=\"currentColor\"\n            icon=\"ph:mouse-left-click-fill\"\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nconst FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({\n  canSelectSlabs,\n  canSelectGeometry,\n  hoveredOpeningId,\n  hoveredWallId,\n  onSlabDoubleClick,\n  onSlabSelect,\n  onOpeningDoubleClick,\n  onOpeningHoverChange,\n  onOpeningPointerDown,\n  onOpeningSelect,\n  onWallClick,\n  onWallDoubleClick,\n  onWallHoverChange,\n  openingsPolygons,\n  palette,\n  selectedIdSet,\n  slabPolygons,\n  wallPolygons,\n  unit,\n}: {\n  canSelectSlabs: boolean\n  canSelectGeometry: boolean\n  hoveredOpeningId: OpeningNode['id'] | null\n  onSlabDoubleClick: (slab: SlabNode) => void\n  onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => void\n  onOpeningDoubleClick: (opening: OpeningNode) => void\n  onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void\n  onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent<SVGElement>) => void\n  onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent<SVGElement>) => void\n  hoveredWallId: WallNode['id'] | null\n  onWallClick: (wall: WallNode, event: ReactMouseEvent<SVGElement>) => void\n  onWallDoubleClick: (wall: WallNode, event: ReactMouseEvent<SVGElement>) => void\n  onWallHoverChange: (wallId: WallNode['id'] | null) => void\n  openingsPolygons: OpeningPolygonEntry[]\n  palette: FloorplanPalette\n  selectedIdSet: ReadonlySet<string>\n  slabPolygons: SlabPolygonEntry[]\n  wallPolygons: WallPolygonEntry[]\n  unit: 'metric' | 'imperial'\n}) {\n  let minX = Number.POSITIVE_INFINITY,\n    maxX = Number.NEGATIVE_INFINITY,\n    minZ = Number.POSITIVE_INFINITY,\n    maxZ = Number.NEGATIVE_INFINITY\n  for (const { wall } of wallPolygons) {\n    minX = Math.min(minX, wall.start[0], wall.end[0])\n    maxX = Math.max(maxX, wall.start[0], wall.end[0])\n    minZ = Math.min(minZ, wall.start[1], wall.end[1])\n    maxZ = Math.max(maxZ, wall.start[1], wall.end[1])\n  }\n  const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2\n  const centerZ = minZ === Number.POSITIVE_INFINITY ? 0 : (minZ + maxZ) / 2\n  const wallMeasurements = wallPolygons.flatMap(({ wall }) => {\n    const measurement = getWallMeasurementOverlay(wall, centerX, centerZ, unit)\n    if (measurement) {\n      measurement.isSelected = selectedIdSet.has(wall.id)\n    }\n    return measurement ? [measurement] : []\n  })\n\n  return (\n    <>\n      {slabPolygons.map(({ slab, polygon, holes, path }) => {\n        const isSelected = selectedIdSet.has(slab.id)\n        let slabLabel = null\n\n        if (isSelected) {\n          const { area, centroid } = getSlabArea(polygon, holes)\n          if (area > 0) {\n            slabLabel = (\n              <text\n                dominantBaseline=\"central\"\n                fill={palette.measurementStroke}\n                fontFamily=\"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace\"\n                fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}\n                fontWeight=\"600\"\n                paintOrder=\"stroke\"\n                pointerEvents=\"none\"\n                stroke={palette.surface}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH}\n                style={{ userSelect: 'none' }}\n                textAnchor=\"middle\"\n                x={toSvgX(centroid.x)}\n                y={toSvgY(centroid.y)}\n              >\n                {formatArea(area, unit)}\n              </text>\n            )\n          }\n        }\n\n        return (\n          <g key={slab.id}>\n            <path\n              clipRule=\"evenodd\"\n              d={path}\n              fill={isSelected ? palette.selectedSlabFill : palette.slabFill}\n              fillRule=\"evenodd\"\n              onClick={\n                canSelectSlabs\n                  ? (event) => {\n                      event.stopPropagation()\n                      onSlabSelect(slab.id, event)\n                    }\n                  : undefined\n              }\n              onDoubleClick={\n                canSelectSlabs\n                  ? (event) => {\n                      event.stopPropagation()\n                      onSlabDoubleClick(slab)\n                    }\n                  : undefined\n              }\n              pointerEvents={canSelectSlabs ? undefined : 'none'}\n              stroke={isSelected ? palette.selectedStroke : palette.slabStroke}\n              strokeOpacity={isSelected ? 0.92 : 0.84}\n              strokeWidth=\"0.05\"\n              style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            {slabLabel}\n          </g>\n        )\n      })}\n\n      {wallPolygons.map(({ wall, polygon, points }) => {\n        const isSelected = selectedIdSet.has(wall.id)\n        const isHovered = canSelectGeometry && hoveredWallId === wall.id\n        const hoverStroke = isSelected ? palette.selectedStroke : palette.wallHoverStroke\n        const hoverSidePaths = getWallHoverSidePaths(polygon, wall)\n\n        return (\n          <g\n            key={wall.id}\n            onPointerEnter={canSelectGeometry ? () => onWallHoverChange(wall.id) : undefined}\n            onPointerLeave={canSelectGeometry ? () => onWallHoverChange(null) : undefined}\n          >\n            {hoverSidePaths?.map((pathData, index) => (\n              <path\n                d={pathData}\n                fill=\"none\"\n                key={`glow-${index}`}\n                pointerEvents=\"none\"\n                stroke={hoverStroke}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.22 : 0.16}\n                strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH}\n                style={{\n                  opacity: isHovered ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            ))}\n            {hoverSidePaths?.map((pathData, index) => (\n              <path\n                d={pathData}\n                fill=\"none\"\n                key={`ring-${index}`}\n                pointerEvents=\"none\"\n                stroke={hoverStroke}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.6 : 0.48}\n                strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH}\n                style={{\n                  opacity: isHovered ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            ))}\n            {canSelectGeometry && (\n              <line\n                onClick={(event) => {\n                  event.stopPropagation()\n                  onWallClick(wall, event)\n                }}\n                onDoubleClick={(event) => {\n                  event.stopPropagation()\n                  onWallDoubleClick(wall, event)\n                }}\n                pointerEvents=\"stroke\"\n                stroke=\"transparent\"\n                strokeLinecap=\"round\"\n                strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH}\n                style={{ cursor: EDITOR_CURSOR }}\n                vectorEffect=\"non-scaling-stroke\"\n                x1={toSvgX(wall.start[0])}\n                x2={toSvgX(wall.end[0])}\n                y1={toSvgY(wall.start[1])}\n                y2={toSvgY(wall.end[1])}\n              />\n            )}\n            <polygon\n              fill={isSelected ? palette.selectedFill : palette.wallFill}\n              onClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onWallClick(wall, event)\n                    }\n                  : undefined\n              }\n              onDoubleClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onWallDoubleClick(wall, event)\n                    }\n                  : undefined\n              }\n              points={points}\n              stroke={isSelected ? 'none' : palette.wallStroke}\n              strokeOpacity={1}\n              strokeWidth=\"0.06\"\n              style={{ cursor: EDITOR_CURSOR }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </g>\n        )\n      })}\n\n      {openingsPolygons.map(({ opening, polygon, points }) => {\n        const isSelected = selectedIdSet.has(opening.id)\n        const isHovered = canSelectGeometry && hoveredOpeningId === opening.id\n        const isHighlighted = isHovered || isSelected\n        const highlightStroke = isSelected ? palette.selectedStroke : palette.wallHoverStroke\n        const detailStroke = isSelected ? palette.surface : palette.openingStroke\n        const centerLine = getOpeningCenterLine(polygon)\n\n        if (opening.type === 'window') {\n          if (polygon.length < 4) return null\n          if (!centerLine) return null\n          const windowLineStartX = toSvgX(centerLine.start.x)\n          const windowLineStartY = toSvgY(centerLine.start.y)\n          const windowLineEndX = toSvgX(centerLine.end.x)\n          const windowLineEndY = toSvgY(centerLine.end.y)\n\n          return (\n            <g\n              key={opening.id}\n              onClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onOpeningSelect(opening.id, event)\n                    }\n                  : undefined\n              }\n              onDoubleClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onOpeningDoubleClick(opening)\n                    }\n                  : undefined\n              }\n              onPointerDown={\n                canSelectGeometry && isSelected\n                  ? (event) => {\n                      if (event.button === 0) {\n                        onOpeningPointerDown(opening.id, event)\n                      }\n                    }\n                  : undefined\n              }\n              onPointerEnter={\n                canSelectGeometry\n                  ? () => {\n                      onWallHoverChange(null)\n                      onOpeningHoverChange(opening.id)\n                    }\n                  : undefined\n              }\n              onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined}\n              style={{ cursor: EDITOR_CURSOR }}\n            >\n              {canSelectGeometry && (\n                <line\n                  pointerEvents=\"stroke\"\n                  stroke=\"transparent\"\n                  strokeLinecap=\"round\"\n                  strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}\n                  vectorEffect=\"non-scaling-stroke\"\n                  x1={windowLineStartX}\n                  x2={windowLineEndX}\n                  y1={windowLineStartY}\n                  y2={windowLineEndY}\n                />\n              )}\n              <polygon\n                fill=\"none\"\n                pointerEvents=\"none\"\n                points={points}\n                stroke={highlightStroke}\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.22 : 0.16}\n                strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH}\n                style={{\n                  opacity: isHighlighted ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <polygon\n                fill=\"none\"\n                pointerEvents=\"none\"\n                points={points}\n                stroke={highlightStroke}\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.6 : 0.48}\n                strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH}\n                style={{\n                  opacity: isHighlighted ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <polygon\n                fill={palette.openingFill}\n                points={points}\n                stroke={isSelected ? palette.selectedStroke : palette.openingStroke}\n                strokeOpacity={1}\n                strokeWidth={FLOORPLAN_OPENING_STROKE_WIDTH}\n              />\n              <line\n                stroke={isSelected ? palette.selectedStroke : detailStroke}\n                strokeWidth={FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH}\n                x1={windowLineStartX}\n                x2={windowLineEndX}\n                y1={windowLineStartY}\n                y2={windowLineEndY}\n              />\n            </g>\n          )\n        }\n\n        if (opening.type === 'door') {\n          if (polygon.length < 4) return null\n          if (!centerLine) return null\n          const [p1, p2, p3, p4] = polygon\n          const svgP1 = toSvgPoint(p1!)\n          const svgP2 = toSvgPoint(p2!)\n          const svgP3 = toSvgPoint(p3!)\n          const svgP4 = toSvgPoint(p4!)\n          const cx = (svgP1.x + svgP2.x + svgP3.x + svgP4.x) / 4\n          const cy = (svgP1.y + svgP2.y + svgP3.y + svgP4.y) / 4\n\n          const dirX = svgP2.x - svgP1.x\n          const dirY = svgP2.y - svgP1.y\n          const len = Math.sqrt(dirX * dirX + dirY * dirY)\n          const nx = dirX / len\n          const ny = dirY / len\n\n          const px = -ny\n          const py = nx\n\n          const hingesSide = opening.hingesSide ?? 'left'\n          const swingDirection = opening.swingDirection ?? 'inward'\n          const width = opening.width\n          const sweepFlag =\n            hingesSide === 'left'\n              ? swingDirection === 'inward'\n                ? 0\n                : 1\n              : swingDirection === 'inward'\n                ? 1\n                : 0\n\n          const hx = cx - nx * (width / 2) * (hingesSide === 'left' ? 1 : -1)\n          const hy = cy - ny * (width / 2) * (hingesSide === 'left' ? 1 : -1)\n\n          const ox = hx + px * width * (swingDirection === 'inward' ? 1 : -1)\n          const oy = hy + py * width * (swingDirection === 'inward' ? 1 : -1)\n\n          const ox2 = cx + nx * (width / 2) * (hingesSide === 'left' ? 1 : -1)\n          const oy2 = cy + ny * (width / 2) * (hingesSide === 'left' ? 1 : -1)\n\n          return (\n            <g\n              key={opening.id}\n              onClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onOpeningSelect(opening.id, event)\n                    }\n                  : undefined\n              }\n              onDoubleClick={\n                canSelectGeometry\n                  ? (event) => {\n                      event.stopPropagation()\n                      onOpeningDoubleClick(opening)\n                    }\n                  : undefined\n              }\n              onPointerDown={\n                canSelectGeometry && isSelected\n                  ? (event) => {\n                      if (event.button === 0) {\n                        onOpeningPointerDown(opening.id, event)\n                      }\n                    }\n                  : undefined\n              }\n              onPointerEnter={\n                canSelectGeometry\n                  ? () => {\n                      onWallHoverChange(null)\n                      onOpeningHoverChange(opening.id)\n                    }\n                  : undefined\n              }\n              onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined}\n              style={{ cursor: EDITOR_CURSOR }}\n            >\n              {canSelectGeometry && (\n                <line\n                  pointerEvents=\"stroke\"\n                  stroke=\"transparent\"\n                  strokeLinecap=\"round\"\n                  strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}\n                  vectorEffect=\"non-scaling-stroke\"\n                  x1={toSvgX(centerLine.start.x)}\n                  x2={toSvgX(centerLine.end.x)}\n                  y1={toSvgY(centerLine.start.y)}\n                  y2={toSvgY(centerLine.end.y)}\n                />\n              )}\n              <polygon\n                fill=\"none\"\n                pointerEvents=\"none\"\n                points={points}\n                stroke={highlightStroke}\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.22 : 0.16}\n                strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH}\n                style={{\n                  opacity: isHighlighted ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <polygon\n                fill=\"none\"\n                pointerEvents=\"none\"\n                points={points}\n                stroke={highlightStroke}\n                strokeLinejoin=\"round\"\n                strokeOpacity={isSelected ? 0.6 : 0.48}\n                strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH}\n                style={{\n                  opacity: isHighlighted ? 1 : 0,\n                  transition: FLOORPLAN_HOVER_TRANSITION,\n                }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <polygon\n                fill={palette.openingFill}\n                points={points}\n                stroke={isSelected ? palette.selectedStroke : palette.openingStroke}\n                strokeOpacity={1}\n                strokeWidth={FLOORPLAN_OPENING_STROKE_WIDTH}\n              />\n              <line\n                stroke={isSelected ? palette.selectedStroke : detailStroke}\n                strokeWidth={FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH}\n                x1={hx}\n                x2={ox}\n                y1={hy}\n                y2={oy}\n              />\n              <path\n                d={`M ${ox} ${oy} A ${width} ${width} 0 0 ${sweepFlag} ${ox2} ${oy2}`}\n                fill=\"none\"\n                stroke={isSelected ? palette.selectedStroke : detailStroke}\n                strokeDasharray=\"0.1 0.1\"\n                strokeWidth={FLOORPLAN_OPENING_DASHED_STROKE_WIDTH}\n              />\n            </g>\n          )\n        }\n\n        return null\n      })}\n\n      {wallMeasurements.map((measurement) => (\n        <g\n          className=\"wall-dimension\"\n          key={`measurement-${measurement.wallId}`}\n          pointerEvents=\"none\"\n          style={{ userSelect: 'none' }}\n        >\n          <FloorplanMeasurementLine\n            isSelected={measurement.isSelected}\n            palette={palette}\n            segment={measurement.extensionStart}\n          />\n          <FloorplanMeasurementLine\n            isSelected={measurement.isSelected}\n            palette={palette}\n            segment={measurement.dimensionLineStart}\n          />\n          <FloorplanMeasurementLine\n            isSelected={measurement.isSelected}\n            palette={palette}\n            segment={measurement.dimensionLineEnd}\n          />\n          <FloorplanMeasurementLine\n            isSelected={measurement.isSelected}\n            palette={palette}\n            segment={measurement.extensionEnd}\n          />\n          <text\n            dominantBaseline=\"central\"\n            fill={palette.measurementStroke}\n            fillOpacity={\n              measurement.isSelected\n                ? FLOORPLAN_MEASUREMENT_LABEL_OPACITY\n                : FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4\n            }\n            fontFamily=\"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace\"\n            fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}\n            fontWeight=\"600\"\n            paintOrder=\"stroke\"\n            stroke={palette.surface}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeOpacity={measurement.isSelected ? 1 : 0.4}\n            strokeWidth={FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH}\n            textAnchor=\"middle\"\n            transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}\n            x={measurement.labelX}\n            y={measurement.labelY}\n          >\n            {measurement.label}\n          </text>\n        </g>\n      ))}\n    </>\n  )\n})\n\nconst FloorplanSiteLayer = memo(function FloorplanSiteLayer({\n  isEditing,\n  sitePolygon,\n}: {\n  isEditing: boolean\n  sitePolygon: SitePolygonEntry | null\n}) {\n  if (!sitePolygon) {\n    return null\n  }\n\n  return (\n    <polygon\n      fill={FLOORPLAN_SITE_COLOR}\n      fillOpacity={isEditing ? 0.12 : 0.08}\n      pointerEvents=\"none\"\n      points={sitePolygon.points}\n      stroke={FLOORPLAN_SITE_COLOR}\n      strokeDasharray={isEditing ? '0.16 0.1' : undefined}\n      strokeLinejoin=\"round\"\n      strokeOpacity={isEditing ? 0.92 : 0.72}\n      strokeWidth={isEditing ? '0.08' : '0.06'}\n      vectorEffect=\"non-scaling-stroke\"\n    />\n  )\n})\n\nconst FloorplanZoneLayer = memo(function FloorplanZoneLayer({\n  canSelectZones,\n  onZoneSelect,\n  palette,\n  selectedZoneId,\n  zonePolygons,\n}: {\n  canSelectZones: boolean\n  onZoneSelect: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void\n  palette: FloorplanPalette\n  selectedZoneId: ZoneNodeType['id'] | null\n  zonePolygons: ZonePolygonEntry[]\n}) {\n  return (\n    <>\n      {zonePolygons.map(({ zone, points }) => {\n        const isSelected = selectedZoneId === zone.id\n\n        return (\n          <g key={zone.id}>\n            <polygon\n              fill={zone.color}\n              fillOpacity={isSelected ? 0.28 : 0.16}\n              pointerEvents=\"none\"\n              points={points}\n              stroke={isSelected ? palette.selectedStroke : zone.color}\n              strokeLinejoin=\"round\"\n              strokeOpacity={isSelected ? 0.96 : 0.72}\n              strokeWidth={isSelected ? '0.08' : '0.05'}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            {canSelectZones && (\n              <polygon\n                fill=\"none\"\n                onClick={(event) => {\n                  event.stopPropagation()\n                  onZoneSelect(zone.id, event)\n                }}\n                pointerEvents=\"stroke\"\n                points={points}\n                stroke=\"transparent\"\n                strokeLinejoin=\"round\"\n                strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH}\n                style={{ cursor: EDITOR_CURSOR }}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n          </g>\n        )\n      })}\n    </>\n  )\n})\n\nconst FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({\n  endpointHandles,\n  hoveredEndpointId,\n  onWallEndpointPointerDown,\n  onEndpointHoverChange,\n  palette,\n}: {\n  endpointHandles: Array<{\n    wall: WallNode\n    endpoint: WallEndpoint\n    point: WallPlanPoint\n    isSelected: boolean\n    isActive: boolean\n  }>\n  onWallEndpointPointerDown: (\n    wall: WallNode,\n    endpoint: WallEndpoint,\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => void\n  hoveredEndpointId: string | null\n  onEndpointHoverChange: (endpointId: string | null) => void\n  palette: FloorplanPalette\n}) {\n  return (\n    <>\n      {endpointHandles.map(({ wall, endpoint, point, isSelected, isActive }) => {\n        const endpointId = `${wall.id}:${endpoint}`\n        const isHovered = hoveredEndpointId === endpointId\n        const stroke =\n          isSelected || isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke\n        const hoverStroke =\n          isSelected || isActive\n            ? palette.endpointHandleActiveStroke\n            : palette.endpointHandleHoverStroke\n        const outerRadius = isActive ? 0.18 : isSelected ? 0.16 : 0.14\n        const svgPoint = toSvgPlanPoint(point)\n\n        return (\n          <g\n            key={endpointId}\n            onClick={(event) => {\n              event.stopPropagation()\n            }}\n            onPointerEnter={() => onEndpointHoverChange(endpointId)}\n            onPointerLeave={() => onEndpointHoverChange(null)}\n          >\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"none\"\n              pointerEvents=\"none\"\n              r={outerRadius}\n              stroke={hoverStroke}\n              strokeOpacity={isActive ? 0.24 : 0.16}\n              strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}\n              style={{\n                opacity: isHovered ? 1 : 0,\n                transition: FLOORPLAN_HOVER_TRANSITION,\n              }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"none\"\n              pointerEvents=\"none\"\n              r={outerRadius}\n              stroke={hoverStroke}\n              strokeOpacity={isActive ? 0.72 : 0.52}\n              strokeWidth={FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH}\n              style={{\n                opacity: isHovered ? 1 : 0,\n                transition: FLOORPLAN_HOVER_TRANSITION,\n              }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}\n              fillOpacity={0.96}\n              pointerEvents=\"none\"\n              r={outerRadius}\n              stroke={stroke}\n              strokeWidth=\"0.05\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={stroke}\n              pointerEvents=\"none\"\n              r={isActive ? 0.08 : 0.06}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"transparent\"\n              onPointerDown={(event) => onWallEndpointPointerDown(wall, endpoint, event)}\n              pointerEvents=\"all\"\n              r={outerRadius}\n              stroke=\"transparent\"\n              strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}\n              style={{ cursor: EDITOR_CURSOR }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </g>\n        )\n      })}\n    </>\n  )\n})\n\nconst FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({\n  hoveredHandleId,\n  midpointHandles,\n  onHandleHoverChange,\n  onMidpointPointerDown,\n  onVertexDoubleClick,\n  onVertexPointerDown,\n  palette,\n  vertexHandles,\n}: {\n  vertexHandles: Array<{\n    nodeId: string\n    vertexIndex: number\n    point: WallPlanPoint\n    isActive: boolean\n  }>\n  midpointHandles: Array<{\n    nodeId: string\n    edgeIndex: number\n    point: WallPlanPoint\n  }>\n  hoveredHandleId: string | null\n  onHandleHoverChange: (handleId: string | null) => void\n  onVertexPointerDown: (\n    nodeId: string,\n    vertexIndex: number,\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => void\n  onVertexDoubleClick: (\n    nodeId: string,\n    vertexIndex: number,\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => void\n  onMidpointPointerDown: (\n    nodeId: string,\n    edgeIndex: number,\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => void\n  palette: FloorplanPalette\n}) {\n  return (\n    <>\n      {vertexHandles.map(({ nodeId, vertexIndex, point, isActive }) => {\n        const handleId = `${nodeId}:vertex:${vertexIndex}`\n        const isHovered = hoveredHandleId === handleId\n        const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke\n        const outerRadius = isActive ? 0.15 : 0.13\n        const svgPoint = toSvgPlanPoint(point)\n\n        return (\n          <g\n            key={handleId}\n            onClick={(event) => {\n              event.stopPropagation()\n            }}\n            onPointerEnter={() => onHandleHoverChange(handleId)}\n            onPointerLeave={() => onHandleHoverChange(null)}\n          >\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"none\"\n              pointerEvents=\"none\"\n              r={outerRadius}\n              stroke={stroke}\n              strokeOpacity={0.18}\n              strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}\n              style={{\n                opacity: isHovered ? 1 : 0,\n                transition: FLOORPLAN_HOVER_TRANSITION,\n              }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}\n              fillOpacity={0.96}\n              pointerEvents=\"none\"\n              r={outerRadius}\n              stroke={stroke}\n              strokeWidth=\"0.045\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={stroke}\n              pointerEvents=\"none\"\n              r={isActive ? 0.058 : 0.05}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"transparent\"\n              onDoubleClick={(event) => {\n                event.preventDefault()\n                event.stopPropagation()\n                onVertexDoubleClick(nodeId, vertexIndex, event as any)\n              }}\n              onPointerDown={(event) => {\n                onVertexPointerDown(nodeId, vertexIndex, event)\n              }}\n              pointerEvents=\"all\"\n              r={outerRadius}\n              stroke=\"transparent\"\n              strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}\n              style={{ cursor: EDITOR_CURSOR }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </g>\n        )\n      })}\n\n      {midpointHandles.map(({ nodeId, edgeIndex, point }) => {\n        const handleId = `${nodeId}:midpoint:${edgeIndex}`\n        const isHovered = hoveredHandleId === handleId\n        const stroke = isHovered ? palette.endpointHandleHoverStroke : palette.endpointHandleStroke\n        const radius = isHovered ? 0.092 : 0.08\n        const svgPoint = toSvgPlanPoint(point)\n\n        return (\n          <g\n            key={handleId}\n            onClick={(event) => {\n              event.stopPropagation()\n            }}\n            onPointerEnter={() => onHandleHoverChange(handleId)}\n            onPointerLeave={() => onHandleHoverChange(null)}\n          >\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"none\"\n              pointerEvents=\"none\"\n              r={radius + 0.03}\n              stroke={stroke}\n              strokeOpacity={0.16}\n              strokeWidth={FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH}\n              style={{\n                opacity: isHovered ? 1 : 0,\n                transition: FLOORPLAN_HOVER_TRANSITION,\n              }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={palette.surface}\n              fillOpacity={0.94}\n              pointerEvents=\"none\"\n              r={radius}\n              stroke={stroke}\n              strokeOpacity={0.9}\n              strokeWidth=\"0.035\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill={stroke}\n              fillOpacity={0.82}\n              pointerEvents=\"none\"\n              r=\"0.028\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <circle\n              cx={svgPoint.x}\n              cy={svgPoint.y}\n              fill=\"transparent\"\n              onPointerDown={(event) => onMidpointPointerDown(nodeId, edgeIndex, event)}\n              pointerEvents=\"all\"\n              r={radius}\n              stroke=\"transparent\"\n              strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}\n              style={{ cursor: EDITOR_CURSOR }}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </g>\n        )\n      })}\n    </>\n  )\n})\n\nexport function FloorplanPanel() {\n  const viewportHostRef = useRef<HTMLDivElement>(null)\n  const svgRef = useRef<SVGSVGElement>(null)\n  const panStateRef = useRef<PanState | null>(null)\n  const guideInteractionRef = useRef<GuideInteractionState | null>(null)\n  const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)\n  const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)\n  const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)\n  const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)\n  const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)\n  const gestureScaleRef = useRef(1)\n  const panelInteractionRef = useRef<PanelInteractionState | null>(null)\n  const panelBoundsRef = useRef<ViewportBounds | null>(null)\n  const containerRef = useRef<HTMLDivElement>(null)\n  const hasUserAdjustedViewportRef = useRef(false)\n  const previousLevelIdRef = useRef<string | null>(null)\n  const levelId = useViewer((state) => state.selection.levelId)\n  const buildingId = useViewer((state) => state.selection.buildingId)\n  const selectedZoneId = useViewer((state) => state.selection.zoneId)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const setSelection = useViewer((state) => state.setSelection)\n  const theme = useViewer((state) => state.theme)\n  const unit = useViewer((state) => state.unit)\n  const showGrid = useViewer((state) => state.showGrid)\n  const showGuides = useViewer((state) => state.showGuides)\n  const setShowGuides = useViewer((state) => state.setShowGuides)\n  const catalogCategory = useEditor((state) => state.catalogCategory)\n  const setCatalogCategory = useEditor((state) => state.setCatalogCategory)\n\n  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)\n  const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)\n  const selectedReferenceId = useEditor((state) => state.selectedReferenceId)\n  const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)\n  const setMode = useEditor((state) => state.setMode)\n  const movingNode = useEditor((state) => state.movingNode)\n  const phase = useEditor((state) => state.phase)\n  const mode = useEditor((state) => state.mode)\n  const setPhase = useEditor((state) => state.setPhase)\n  const setMovingNode = useEditor((state) => state.setMovingNode)\n  const structureLayer = useEditor((state) => state.structureLayer)\n  const setStructureLayer = useEditor((state) => state.setStructureLayer)\n  const setTool = useEditor((state) => state.setTool)\n  const tool = useEditor((state) => state.tool)\n  const deleteNode = useScene((state) => state.deleteNode)\n  const updateNode = useScene((state) => state.updateNode)\n  const levelNode = useScene((state) =>\n    levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,\n  )\n  const currentBuildingId =\n    levelNode?.type === 'level' && levelNode.parentId\n      ? (levelNode.parentId as BuildingNode['id'])\n      : (buildingId as BuildingNode['id'] | null)\n  const site = useScene((state) => {\n    for (const rootNodeId of state.rootNodeIds) {\n      const node = state.nodes[rootNodeId]\n      if (node?.type === 'site') {\n        return node as SiteNode\n      }\n    }\n\n    return null\n  })\n  const floorplanLevels = useScene(\n    useShallow((state) => {\n      if (!currentBuildingId) {\n        return [] as LevelNode[]\n      }\n\n      const buildingNode = state.nodes[currentBuildingId]\n      if (!buildingNode || buildingNode.type !== 'building') {\n        return [] as LevelNode[]\n      }\n\n      return buildingNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is LevelNode => node?.type === 'level')\n        .sort((a, b) => a.level - b.level)\n    }),\n  )\n  const walls = useScene(\n    useShallow((state) => {\n      if (!levelId) {\n        return [] as WallNode[]\n      }\n\n      const nextLevelNode = state.nodes[levelId]\n      if (!nextLevelNode || nextLevelNode.type !== 'level') {\n        return [] as WallNode[]\n      }\n\n      return nextLevelNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is WallNode => node?.type === 'wall')\n    }),\n  )\n  const openings = useScene(\n    useShallow((state) => {\n      if (!levelId) {\n        return [] as OpeningNode[]\n      }\n\n      const nextLevelNode = state.nodes[levelId]\n      if (!nextLevelNode || nextLevelNode.type !== 'level') {\n        return [] as OpeningNode[]\n      }\n\n      const nextWalls = nextLevelNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is WallNode => node?.type === 'wall')\n\n      return nextWalls.flatMap((wall) =>\n        wall.children\n          .map((childId) => state.nodes[childId])\n          .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),\n      )\n    }),\n  )\n  const slabs = useScene(\n    useShallow((state) => {\n      if (!levelId) {\n        return [] as SlabNode[]\n      }\n\n      const nextLevelNode = state.nodes[levelId]\n      if (!nextLevelNode || nextLevelNode.type !== 'level') {\n        return [] as SlabNode[]\n      }\n\n      return nextLevelNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is SlabNode => node?.type === 'slab')\n    }),\n  )\n  const levelGuides = useScene(\n    useShallow((state) => {\n      if (!levelId) {\n        return [] as GuideNode[]\n      }\n\n      const nextLevelNode = state.nodes[levelId]\n      if (!nextLevelNode || nextLevelNode.type !== 'level') {\n        return [] as GuideNode[]\n      }\n\n      return nextLevelNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is GuideNode => node?.type === 'guide')\n    }),\n  )\n  const zones = useScene(\n    useShallow((state) => {\n      if (!levelId) {\n        return [] as ZoneNodeType[]\n      }\n\n      const nextLevelNode = state.nodes[levelId]\n      if (!nextLevelNode || nextLevelNode.type !== 'level') {\n        return [] as ZoneNodeType[]\n      }\n\n      return nextLevelNode.children\n        .map((childId) => state.nodes[childId])\n        .filter((node): node is ZoneNodeType => node?.type === 'zone')\n    }),\n  )\n\n  const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)\n  const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)\n  const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])\n  const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])\n  const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)\n  const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)\n  const [slabBoundaryDraft, setSlabBoundaryDraft] = useState<SlabBoundaryDraft | null>(null)\n  const [slabVertexDragState, setSlabVertexDragState] = useState<SlabVertexDragState | null>(null)\n  const [zoneBoundaryDraft, setZoneBoundaryDraft] = useState<ZoneBoundaryDraft | null>(null)\n  const [zoneVertexDragState, setZoneVertexDragState] = useState<ZoneVertexDragState | null>(null)\n  const [guideTransformDraft, setGuideTransformDraft] = useState<GuideTransformDraft | null>(null)\n  const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)\n  const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)\n  const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)\n  const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)\n  const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)\n  const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)\n  const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)\n  const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)\n  const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)\n  const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)\n  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)\n  const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)\n  const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(\n    null,\n  )\n  const [shiftPressed, setShiftPressed] = useState(false)\n  const [rotationModifierPressed, setRotationModifierPressed] = useState(false)\n  const [isPanning, setIsPanning] = useState(false)\n  const [isDraggingPanel, setIsDraggingPanel] = useState(false)\n  const [isMacPlatform, setIsMacPlatform] = useState(true)\n  const [activeResizeDirection, setActiveResizeDirection] = useState<ResizeDirection | null>(null)\n  const [panelRect, setPanelRect] = useState<PanelRect>({\n    x: PANEL_MARGIN,\n    y: PANEL_MARGIN,\n    width: PANEL_DEFAULT_WIDTH,\n    height: PANEL_DEFAULT_HEIGHT,\n  })\n\n  const [isPanelReady, setIsPanelReady] = useState(false)\n  const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })\n  const [viewport, setViewport] = useState<FloorplanViewport | null>(null)\n\n  useEffect(() => {\n    if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {\n      setFloorplanSelectionTool('click')\n    }\n  }, [floorplanSelectionTool, structureLayer])\n\n  useEffect(() => {\n    setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))\n  }, [])\n\n  const sitePolygonEntry = useMemo(() => {\n    const polygonPoints = site?.polygon?.points\n    if (!(site && polygonPoints)) {\n      return null\n    }\n\n    const polygon = toFloorplanPolygon(polygonPoints)\n    if (polygon.length < 3) {\n      return null\n    }\n\n    return {\n      site,\n      polygon,\n      points: formatPolygonPoints(polygon),\n    }\n  }, [site])\n  const displaySitePolygon = useMemo(() => {\n    if (!sitePolygonEntry) {\n      return null\n    }\n\n    if (!(siteBoundaryDraft && siteBoundaryDraft.siteId === sitePolygonEntry.site.id)) {\n      return sitePolygonEntry\n    }\n\n    const polygon = siteBoundaryDraft.polygon.map(toPoint2D)\n\n    return {\n      ...sitePolygonEntry,\n      polygon,\n      points: formatPolygonPoints(polygon),\n    }\n  }, [siteBoundaryDraft, sitePolygonEntry])\n  const movingOpeningType =\n    movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null\n\n  const activeFloorplanToolConfig = useMemo(() => {\n    if (movingOpeningType) {\n      return structureTools.find((entry) => entry.id === movingOpeningType) ?? null\n    }\n\n    if (mode !== 'build' || !tool) {\n      return null\n    }\n\n    if (tool === 'item' && catalogCategory) {\n      return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null\n    }\n\n    return structureTools.find((entry) => entry.id === tool) ?? null\n  }, [catalogCategory, mode, movingOpeningType, tool])\n  const activeFloorplanCursorIndicator = useMemo<FloorplanCursorIndicator | null>(() => {\n    if (!activeFloorplanToolConfig) {\n      return null\n    }\n\n    return {\n      kind: 'asset',\n      iconSrc: activeFloorplanToolConfig.iconSrc,\n    }\n  }, [activeFloorplanToolConfig])\n  const visibleGuides = useMemo<GuideNode[]>(() => {\n    if (!showGuides) {\n      return []\n    }\n\n    return levelGuides.filter((guide) => guide.visible !== false)\n  }, [levelGuides, showGuides])\n  const guideById = useMemo(\n    () => new Map(levelGuides.map((guide) => [guide.id, guide] as const)),\n    [levelGuides],\n  )\n  const displayGuides = useMemo<GuideNode[]>(() => {\n    if (!guideTransformDraft) {\n      return visibleGuides\n    }\n\n    return visibleGuides.map((guide) =>\n      guide.id === guideTransformDraft.guideId\n        ? {\n            ...guide,\n            position: [\n              guideTransformDraft.position[0],\n              guide.position[1],\n              guideTransformDraft.position[1],\n            ] as [number, number, number],\n            rotation: [guide.rotation[0], guideTransformDraft.rotation, guide.rotation[2]] as [\n              number,\n              number,\n              number,\n            ],\n            scale: guideTransformDraft.scale,\n          }\n        : guide,\n    )\n  }, [guideTransformDraft, visibleGuides])\n  const selectedGuideId =\n    selectedReferenceId && guideById.has(selectedReferenceId as GuideNode['id'])\n      ? (selectedReferenceId as GuideNode['id'])\n      : null\n  const selectedGuide = useMemo(\n    () => displayGuides.find((guide) => guide.id === selectedGuideId) ?? null,\n    [displayGuides, selectedGuideId],\n  )\n  const selectedGuideResolvedUrl = useResolvedAssetUrl(selectedGuide?.url ?? '')\n  const selectedGuideDimensions = useGuideImageDimensions(selectedGuideResolvedUrl)\n  const activeGuideInteractionGuideId = guideTransformDraft\n    ? (guideInteractionRef.current?.guideId ?? null)\n    : null\n  const activeGuideInteractionMode = guideTransformDraft\n    ? (guideInteractionRef.current?.mode ?? null)\n    : null\n  const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])\n  const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])\n  const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])\n  const floorplanWallById = useMemo(\n    () => new Map(floorplanWalls.map((wall) => [wall.id, wall] as const)),\n    [floorplanWalls],\n  )\n  const displayWallById = useMemo(() => {\n    if (!wallEndpointDraft) {\n      return wallById\n    }\n\n    const wall = wallById.get(wallEndpointDraft.wallId)\n    if (!wall) {\n      return wallById\n    }\n\n    const nextWallById = new Map(wallById)\n    nextWallById.set(\n      wall.id,\n      buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),\n    )\n\n    return nextWallById\n  }, [wallById, wallEndpointDraft])\n  const displayFloorplanWallById = useMemo(() => {\n    if (!wallEndpointDraft) {\n      return floorplanWallById\n    }\n\n    const previewWall = displayWallById.get(wallEndpointDraft.wallId)\n    if (!previewWall) {\n      return floorplanWallById\n    }\n\n    const nextFloorplanWallById = new Map(floorplanWallById)\n    nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))\n    return nextFloorplanWallById\n  }, [displayWallById, floorplanWallById, wallEndpointDraft])\n  const wallPolygons = useMemo(\n    () =>\n      walls.map((wall) => {\n        const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall)\n        const polygon = getWallPlanFootprint(floorplanWall, wallMiterData)\n        return {\n          points: formatPolygonPoints(polygon),\n          wall,\n          polygon,\n        }\n      }),\n    [floorplanWallById, wallMiterData, walls],\n  )\n  const displayWallPolygons = useMemo(() => {\n    if (!wallEndpointDraft) {\n      return wallPolygons\n    }\n\n    const previewWall = displayWallById.get(wallEndpointDraft.wallId)\n    if (!previewWall) {\n      return wallPolygons\n    }\n\n    const previewPolygon = getWallPlanFootprint(\n      getFloorplanWall(previewWall),\n      EMPTY_WALL_MITER_DATA,\n    )\n\n    return wallPolygons.map((entry) =>\n      entry.wall.id === previewWall.id\n        ? {\n            wall: previewWall,\n            polygon: previewPolygon,\n            points: formatPolygonPoints(previewPolygon),\n          }\n        : entry,\n    )\n  }, [displayWallById, wallEndpointDraft, wallPolygons])\n\n  const openingsPolygons = useMemo(\n    () =>\n      openings.flatMap((opening) => {\n        const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id'])\n        if (!wall) return []\n        const polygon = getOpeningFootprint(wall, opening)\n        return [\n          {\n            opening,\n            points: formatPolygonPoints(polygon),\n            polygon,\n          },\n        ]\n      }),\n    [displayFloorplanWallById, openings],\n  )\n  const slabPolygons = useMemo(\n    () =>\n      slabs.flatMap((slab) => {\n        const polygon = toFloorplanPolygon(slab.polygon)\n        if (polygon.length < 3) {\n          return []\n        }\n\n        const holes = (slab.holes ?? [])\n          .map((hole) => toFloorplanPolygon(hole))\n          .filter((hole) => hole.length >= 3)\n\n        return [\n          {\n            slab,\n            polygon,\n            holes,\n            path: formatPolygonPath(polygon, holes),\n          },\n        ]\n      }),\n    [slabs],\n  )\n  const displaySlabPolygons = useMemo(() => {\n    if (!slabBoundaryDraft) {\n      return slabPolygons\n    }\n\n    return slabPolygons.map((entry) =>\n      entry.slab.id === slabBoundaryDraft.slabId\n        ? {\n            ...entry,\n            polygon: slabBoundaryDraft.polygon.map(toPoint2D),\n            path: formatPolygonPath(slabBoundaryDraft.polygon.map(toPoint2D), entry.holes),\n          }\n        : entry,\n    )\n  }, [slabBoundaryDraft, slabPolygons])\n  const zonePolygons = useMemo(\n    () =>\n      zones.flatMap((zone) => {\n        const polygon = toFloorplanPolygon(zone.polygon)\n        if (polygon.length < 3) {\n          return []\n        }\n\n        return [\n          {\n            zone,\n            polygon,\n            points: formatPolygonPoints(polygon),\n          },\n        ]\n      }),\n    [zones],\n  )\n  const displayZonePolygons = useMemo(() => {\n    if (!zoneBoundaryDraft) {\n      return zonePolygons\n    }\n\n    return zonePolygons.map((entry) =>\n      entry.zone.id === zoneBoundaryDraft.zoneId\n        ? {\n            ...entry,\n            polygon: zoneBoundaryDraft.polygon.map(toPoint2D),\n            points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)),\n          }\n        : entry,\n    )\n  }, [zoneBoundaryDraft, zonePolygons])\n  const selectedOpeningEntry = useMemo(() => {\n    if (selectedIds.length !== 1) {\n      return null\n    }\n\n    return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null\n  }, [openingsPolygons, selectedIds])\n  const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs])\n  const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones])\n  const selectedSlabEntry = useMemo(() => {\n    if (selectedIds.length !== 1) {\n      return null\n    }\n\n    return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null\n  }, [displaySlabPolygons, selectedIds])\n  const selectedZoneEntry = useMemo(() => {\n    if (!selectedZoneId) {\n      return null\n    }\n\n    return displayZonePolygons.find(({ zone }) => zone.id === selectedZoneId) ?? null\n  }, [displayZonePolygons, selectedZoneId])\n\n  const isSiteEditActive = phase === 'site'\n  const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall'\n  const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab'\n  const isZoneBuildActive = phase === 'structure' && mode === 'build' && tool === 'zone'\n  const isDoorBuildActive = phase === 'structure' && mode === 'build' && tool === 'door'\n  const isWindowBuildActive = phase === 'structure' && mode === 'build' && tool === 'window'\n  const isPolygonBuildActive = isSlabBuildActive || isZoneBuildActive\n  const isOpeningBuildActive = isDoorBuildActive || isWindowBuildActive\n  const isOpeningMoveActive = movingOpeningType !== null\n  const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive\n  const floorplanOpeningLocalY = useMemo(() => {\n    if (movingNode?.type === 'door' || movingNode?.type === 'window') {\n      return snapToHalf(movingNode.position[1])\n    }\n\n    if (isWindowBuildActive) {\n      // Floorplan is top-down, so new windows need an explicit wall-local height.\n      return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)\n    }\n\n    return 0\n  }, [isWindowBuildActive, movingNode])\n  const isMarqueeSelectionToolActive =\n    mode === 'select' &&\n    floorplanSelectionTool === 'marquee' &&\n    !movingNode &&\n    structureLayer !== 'zones'\n  const canSelectElementFloorplanGeometry =\n    mode === 'select' && floorplanSelectionTool === 'click' && !movingNode\n  const canInteractWithGuides = showGuides && canSelectElementFloorplanGeometry\n  const canSelectFloorplanZones =\n    mode === 'select' &&\n    floorplanSelectionTool === 'click' &&\n    !movingNode &&\n    structureLayer === 'zones'\n  const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null\n  const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null\n  const shouldShowPersistentWallEndpointHandles = mode === 'select' && !movingNode\n  const shouldShowSlabBoundaryHandles =\n    mode === 'select' &&\n    !movingNode &&\n    floorplanSelectionTool === 'click' &&\n    selectedSlabEntry !== null\n  const shouldShowZoneBoundaryHandles = canSelectFloorplanZones && selectedZoneEntry !== null\n  const showZonePolygons =\n    phase === 'structure' && (structureLayer === 'zones' || isZoneBuildActive)\n  const visibleZonePolygons = useMemo(\n    () => (showZonePolygons ? displayZonePolygons : []),\n    [displayZonePolygons, showZonePolygons],\n  )\n  const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])\n  const activeMarqueeBounds = useMemo(() => {\n    if (!floorplanMarqueeState) {\n      return null\n    }\n\n    return getFloorplanSelectionBounds(\n      floorplanMarqueeState.startPlanPoint,\n      floorplanMarqueeState.currentPlanPoint,\n    )\n  }, [floorplanMarqueeState])\n  const visibleMarqueeBounds = useMemo(() => {\n    if (!(floorplanMarqueeState && activeMarqueeBounds)) {\n      return null\n    }\n\n    const dragDistance = Math.hypot(\n      floorplanMarqueeState.currentPlanPoint[0] - floorplanMarqueeState.startPlanPoint[0],\n      floorplanMarqueeState.currentPlanPoint[1] - floorplanMarqueeState.startPlanPoint[1],\n    )\n\n    return dragDistance > 0 ? activeMarqueeBounds : null\n  }, [activeMarqueeBounds, floorplanMarqueeState])\n  const visibleSvgMarqueeBounds = useMemo(() => {\n    if (!visibleMarqueeBounds) {\n      return null\n    }\n\n    return toSvgSelectionBounds(visibleMarqueeBounds)\n  }, [visibleMarqueeBounds])\n  const wallEndpointHandles = useMemo(() => {\n    if (isOpeningPlacementActive || movingNode) {\n      return []\n    }\n\n    return displayWallPolygons.flatMap(({ wall }) => {\n      const isSelected = selectedIdSet.has(wall.id)\n      const isVisible =\n        shouldShowPersistentWallEndpointHandles ||\n        isWallBuildActive ||\n        isSelected ||\n        wallEndpointDraft?.wallId === wall.id\n      if (!isVisible) {\n        return []\n      }\n\n      return (['start', 'end'] as const).map((endpoint) => ({\n        wall,\n        endpoint,\n        point: endpoint === 'start' ? wall.start : wall.end,\n        isSelected,\n        isActive: wallEndpointDraft?.wallId === wall.id && wallEndpointDraft.endpoint === endpoint,\n      }))\n    })\n  }, [\n    displayWallPolygons,\n    isOpeningPlacementActive,\n    isWallBuildActive,\n    movingNode,\n    selectedIdSet,\n    shouldShowPersistentWallEndpointHandles,\n    wallEndpointDraft,\n  ])\n  const slabVertexHandles = useMemo(() => {\n    if (!shouldShowSlabBoundaryHandles) {\n      return []\n    }\n\n    return selectedSlabEntry.polygon.map((point, vertexIndex) => ({\n      nodeId: selectedSlabEntry.slab.id,\n      vertexIndex,\n      point: toWallPlanPoint(point),\n      isActive:\n        slabVertexDragState?.slabId === selectedSlabEntry.slab.id &&\n        slabVertexDragState.vertexIndex === vertexIndex,\n    }))\n  }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState])\n  const slabMidpointHandles = useMemo(() => {\n    if (!(shouldShowSlabBoundaryHandles && !slabVertexDragState)) {\n      return []\n    }\n\n    return selectedSlabEntry.polygon.map((point, edgeIndex, polygon) => {\n      const nextPoint = polygon[(edgeIndex + 1) % polygon.length]\n      return {\n        nodeId: selectedSlabEntry.slab.id,\n        edgeIndex,\n        point: [\n          (point.x + (nextPoint?.x ?? point.x)) / 2,\n          (point.y + (nextPoint?.y ?? point.y)) / 2,\n        ] as WallPlanPoint,\n      }\n    })\n  }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState])\n  const siteVertexHandles = useMemo(() => {\n    if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) {\n      return []\n    }\n\n    return visibleSitePolygon.polygon.map((point, vertexIndex) => ({\n      nodeId: visibleSitePolygon.site.id,\n      vertexIndex,\n      point: toWallPlanPoint(point),\n      isActive:\n        siteVertexDragState?.siteId === visibleSitePolygon.site.id &&\n        siteVertexDragState.vertexIndex === vertexIndex,\n    }))\n  }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])\n  const siteMidpointHandles = useMemo(() => {\n    if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon && !siteVertexDragState)) {\n      return []\n    }\n\n    return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {\n      const nextPoint = polygon[(edgeIndex + 1) % polygon.length]\n      return {\n        nodeId: visibleSitePolygon.site.id,\n        edgeIndex,\n        point: [\n          (point.x + (nextPoint?.x ?? point.x)) / 2,\n          (point.y + (nextPoint?.y ?? point.y)) / 2,\n        ] as WallPlanPoint,\n      }\n    })\n  }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])\n  const zoneVertexHandles = useMemo(() => {\n    if (!shouldShowZoneBoundaryHandles) {\n      return []\n    }\n\n    return selectedZoneEntry.polygon.map((point, vertexIndex) => ({\n      nodeId: selectedZoneEntry.zone.id,\n      vertexIndex,\n      point: toWallPlanPoint(point),\n      isActive:\n        zoneVertexDragState?.zoneId === selectedZoneEntry.zone.id &&\n        zoneVertexDragState.vertexIndex === vertexIndex,\n    }))\n  }, [selectedZoneEntry, shouldShowZoneBoundaryHandles, zoneVertexDragState])\n  const zoneMidpointHandles = useMemo(() => {\n    if (!(shouldShowZoneBoundaryHandles && !zoneVertexDragState)) {\n      return []\n    }\n\n    return selectedZoneEntry.polygon.map((point, edgeIndex, polygon) => {\n      const nextPoint = polygon[(edgeIndex + 1) % polygon.length]\n      return {\n        nodeId: selectedZoneEntry.zone.id,\n        edgeIndex,\n        point: [\n          (point.x + (nextPoint?.x ?? point.x)) / 2,\n          (point.y + (nextPoint?.y ?? point.y)) / 2,\n        ] as WallPlanPoint,\n      }\n    })\n  }, [selectedZoneEntry, shouldShowZoneBoundaryHandles, zoneVertexDragState])\n\n  const draftPolygon = useMemo(() => {\n    if (!(levelId && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) {\n      return null\n    }\n\n    const draftWall = getFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd))\n    // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.\n    return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)\n  }, [draftEnd, draftStart, levelId])\n  const draftPolygonPoints = useMemo(\n    () => (draftPolygon ? formatPolygonPoints(draftPolygon) : null),\n    [draftPolygon],\n  )\n  const activePolygonDraftPoints = useMemo(() => {\n    if (isZoneBuildActive) {\n      return zoneDraftPoints\n    }\n\n    if (isSlabBuildActive) {\n      return slabDraftPoints\n    }\n\n    return [] as WallPlanPoint[]\n  }, [isSlabBuildActive, isZoneBuildActive, slabDraftPoints, zoneDraftPoints])\n  const polygonDraftPolylinePoints = useMemo(() => {\n    if (!(isPolygonBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {\n      return null\n    }\n\n    return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])\n  }, [activePolygonDraftPoints, cursorPoint, isPolygonBuildActive])\n  const polygonDraftPolygonPoints = useMemo(() => {\n    if (!(isPolygonBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {\n      return null\n    }\n\n    return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])\n  }, [activePolygonDraftPoints, cursorPoint, isPolygonBuildActive])\n  const polygonDraftClosingSegment = useMemo(() => {\n    if (!(isPolygonBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {\n      return null\n    }\n\n    const firstPoint = activePolygonDraftPoints[0]\n    if (!firstPoint) {\n      return null\n    }\n\n    return {\n      x1: toSvgX(cursorPoint[0]),\n      y1: toSvgY(cursorPoint[1]),\n      x2: toSvgX(firstPoint[0]),\n      y2: toSvgY(firstPoint[1]),\n    }\n  }, [activePolygonDraftPoints, cursorPoint, isPolygonBuildActive])\n\n  const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1\n\n  const fittedViewport = useMemo(() => {\n    const allPoints = [\n      ...(visibleSitePolygon ? visibleSitePolygon.polygon : []),\n      ...displaySlabPolygons.flatMap((entry) => entry.polygon),\n      ...visibleZonePolygons.flatMap((entry) => entry.polygon),\n      ...wallPolygons.flatMap((entry) => entry.polygon),\n    ]\n\n    if (allPoints.length === 0) {\n      return {\n        centerX: 0,\n        centerY: 0,\n        width: Math.max(FALLBACK_VIEW_SIZE, FALLBACK_VIEW_SIZE * svgAspectRatio),\n      }\n    }\n\n    let minX = Number.POSITIVE_INFINITY\n    let maxX = Number.NEGATIVE_INFINITY\n    let minY = Number.POSITIVE_INFINITY\n    let maxY = Number.NEGATIVE_INFINITY\n\n    for (const point of allPoints) {\n      const svgPoint = toSvgPoint(point)\n      minX = Math.min(minX, svgPoint.x)\n      maxX = Math.max(maxX, svgPoint.x)\n      minY = Math.min(minY, svgPoint.y)\n      maxY = Math.max(maxY, svgPoint.y)\n    }\n\n    const rawWidth = maxX - minX\n    const rawHeight = maxY - minY\n    const paddedWidth = rawWidth + FLOORPLAN_PADDING * 2\n    const paddedHeight = rawHeight + FLOORPLAN_PADDING * 2\n    const width = Math.max(FALLBACK_VIEW_SIZE, paddedWidth, paddedHeight * svgAspectRatio)\n    const centerX = (minX + maxX) / 2\n    const centerY = (minY + maxY) / 2\n\n    return {\n      centerX,\n      centerY,\n      width,\n    }\n  }, [displaySlabPolygons, svgAspectRatio, visibleSitePolygon, visibleZonePolygons, wallPolygons])\n\n  useEffect(() => {\n    const host = viewportHostRef.current\n    if (!host) {\n      return\n    }\n\n    const updateSize = () => {\n      const rect = host.getBoundingClientRect()\n      setSurfaceSize({\n        width: Math.max(rect.width, 1),\n        height: Math.max(rect.height, 1),\n      })\n    }\n\n    updateSize()\n\n    const resizeObserver = new ResizeObserver(updateSize)\n    resizeObserver.observe(host)\n    return () => {\n      resizeObserver.disconnect()\n    }\n  }, [])\n\n  // Track actual container position and size for SVG coordinate transforms\n  useEffect(() => {\n    const el = containerRef.current\n    if (!el) return\n    const update = () => {\n      const rect = el.getBoundingClientRect()\n      setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height })\n      setIsPanelReady(true)\n    }\n    const observer = new ResizeObserver(update)\n    observer.observe(el)\n    window.addEventListener('resize', update)\n    update()\n    return () => {\n      observer.disconnect()\n      window.removeEventListener('resize', update)\n    }\n  }, [])\n\n  useEffect(() => {\n    const levelChanged = previousLevelIdRef.current !== (levelId ?? null)\n\n    if (levelChanged) {\n      previousLevelIdRef.current = levelId ?? null\n      hasUserAdjustedViewportRef.current = false\n      setViewport(fittedViewport)\n      return\n    }\n\n    if (!hasUserAdjustedViewportRef.current) {\n      setViewport(fittedViewport)\n    }\n  }, [fittedViewport, levelId])\n\n  useEffect(() => {\n    if (!(phase === 'site' && levelNode?.type === 'level' && levelNode.level > 0)) {\n      return\n    }\n\n    setPhase('structure')\n  }, [levelNode, phase, setPhase])\n\n  const viewBox = useMemo(() => {\n    const currentViewport = viewport ?? fittedViewport\n    const width = currentViewport.width\n    const height = width / svgAspectRatio\n\n    return {\n      minX: currentViewport.centerX - width / 2,\n      minY: currentViewport.centerY - height / 2,\n      width,\n      height,\n    }\n  }, [fittedViewport, svgAspectRatio, viewport])\n  const floorplanWorldUnitsPerPixel = useMemo(() => {\n    const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)\n    const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1)\n\n    return (widthUnitsPerPixel + heightUnitsPerPixel) / 2\n  }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width])\n  const floorplanWallHitTolerance = useMemo(\n    () => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2),\n    [floorplanWorldUnitsPerPixel],\n  )\n  const floorplanOpeningHitTolerance = useMemo(\n    () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2),\n    [floorplanWorldUnitsPerPixel],\n  )\n  const selectedOpeningActionMenuPosition = useMemo(() => {\n    if (!selectedOpeningEntry) {\n      return null\n    }\n\n    let minX = Number.POSITIVE_INFINITY\n    let maxX = Number.NEGATIVE_INFINITY\n    let minY = Number.POSITIVE_INFINITY\n    let maxY = Number.NEGATIVE_INFINITY\n\n    for (const point of selectedOpeningEntry.polygon) {\n      const svgPoint = toSvgPoint(point)\n      minX = Math.min(minX, svgPoint.x)\n      maxX = Math.max(maxX, svgPoint.x)\n      minY = Math.min(minY, svgPoint.y)\n      maxY = Math.max(maxY, svgPoint.y)\n    }\n\n    if (\n      !(\n        Number.isFinite(minX) &&\n        Number.isFinite(maxX) &&\n        Number.isFinite(minY) &&\n        Number.isFinite(maxY)\n      )\n    ) {\n      return null\n    }\n\n    if (\n      maxX < viewBox.minX ||\n      minX > viewBox.minX + viewBox.width ||\n      maxY < viewBox.minY ||\n      minY > viewBox.minY + viewBox.height\n    ) {\n      return null\n    }\n\n    const anchorX = (((minX + maxX) / 2 - viewBox.minX) / viewBox.width) * surfaceSize.width\n    const anchorY = ((minY - viewBox.minY) / viewBox.height) * surfaceSize.height\n\n    return {\n      x: Math.min(\n        Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING),\n        surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING,\n      ),\n      y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y),\n    }\n  }, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox])\n\n  useEffect(() => {\n    setHoveredGuideCorner(null)\n  }, [selectedGuide?.id])\n\n  useEffect(() => {\n    if (!(selectedGuide && showGuides && canInteractWithGuides)) {\n      setHoveredGuideCorner(null)\n    }\n  }, [canInteractWithGuides, selectedGuide, showGuides])\n\n  const guideHandleHintAnchor = useMemo<GuideHandleHintAnchor | null>(() => {\n    if (\n      !(\n        hoveredGuideCorner &&\n        selectedGuide &&\n        selectedGuideDimensions &&\n        surfaceSize.width > 0 &&\n        surfaceSize.height > 0 &&\n        viewBox.width > 0 &&\n        viewBox.height > 0\n      )\n    ) {\n      return null\n    }\n\n    const aspectRatio = selectedGuideDimensions.width / selectedGuideDimensions.height\n    if (!(aspectRatio > 0)) {\n      return null\n    }\n\n    const planWidth = getGuideWidth(selectedGuide.scale)\n    const planHeight = getGuideHeight(planWidth, aspectRatio)\n    const centerSvg = getGuideCenterSvgPoint(selectedGuide)\n    const handleSvg = getGuideCornerSvgPoint(\n      centerSvg,\n      planWidth,\n      planHeight,\n      -selectedGuide.rotation[1],\n      hoveredGuideCorner,\n    )\n\n    if (\n      handleSvg.x < viewBox.minX ||\n      handleSvg.x > viewBox.minX + viewBox.width ||\n      handleSvg.y < viewBox.minY ||\n      handleSvg.y > viewBox.minY + viewBox.height\n    ) {\n      return null\n    }\n\n    const centerX = ((centerSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width\n    const centerY = ((centerSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height\n    const handleX = ((handleSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width\n    const handleY = ((handleSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height\n\n    let directionX = handleX - centerX\n    let directionY = handleY - centerY\n    const directionLength = Math.hypot(directionX, directionY)\n\n    if (directionLength > 0.001) {\n      directionX /= directionLength\n      directionY /= directionLength\n    } else {\n      directionX = 1\n      directionY = 0\n    }\n\n    const minX = Math.min(FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X, surfaceSize.width / 2)\n    const maxX = Math.max(surfaceSize.width - FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X, minX)\n    const minY = Math.min(FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y, surfaceSize.height / 2)\n    const maxY = Math.max(surfaceSize.height - FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y, minY)\n\n    return {\n      x: clamp(handleX + directionX * FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET, minX, maxX),\n      y: clamp(handleY + directionY * FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET, minY, maxY),\n      directionX,\n      directionY,\n    }\n  }, [\n    hoveredGuideCorner,\n    selectedGuide,\n    selectedGuideDimensions,\n    surfaceSize.height,\n    surfaceSize.width,\n    viewBox,\n  ])\n\n  const minViewportWidth = fittedViewport.width * MIN_VIEWPORT_WIDTH_RATIO\n  const maxViewportWidth = fittedViewport.width * MAX_VIEWPORT_WIDTH_RATIO\n\n  const palette = useMemo(\n    () =>\n      theme === 'dark'\n        ? {\n            surface: '#0a0e1b',\n            minorGrid: '#475569',\n            majorGrid: '#94a3b8',\n            minorGridOpacity: 0.7,\n            majorGridOpacity: 0.9,\n            slabFill: '#5f6483',\n            slabStroke: '#71717a',\n            selectedSlabFill: '#b7b5f7',\n            wallFill: '#fafafa',\n            wallStroke: '#38bdf8',\n            wallHoverStroke: '#a1a1aa',\n            selectedFill: '#8381ed',\n            selectedStroke: '#8381ed',\n            draftFill: '#818cf8',\n            draftStroke: '#c7d2fe',\n            measurementStroke: '#cbd5e1',\n            cursor: '#818cf8',\n            editCursor: '#8381ed',\n            anchor: '#818cf8',\n            openingFill: '#0a0e1b',\n            openingStroke: '#fafafa',\n            endpointHandleFill: '#09090b',\n            endpointHandleStroke: '#a1a1aa',\n            endpointHandleHoverStroke: '#d4d4d8',\n            endpointHandleActiveFill: '#8381ed',\n            endpointHandleActiveStroke: '#8381ed',\n          }\n        : {\n            surface: '#ffffff',\n            minorGrid: '#94a3b8',\n            majorGrid: '#475569',\n            minorGridOpacity: 0.7,\n            majorGridOpacity: 0.9,\n            slabFill: '#c4c4cc',\n            slabStroke: '#52525b',\n            selectedSlabFill: '#b7b5f7',\n            wallFill: '#171717',\n            wallStroke: '#0284c7',\n            wallHoverStroke: '#71717a',\n            selectedFill: '#8381ed',\n            selectedStroke: '#8381ed',\n            draftFill: '#6366f1',\n            draftStroke: '#4338ca',\n            measurementStroke: '#334155',\n            cursor: '#6366f1',\n            editCursor: '#8381ed',\n            anchor: '#4338ca',\n            openingFill: '#ffffff',\n            openingStroke: '#171717',\n            endpointHandleFill: '#ffffff',\n            endpointHandleStroke: '#71717a',\n            endpointHandleHoverStroke: '#52525b',\n            endpointHandleActiveFill: '#8381ed',\n            endpointHandleActiveStroke: '#8381ed',\n          },\n    [theme],\n  )\n  const gridSteps = useMemo(\n    () => getVisibleGridSteps(viewBox.width, surfaceSize.width),\n    [surfaceSize.width, viewBox.width],\n  )\n\n  const minorGridPath = useMemo(\n    () =>\n      buildGridPath(\n        viewBox.minX,\n        viewBox.minX + viewBox.width,\n        viewBox.minY,\n        viewBox.minY + viewBox.height,\n        gridSteps.minorStep,\n        {\n          excludeStep: gridSteps.majorStep,\n        },\n      ),\n    [gridSteps.majorStep, gridSteps.minorStep, viewBox],\n  )\n  const majorGridPath = useMemo(\n    () =>\n      buildGridPath(\n        viewBox.minX,\n        viewBox.minX + viewBox.width,\n        viewBox.minY,\n        viewBox.minY + viewBox.height,\n        gridSteps.majorStep,\n      ),\n    [gridSteps.majorStep, viewBox],\n  )\n\n  const getSvgPointFromClientPoint = useCallback(\n    (clientX: number, clientY: number): SvgPoint | null => {\n      const svg = svgRef.current\n      const ctm = svg?.getScreenCTM()\n      if (!(svg && ctm)) {\n        return null\n      }\n\n      const screenPoint = svg.createSVGPoint()\n      screenPoint.x = clientX\n      screenPoint.y = clientY\n      const transformedPoint = screenPoint.matrixTransform(ctm.inverse())\n\n      return { x: transformedPoint.x, y: transformedPoint.y }\n    },\n    [],\n  )\n\n  const getPlanPointFromClientPoint = useCallback(\n    (clientX: number, clientY: number): WallPlanPoint | null => {\n      const svgPoint = getSvgPointFromClientPoint(clientX, clientY)\n      if (!svgPoint) {\n        return null\n      }\n\n      return toPlanPointFromSvgPoint(svgPoint)\n    },\n    [getSvgPointFromClientPoint],\n  )\n  useEffect(() => {\n    siteBoundaryDraftRef.current = siteBoundaryDraft\n  }, [siteBoundaryDraft])\n\n  useEffect(() => {\n    slabBoundaryDraftRef.current = slabBoundaryDraft\n  }, [slabBoundaryDraft])\n\n  useEffect(() => {\n    zoneBoundaryDraftRef.current = zoneBoundaryDraft\n  }, [zoneBoundaryDraft])\n\n  useEffect(() => {\n    guideTransformDraftRef.current = guideTransformDraft\n  }, [guideTransformDraft])\n\n  const updateViewport = useCallback((nextViewport: FloorplanViewport) => {\n    hasUserAdjustedViewportRef.current = true\n    setViewport(nextViewport)\n  }, [])\n\n  const clearGuideInteraction = useCallback(() => {\n    guideInteractionRef.current = null\n    guideTransformDraftRef.current = null\n    setGuideTransformDraft(null)\n    document.body.style.userSelect = ''\n    document.body.style.cursor = ''\n  }, [])\n\n  const finishPanelInteraction = useCallback(() => {\n    panelInteractionRef.current = null\n    setIsDraggingPanel(false)\n    setActiveResizeDirection(null)\n    document.body.style.userSelect = ''\n    document.body.style.cursor = ''\n  }, [])\n\n  const beginPanelInteraction = useCallback((interaction: PanelInteractionState) => {\n    panelInteractionRef.current = interaction\n    if (interaction.type === 'drag') {\n      setIsDraggingPanel(true)\n      setActiveResizeDirection(null)\n      document.body.style.cursor = 'grabbing'\n    } else if (interaction.direction) {\n      setIsDraggingPanel(false)\n      setActiveResizeDirection(interaction.direction)\n      document.body.style.cursor = resizeCursorByDirection[interaction.direction]\n    }\n\n    document.body.style.userSelect = 'none'\n  }, [])\n\n  useEffect(() => {\n    const handlePointerMove = (event: PointerEvent) => {\n      const interaction = panelInteractionRef.current\n      if (!interaction || event.pointerId !== interaction.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const dx = event.clientX - interaction.startClientX\n      const dy = event.clientY - interaction.startClientY\n      const bounds = getViewportBounds()\n\n      const nextRect =\n        interaction.type === 'drag'\n          ? movePanelRect(interaction.initialRect, dx, dy, bounds)\n          : resizePanelRect(interaction.initialRect, interaction.direction ?? 'se', dx, dy, bounds)\n\n      setPanelRect(nextRect)\n    }\n\n    const handlePointerUp = (event: PointerEvent) => {\n      const interaction = panelInteractionRef.current\n      if (!interaction || event.pointerId !== interaction.pointerId) {\n        return\n      }\n\n      finishPanelInteraction()\n    }\n\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n    window.addEventListener('pointercancel', handlePointerUp)\n\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n      window.removeEventListener('pointercancel', handlePointerUp)\n    }\n  }, [finishPanelInteraction])\n\n  useEffect(() => {\n    return () => {\n      finishPanelInteraction()\n    }\n  }, [finishPanelInteraction])\n\n  useEffect(() => {\n    const interaction = guideInteractionRef.current\n    if (interaction && !guideById.has(interaction.guideId)) {\n      clearGuideInteraction()\n    }\n  }, [clearGuideInteraction, guideById])\n\n  useEffect(() => {\n    if (!canInteractWithGuides) {\n      clearGuideInteraction()\n    }\n  }, [canInteractWithGuides, clearGuideInteraction])\n\n  useEffect(() => {\n    return () => {\n      clearGuideInteraction()\n    }\n  }, [clearGuideInteraction])\n\n  const handlePanelDragStart = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      const target = event.target as HTMLElement | null\n      if (target?.closest('[data-floorplan-panel-control=\"true\"]')) {\n        return\n      }\n\n      event.preventDefault()\n\n      beginPanelInteraction({\n        pointerId: event.pointerId,\n        startClientX: event.clientX,\n        startClientY: event.clientY,\n        initialRect: panelRect,\n        type: 'drag',\n      })\n    },\n    [beginPanelInteraction, panelRect],\n  )\n\n  const handleResizeStart = useCallback(\n    (direction: ResizeDirection, event: ReactPointerEvent<HTMLDivElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      beginPanelInteraction({\n        pointerId: event.pointerId,\n        startClientX: event.clientX,\n        startClientY: event.clientY,\n        initialRect: panelRect,\n        type: 'resize',\n        direction,\n      })\n    },\n    [beginPanelInteraction, panelRect],\n  )\n\n  const zoomViewportAtClientPoint = useCallback(\n    (clientX: number, clientY: number, widthFactor: number) => {\n      if (!Number.isFinite(widthFactor) || widthFactor <= 0) {\n        return\n      }\n\n      const svgPoint = getSvgPointFromClientPoint(clientX, clientY)\n      if (!svgPoint) {\n        return\n      }\n\n      const currentViewport = viewport ?? fittedViewport\n      const currentViewBox = viewBox\n      const nextWidth = Math.min(\n        maxViewportWidth,\n        Math.max(minViewportWidth, currentViewport.width * widthFactor),\n      )\n      const nextHeight = nextWidth / svgAspectRatio\n      const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width\n      const normalizedY = (svgPoint.y - currentViewBox.minY) / currentViewBox.height\n      const nextMinX = svgPoint.x - normalizedX * nextWidth\n      const nextMinY = svgPoint.y - normalizedY * nextHeight\n\n      updateViewport({\n        centerX: nextMinX + nextWidth / 2,\n        centerY: nextMinY + nextHeight / 2,\n        width: nextWidth,\n      })\n    },\n    [\n      fittedViewport,\n      getSvgPointFromClientPoint,\n      maxViewportWidth,\n      minViewportWidth,\n      svgAspectRatio,\n      updateViewport,\n      viewBox,\n      viewport,\n    ],\n  )\n\n  const clearWallPlacementDraft = useCallback(() => {\n    setDraftStart(null)\n    setDraftEnd(null)\n  }, [])\n  const clearSlabPlacementDraft = useCallback(() => {\n    setSlabDraftPoints([])\n  }, [])\n  const clearZonePlacementDraft = useCallback(() => {\n    setZoneDraftPoints([])\n  }, [])\n\n  const clearWallEndpointDrag = useCallback(() => {\n    wallEndpointDragRef.current = null\n    setWallEndpointDraft(null)\n    setHoveredEndpointId(null)\n  }, [])\n  const clearSiteBoundaryInteraction = useCallback(() => {\n    setSiteVertexDragState(null)\n    setSiteBoundaryDraft(null)\n    setHoveredSiteHandleId(null)\n  }, [])\n  const clearSlabBoundaryInteraction = useCallback(() => {\n    setSlabVertexDragState(null)\n    setSlabBoundaryDraft(null)\n    setHoveredSlabHandleId(null)\n  }, [])\n  const clearZoneBoundaryInteraction = useCallback(() => {\n    setZoneVertexDragState(null)\n    setZoneBoundaryDraft(null)\n    setHoveredZoneHandleId(null)\n  }, [])\n\n  const clearDraft = useCallback(() => {\n    clearWallPlacementDraft()\n    clearSlabPlacementDraft()\n    clearZonePlacementDraft()\n    clearWallEndpointDrag()\n    clearSiteBoundaryInteraction()\n    clearSlabBoundaryInteraction()\n    clearZoneBoundaryInteraction()\n    setCursorPoint(null)\n  }, [\n    clearSiteBoundaryInteraction,\n    clearSlabBoundaryInteraction,\n    clearSlabPlacementDraft,\n    clearZoneBoundaryInteraction,\n    clearWallEndpointDrag,\n    clearWallPlacementDraft,\n    clearZonePlacementDraft,\n  ])\n\n  useEffect(() => {\n    if (isWallBuildActive || isPolygonBuildActive) {\n      return\n    }\n\n    clearDraft()\n  }, [clearDraft, isPolygonBuildActive, isWallBuildActive])\n\n  useEffect(() => {\n    const handleCancel = () => {\n      clearDraft()\n    }\n\n    emitter.on('tool:cancel', handleCancel)\n    return () => {\n      emitter.off('tool:cancel', handleCancel)\n    }\n  }, [clearDraft])\n\n  const createSlabOnCurrentLevel = useCallback(\n    (points: WallPlanPoint[]) => {\n      if (!levelId) {\n        return null\n      }\n\n      const { createNode, nodes } = useScene.getState()\n      const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length\n      const slab = SlabNode.parse({\n        name: `Slab ${slabCount + 1}`,\n        polygon: points.map(([x, z]) => [x, z] as [number, number]),\n      })\n\n      createNode(slab, levelId)\n      sfxEmitter.emit('sfx:structure-build')\n      setSelection({ selectedIds: [slab.id] })\n      return slab.id\n    },\n    [levelId, setSelection],\n  )\n  const createZoneOnCurrentLevel = useCallback(\n    (points: WallPlanPoint[]) => {\n      if (!levelId) {\n        return null\n      }\n\n      const { createNode, nodes } = useScene.getState()\n      const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length\n      const zone = ZoneNodeSchema.parse({\n        color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],\n        name: `Zone ${zoneCount + 1}`,\n        polygon: points.map(([x, z]) => [x, z] as [number, number]),\n      })\n\n      createNode(zone, levelId)\n      sfxEmitter.emit('sfx:structure-build')\n      setSelection({ zoneId: zone.id })\n      return zone.id\n    },\n    [levelId, setSelection],\n  )\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Shift') {\n        setShiftPressed(true)\n      }\n\n      setRotationModifierPressed(\n        event.key === 'Meta' || event.key === 'Control' || event.metaKey || event.ctrlKey,\n      )\n    }\n    const handleKeyUp = (event: KeyboardEvent) => {\n      if (event.key === 'Shift') {\n        setShiftPressed(false)\n      }\n\n      setRotationModifierPressed(event.metaKey || event.ctrlKey)\n    }\n    const handleBlur = () => {\n      setShiftPressed(false)\n      setRotationModifierPressed(false)\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    window.addEventListener('keyup', handleKeyUp)\n    window.addEventListener('blur', handleBlur)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown)\n      window.removeEventListener('keyup', handleKeyUp)\n      window.removeEventListener('blur', handleBlur)\n    }\n  }, [])\n\n  useEffect(() => {\n    const handleWindowPointerMove = (event: PointerEvent) => {\n      const guideInteraction = guideInteractionRef.current\n      if (guideInteraction && event.pointerId === guideInteraction.pointerId) {\n        event.preventDefault()\n\n        const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)\n        if (!svgPoint) {\n          return\n        }\n\n        const nextDraft =\n          guideInteraction.mode === 'rotate'\n            ? buildGuideRotationDraft(guideInteraction, svgPoint, shiftPressed)\n            : guideInteraction.mode === 'translate'\n              ? buildGuideTranslateDraft(guideInteraction, svgPoint)\n              : buildGuideResizeDraft(guideInteraction, svgPoint)\n\n        if (areGuideTransformDraftsEqual(guideTransformDraftRef.current, nextDraft)) {\n          return\n        }\n\n        guideTransformDraftRef.current = nextDraft\n        setGuideTransformDraft(nextDraft)\n        return\n      }\n\n      const dragState = wallEndpointDragRef.current\n      if (!dragState || event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      const snappedPoint = snapWallDraftPoint({\n        point: planPoint,\n        walls,\n        start: dragState.fixedPoint,\n        angleSnap: !shiftPressed,\n        ignoreWallIds: [dragState.wallId],\n      })\n\n      if (pointsEqual(dragState.currentPoint, snappedPoint)) {\n        return\n      }\n\n      dragState.currentPoint = snappedPoint\n      setCursorPoint(snappedPoint)\n      setWallEndpointDraft((previousDraft) => {\n        const nextDraft = buildWallEndpointDraft(\n          dragState.wallId,\n          dragState.endpoint,\n          dragState.fixedPoint,\n          snappedPoint,\n        )\n\n        if (\n          !(\n            previousDraft &&\n            pointsEqual(previousDraft.start, nextDraft.start) &&\n            pointsEqual(previousDraft.end, nextDraft.end)\n          )\n        ) {\n          sfxEmitter.emit('sfx:grid-snap')\n        }\n\n        return nextDraft\n      })\n    }\n\n    const commitGuideInteraction = (event: PointerEvent) => {\n      const interaction = guideInteractionRef.current\n      if (!interaction || event.pointerId !== interaction.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const guide = guideById.get(interaction.guideId)\n      if (!guide) {\n        clearGuideInteraction()\n        return\n      }\n\n      const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)\n      const nextDraft = svgPoint\n        ? interaction.mode === 'rotate'\n          ? buildGuideRotationDraft(interaction, svgPoint, shiftPressed)\n          : interaction.mode === 'translate'\n            ? buildGuideTranslateDraft(interaction, svgPoint)\n            : buildGuideResizeDraft(interaction, svgPoint)\n        : guideTransformDraftRef.current\n\n      if (nextDraft && !doesGuideMatchDraft(guide, nextDraft)) {\n        updateNode(guide.id, {\n          position: [nextDraft.position[0], guide.position[1], nextDraft.position[1]] as [\n            number,\n            number,\n            number,\n          ],\n          rotation: [guide.rotation[0], nextDraft.rotation, guide.rotation[2]] as [\n            number,\n            number,\n            number,\n          ],\n          scale: nextDraft.scale,\n        })\n      }\n\n      clearGuideInteraction()\n    }\n\n    const cancelGuideInteraction = (event: PointerEvent) => {\n      const interaction = guideInteractionRef.current\n      if (!interaction || event.pointerId !== interaction.pointerId) {\n        return\n      }\n\n      clearGuideInteraction()\n    }\n\n    const commitWallEndpointDrag = (event: PointerEvent) => {\n      const dragState = wallEndpointDragRef.current\n      if (!dragState || event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      const wall = wallById.get(dragState.wallId)\n      if (wall) {\n        const nextDraft = buildWallEndpointDraft(\n          dragState.wallId,\n          dragState.endpoint,\n          dragState.fixedPoint,\n          dragState.currentPoint,\n        )\n        const hasChanged = !(\n          pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end)\n        )\n\n        if (hasChanged && isWallLongEnough(nextDraft.start, nextDraft.end)) {\n          updateNode(wall.id, {\n            start: nextDraft.start,\n            end: nextDraft.end,\n          })\n          sfxEmitter.emit('sfx:structure-build')\n        }\n      }\n\n      clearWallEndpointDrag()\n      setCursorPoint(null)\n    }\n\n    const cancelWallEndpointDrag = (event: PointerEvent) => {\n      const dragState = wallEndpointDragRef.current\n      if (!dragState || event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      clearWallEndpointDrag()\n      setCursorPoint(null)\n    }\n\n    window.addEventListener('pointermove', handleWindowPointerMove)\n    window.addEventListener('pointerup', commitGuideInteraction)\n    window.addEventListener('pointercancel', cancelGuideInteraction)\n    window.addEventListener('pointerup', commitWallEndpointDrag)\n    window.addEventListener('pointercancel', cancelWallEndpointDrag)\n\n    return () => {\n      window.removeEventListener('pointermove', handleWindowPointerMove)\n      window.removeEventListener('pointerup', commitGuideInteraction)\n      window.removeEventListener('pointercancel', cancelGuideInteraction)\n      window.removeEventListener('pointerup', commitWallEndpointDrag)\n      window.removeEventListener('pointercancel', cancelWallEndpointDrag)\n    }\n  }, [\n    clearGuideInteraction,\n    clearWallEndpointDrag,\n    getSvgPointFromClientPoint,\n    guideById,\n    getPlanPointFromClientPoint,\n    shiftPressed,\n    updateNode,\n    wallById,\n    walls,\n  ])\n\n  useEffect(() => {\n    clearWallEndpointDrag()\n  }, [clearWallEndpointDrag, levelId])\n\n  useEffect(() => {\n    if (shouldShowSiteBoundaryHandles) {\n      return\n    }\n\n    clearSiteBoundaryInteraction()\n  }, [clearSiteBoundaryInteraction, shouldShowSiteBoundaryHandles])\n\n  useEffect(() => {\n    if (shouldShowSlabBoundaryHandles) {\n      return\n    }\n\n    clearSlabBoundaryInteraction()\n  }, [clearSlabBoundaryInteraction, shouldShowSlabBoundaryHandles])\n\n  useEffect(() => {\n    if (shouldShowZoneBoundaryHandles) {\n      return\n    }\n\n    clearZoneBoundaryInteraction()\n  }, [clearZoneBoundaryInteraction, shouldShowZoneBoundaryHandles])\n\n  useEffect(() => {\n    const dragState = siteVertexDragState\n    if (!dragState) {\n      return\n    }\n\n    const handleWindowPointerMove = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]\n      setCursorPoint(snappedPoint)\n\n      setSiteBoundaryDraft((currentDraft) => {\n        if (!currentDraft || currentDraft.siteId !== dragState.siteId) {\n          return currentDraft\n        }\n\n        const currentPoint = currentDraft.polygon[dragState.vertexIndex]\n        if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {\n          return currentDraft\n        }\n\n        sfxEmitter.emit('sfx:grid-snap')\n\n        const nextPolygon = [...currentDraft.polygon]\n        nextPolygon[dragState.vertexIndex] = snappedPoint\n\n        return {\n          ...currentDraft,\n          polygon: nextPolygon,\n        }\n      })\n    }\n\n    const commitSiteVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      const draft = siteBoundaryDraftRef.current\n      if (\n        draft &&\n        site &&\n        draft.siteId === site.id &&\n        !polygonsEqual(draft.polygon, site.polygon?.points ?? [])\n      ) {\n        const suppressClick = (clickEvent: MouseEvent) => {\n          clickEvent.stopImmediatePropagation()\n          clickEvent.preventDefault()\n          window.removeEventListener('click', suppressClick, true)\n        }\n        window.addEventListener('click', suppressClick, true)\n        requestAnimationFrame(() => {\n          window.removeEventListener('click', suppressClick, true)\n        })\n\n        updateNode(draft.siteId, {\n          polygon: {\n            type: 'polygon',\n            points: draft.polygon,\n          },\n        })\n        sfxEmitter.emit('sfx:structure-build')\n      }\n\n      clearSiteBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    const cancelSiteVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      clearSiteBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    window.addEventListener('pointermove', handleWindowPointerMove)\n    window.addEventListener('pointerup', commitSiteVertexDrag)\n    window.addEventListener('pointercancel', cancelSiteVertexDrag)\n\n    return () => {\n      window.removeEventListener('pointermove', handleWindowPointerMove)\n      window.removeEventListener('pointerup', commitSiteVertexDrag)\n      window.removeEventListener('pointercancel', cancelSiteVertexDrag)\n    }\n  }, [\n    clearSiteBoundaryInteraction,\n    getPlanPointFromClientPoint,\n    site,\n    siteVertexDragState,\n    updateNode,\n  ])\n\n  useEffect(() => {\n    const dragState = slabVertexDragState\n    if (!dragState) {\n      return\n    }\n\n    const handleWindowPointerMove = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]\n      setCursorPoint(snappedPoint)\n\n      setSlabBoundaryDraft((currentDraft) => {\n        if (!currentDraft || currentDraft.slabId !== dragState.slabId) {\n          return currentDraft\n        }\n\n        const currentPoint = currentDraft.polygon[dragState.vertexIndex]\n        if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {\n          return currentDraft\n        }\n\n        sfxEmitter.emit('sfx:grid-snap')\n\n        const nextPolygon = [...currentDraft.polygon]\n        nextPolygon[dragState.vertexIndex] = snappedPoint\n\n        return {\n          ...currentDraft,\n          polygon: nextPolygon,\n        }\n      })\n    }\n\n    const commitSlabVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      const draft = slabBoundaryDraftRef.current\n      const slab = slabById.get(dragState.slabId)\n      if (draft && slab && !polygonsEqual(draft.polygon, slab.polygon)) {\n        const suppressClick = (clickEvent: MouseEvent) => {\n          clickEvent.stopImmediatePropagation()\n          clickEvent.preventDefault()\n          window.removeEventListener('click', suppressClick, true)\n        }\n        window.addEventListener('click', suppressClick, true)\n        requestAnimationFrame(() => {\n          window.removeEventListener('click', suppressClick, true)\n        })\n\n        updateNode(draft.slabId, {\n          polygon: draft.polygon,\n        })\n        sfxEmitter.emit('sfx:structure-build')\n      }\n\n      clearSlabBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    const cancelSlabVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      clearSlabBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    window.addEventListener('pointermove', handleWindowPointerMove)\n    window.addEventListener('pointerup', commitSlabVertexDrag)\n    window.addEventListener('pointercancel', cancelSlabVertexDrag)\n\n    return () => {\n      window.removeEventListener('pointermove', handleWindowPointerMove)\n      window.removeEventListener('pointerup', commitSlabVertexDrag)\n      window.removeEventListener('pointercancel', cancelSlabVertexDrag)\n    }\n  }, [\n    clearSlabBoundaryInteraction,\n    getPlanPointFromClientPoint,\n    slabById,\n    slabVertexDragState,\n    updateNode,\n  ])\n\n  useEffect(() => {\n    const dragState = zoneVertexDragState\n    if (!dragState) {\n      return\n    }\n\n    const handleWindowPointerMove = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      event.preventDefault()\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]\n      setCursorPoint(snappedPoint)\n\n      setZoneBoundaryDraft((currentDraft) => {\n        if (!currentDraft || currentDraft.zoneId !== dragState.zoneId) {\n          return currentDraft\n        }\n\n        const currentPoint = currentDraft.polygon[dragState.vertexIndex]\n        if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {\n          return currentDraft\n        }\n\n        sfxEmitter.emit('sfx:grid-snap')\n\n        const nextPolygon = [...currentDraft.polygon]\n        nextPolygon[dragState.vertexIndex] = snappedPoint\n\n        return {\n          ...currentDraft,\n          polygon: nextPolygon,\n        }\n      })\n    }\n\n    const commitZoneVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      const draft = zoneBoundaryDraftRef.current\n      const zone = zoneById.get(dragState.zoneId)\n      if (draft && zone && !polygonsEqual(draft.polygon, zone.polygon)) {\n        const suppressClick = (clickEvent: MouseEvent) => {\n          clickEvent.stopImmediatePropagation()\n          clickEvent.preventDefault()\n          window.removeEventListener('click', suppressClick, true)\n        }\n        window.addEventListener('click', suppressClick, true)\n        requestAnimationFrame(() => {\n          window.removeEventListener('click', suppressClick, true)\n        })\n\n        updateNode(draft.zoneId, {\n          polygon: draft.polygon,\n        })\n        sfxEmitter.emit('sfx:structure-build')\n      }\n\n      clearZoneBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    const cancelZoneVertexDrag = (event: PointerEvent) => {\n      if (event.pointerId !== dragState.pointerId) {\n        return\n      }\n\n      clearZoneBoundaryInteraction()\n      setCursorPoint(null)\n    }\n\n    window.addEventListener('pointermove', handleWindowPointerMove)\n    window.addEventListener('pointerup', commitZoneVertexDrag)\n    window.addEventListener('pointercancel', cancelZoneVertexDrag)\n\n    return () => {\n      window.removeEventListener('pointermove', handleWindowPointerMove)\n      window.removeEventListener('pointerup', commitZoneVertexDrag)\n      window.removeEventListener('pointercancel', cancelZoneVertexDrag)\n    }\n  }, [\n    clearZoneBoundaryInteraction,\n    getPlanPointFromClientPoint,\n    updateNode,\n    zoneById,\n    zoneVertexDragState,\n  ])\n\n  useEffect(() => {\n    return () => {\n      setFloorplanHovered(false)\n    }\n  }, [setFloorplanHovered])\n\n  const handlePointerDown = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {\n    if (event.button !== 2) {\n      return\n    }\n\n    event.preventDefault()\n    event.stopPropagation()\n\n    panStateRef.current = {\n      pointerId: event.pointerId,\n      clientX: event.clientX,\n      clientY: event.clientY,\n    }\n    setIsPanning(true)\n\n    event.currentTarget.setPointerCapture(event.pointerId)\n  }, [])\n\n  const endPanning = useCallback((event?: ReactPointerEvent<SVGSVGElement>) => {\n    if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n\n    panStateRef.current = null\n    setIsPanning(false)\n  }, [])\n\n  const hoveredWallIdRef = useRef<string | null>(null)\n  const emitFloorplanWallLeave = useCallback((wallId: string | null) => {\n    if (!wallId) {\n      return\n    }\n\n    const wallNode = useScene.getState().nodes[wallId as AnyNodeId]\n    if (!wallNode || wallNode.type !== 'wall') {\n      return\n    }\n\n    emitter.emit('wall:leave', {\n      node: wallNode,\n      position: [0, 0, 0],\n      localPosition: [0, 0, 0],\n      stopPropagation: () => {},\n    } as any)\n  }, [])\n\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>) => {\n      if (panStateRef.current?.pointerId === event.pointerId) {\n        const deltaX = event.clientX - panStateRef.current.clientX\n        const deltaY = event.clientY - panStateRef.current.clientY\n        const worldPerPixelX = viewBox.width / surfaceSize.width\n        const worldPerPixelY = viewBox.height / surfaceSize.height\n\n        updateViewport({\n          centerX: (viewport ?? fittedViewport).centerX - deltaX * worldPerPixelX,\n          centerY: (viewport ?? fittedViewport).centerY - deltaY * worldPerPixelY,\n          width: (viewport ?? fittedViewport).width,\n        })\n\n        panStateRef.current = {\n          pointerId: event.pointerId,\n          clientX: event.clientX,\n          clientY: event.clientY,\n        }\n        setCursorPoint(null)\n        return\n      }\n\n      if (guideInteractionRef.current?.pointerId === event.pointerId) {\n        return\n      }\n\n      if (wallEndpointDragRef.current?.pointerId === event.pointerId) {\n        return\n      }\n\n      if (slabVertexDragState?.pointerId === event.pointerId) {\n        return\n      }\n\n      if (siteVertexDragState?.pointerId === event.pointerId) {\n        return\n      }\n\n      if (zoneVertexDragState?.pointerId === event.pointerId) {\n        return\n      }\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      if (isPolygonBuildActive) {\n        const snappedPoint = snapPolygonDraftPoint({\n          point: planPoint,\n          start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],\n          angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,\n        })\n\n        setCursorPoint((previousPoint) => {\n          const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))\n          if (hasChanged && activePolygonDraftPoints.length > 0) {\n            sfxEmitter.emit('sfx:grid-snap')\n          }\n          return snappedPoint\n        })\n        return\n      }\n\n      if (isOpeningPlacementActive) {\n        const closest = findClosestWallPoint(planPoint, walls)\n        if (closest) {\n          const dx = closest.wall.end[0] - closest.wall.start[0]\n          const dz = closest.wall.end[1] - closest.wall.start[1]\n          const length = Math.sqrt(dx * dx + dz * dz)\n          const distance = closest.t * length\n\n          const wallEvent = {\n            node: closest.wall,\n            point: { x: closest.point[0], y: 0, z: closest.point[1] },\n            localPosition: [distance, floorplanOpeningLocalY, 0] as [number, number, number],\n            normal: closest.normal,\n            stopPropagation: () => {},\n          }\n\n          if (hoveredWallIdRef.current !== closest.wall.id) {\n            if (hoveredWallIdRef.current) {\n              emitFloorplanWallLeave(hoveredWallIdRef.current)\n            }\n            hoveredWallIdRef.current = closest.wall.id\n            emitter.emit('wall:enter', wallEvent as any)\n          } else {\n            emitter.emit('wall:move', wallEvent as any)\n          }\n        } else if (hoveredWallIdRef.current) {\n          emitFloorplanWallLeave(hoveredWallIdRef.current)\n          hoveredWallIdRef.current = null\n        }\n        return\n      }\n\n      if (!isWallBuildActive) {\n        setCursorPoint(null)\n        return\n      }\n\n      const snappedPoint = snapWallDraftPoint({\n        point: planPoint,\n        walls,\n        start: draftStart ?? undefined,\n        angleSnap: Boolean(draftStart) && !shiftPressed,\n      })\n\n      setCursorPoint(snappedPoint)\n\n      if (!draftStart) {\n        return\n      }\n\n      setDraftEnd((previousEnd) => {\n        if (\n          !previousEnd ||\n          previousEnd[0] !== snappedPoint[0] ||\n          previousEnd[1] !== snappedPoint[1]\n        ) {\n          sfxEmitter.emit('sfx:grid-snap')\n        }\n\n        return snappedPoint\n      })\n    },\n    [\n      draftStart,\n      emitFloorplanWallLeave,\n      floorplanOpeningLocalY,\n      fittedViewport,\n      getPlanPointFromClientPoint,\n      activePolygonDraftPoints,\n      isOpeningPlacementActive,\n      isPolygonBuildActive,\n      isWallBuildActive,\n      siteVertexDragState,\n      slabVertexDragState,\n      shiftPressed,\n      surfaceSize.height,\n      surfaceSize.width,\n      updateViewport,\n      viewBox.height,\n      viewBox.width,\n      viewport,\n      walls,\n      zoneVertexDragState,\n    ],\n  )\n\n  const handleSlabPlacementPoint = useCallback(\n    (point: WallPlanPoint) => {\n      const lastPoint = slabDraftPoints[slabDraftPoints.length - 1]\n      if (lastPoint && pointsEqual(lastPoint, point)) {\n        return\n      }\n\n      const firstPoint = slabDraftPoints[0]\n      if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {\n        createSlabOnCurrentLevel(slabDraftPoints)\n        clearDraft()\n        return\n      }\n\n      setSlabDraftPoints((currentPoints) => [...currentPoints, point])\n      setCursorPoint(point)\n    },\n    [clearDraft, createSlabOnCurrentLevel, slabDraftPoints],\n  )\n  const handleSlabPlacementConfirm = useCallback(\n    (point?: WallPlanPoint) => {\n      const firstPoint = slabDraftPoints[0]\n      const lastPoint = slabDraftPoints[slabDraftPoints.length - 1]\n\n      let nextPoints = slabDraftPoints\n      if (point) {\n        const isClosingExistingPolygon = Boolean(\n          firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint),\n        )\n        const isDuplicatePoint = Boolean(lastPoint && pointsEqual(lastPoint, point))\n\n        if (!(isClosingExistingPolygon || isDuplicatePoint)) {\n          nextPoints = [...slabDraftPoints, point]\n        }\n      }\n\n      if (nextPoints.length < 3) {\n        return\n      }\n\n      createSlabOnCurrentLevel(nextPoints)\n      clearDraft()\n    },\n    [clearDraft, createSlabOnCurrentLevel, slabDraftPoints],\n  )\n  const handleZonePlacementPoint = useCallback(\n    (point: WallPlanPoint) => {\n      const lastPoint = zoneDraftPoints[zoneDraftPoints.length - 1]\n      if (lastPoint && pointsEqual(lastPoint, point)) {\n        return\n      }\n\n      const firstPoint = zoneDraftPoints[0]\n      if (firstPoint && zoneDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {\n        createZoneOnCurrentLevel(zoneDraftPoints)\n        clearDraft()\n        return\n      }\n\n      setZoneDraftPoints((currentPoints) => [...currentPoints, point])\n      setCursorPoint(point)\n    },\n    [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],\n  )\n  const handleZonePlacementConfirm = useCallback(\n    (point?: WallPlanPoint) => {\n      const firstPoint = zoneDraftPoints[0]\n      const lastPoint = zoneDraftPoints[zoneDraftPoints.length - 1]\n\n      let nextPoints = zoneDraftPoints\n      if (point) {\n        const isClosingExistingPolygon = Boolean(\n          firstPoint && zoneDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint),\n        )\n        const isDuplicatePoint = Boolean(lastPoint && pointsEqual(lastPoint, point))\n\n        if (!(isClosingExistingPolygon || isDuplicatePoint)) {\n          nextPoints = [...zoneDraftPoints, point]\n        }\n      }\n\n      if (nextPoints.length < 3) {\n        return\n      }\n\n      createZoneOnCurrentLevel(nextPoints)\n      clearDraft()\n    },\n    [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],\n  )\n\n  const handleWallPlacementPoint = useCallback(\n    (point: WallPlanPoint) => {\n      if (!draftStart) {\n        setDraftStart(point)\n        setDraftEnd(point)\n        setCursorPoint(point)\n        return\n      }\n\n      if (!isWallLongEnough(draftStart, point)) {\n        return\n      }\n\n      createWallOnCurrentLevel(draftStart, point)\n      clearDraft()\n    },\n    [clearDraft, draftStart],\n  )\n\n  const handleBackgroundClick = useCallback(\n    (event: ReactMouseEvent<SVGSVGElement>) => {\n      if (isPolygonBuildActive && event.detail >= 2) {\n        return\n      }\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      if (isOpeningPlacementActive) {\n        const closest = findClosestWallPoint(planPoint, walls)\n        if (closest) {\n          const dx = closest.wall.end[0] - closest.wall.start[0]\n          const dz = closest.wall.end[1] - closest.wall.start[1]\n          const length = Math.sqrt(dx * dx + dz * dz)\n          const distance = closest.t * length\n\n          emitter.emit('wall:click', {\n            node: closest.wall,\n            point: { x: closest.point[0], y: 0, z: closest.point[1] },\n            localPosition: [distance, floorplanOpeningLocalY, 0],\n            normal: closest.normal,\n            stopPropagation: () => {},\n          } as any)\n        }\n        return\n      }\n\n      if (isPolygonBuildActive) {\n        const snappedPoint = snapPolygonDraftPoint({\n          point: planPoint,\n          start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],\n          angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,\n        })\n\n        if (isZoneBuildActive) {\n          handleZonePlacementPoint(snappedPoint)\n        } else {\n          handleSlabPlacementPoint(snappedPoint)\n        }\n        return\n      }\n\n      if (canSelectFloorplanZones) {\n        const zoneHit = visibleZonePolygons.find(({ polygon }) =>\n          isPointInsidePolygon(toPoint2D(planPoint), polygon),\n        )\n        if (zoneHit) {\n          setSelectedReferenceId(null)\n          setSelection({ zoneId: zoneHit.zone.id })\n          return\n        }\n      }\n\n      if (!isWallBuildActive) {\n        if (structureLayer === 'zones') {\n          setSelectedReferenceId(null)\n          setSelection({ zoneId: null })\n        } else {\n          setSelectedReferenceId(null)\n          setSelection({ selectedIds: [] })\n        }\n        return\n      }\n\n      const snappedPoint = snapWallDraftPoint({\n        point: planPoint,\n        walls,\n        start: draftStart ?? undefined,\n        angleSnap: Boolean(draftStart) && !shiftPressed,\n      })\n\n      handleWallPlacementPoint(snappedPoint)\n    },\n    [\n      draftStart,\n      floorplanOpeningLocalY,\n      getPlanPointFromClientPoint,\n      activePolygonDraftPoints,\n      canSelectFloorplanZones,\n      handleSlabPlacementPoint,\n      handleZonePlacementPoint,\n      handleWallPlacementPoint,\n      isOpeningPlacementActive,\n      isPolygonBuildActive,\n      isWallBuildActive,\n      isZoneBuildActive,\n      setSelectedReferenceId,\n      setSelection,\n      shiftPressed,\n      structureLayer,\n      visibleZonePolygons,\n      walls,\n    ],\n  )\n  const handleBackgroundDoubleClick = useCallback(\n    (event: ReactMouseEvent<SVGSVGElement>) => {\n      if (!isPolygonBuildActive) {\n        return\n      }\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      const snappedPoint = snapPolygonDraftPoint({\n        point: planPoint,\n        start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],\n        angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,\n      })\n\n      if (isZoneBuildActive) {\n        handleZonePlacementConfirm(snappedPoint)\n      } else {\n        handleSlabPlacementConfirm(snappedPoint)\n      }\n    },\n    [\n      activePolygonDraftPoints,\n      getPlanPointFromClientPoint,\n      handleSlabPlacementConfirm,\n      handleZonePlacementConfirm,\n      isPolygonBuildActive,\n      isZoneBuildActive,\n      shiftPressed,\n    ],\n  )\n\n  const commitFloorplanSelection = useCallback(\n    (nextSelectedIds: string[]) => {\n      if (!(levelId && levelNode) || levelNode.type !== 'level') {\n        setSelectedReferenceId(null)\n        setSelection({ selectedIds: nextSelectedIds })\n        return\n      }\n\n      const { selection } = useViewer.getState()\n      const nodes = useScene.getState().nodes\n      const updates: Parameters<typeof setSelection>[0] = {\n        selectedIds: nextSelectedIds,\n      }\n\n      if (levelId !== selection.levelId) {\n        updates.levelId = levelId\n      }\n\n      const parentNode = levelNode.parentId ? nodes[levelNode.parentId as AnyNodeId] : null\n      if (parentNode?.type === 'building' && parentNode.id !== selection.buildingId) {\n        updates.buildingId = parentNode.id\n      }\n\n      setSelectedReferenceId(null)\n      setSelection(updates)\n    },\n    [levelId, levelNode, setSelectedReferenceId, setSelection],\n  )\n\n  const addFloorplanSelection = useCallback(\n    (nextSelectedIds: string[], modifierKeys?: { meta: boolean; ctrl: boolean }) => {\n      const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)\n\n      if (shouldAppend) {\n        if (nextSelectedIds.length === 0) {\n          return\n        }\n\n        const currentSelectedIds = useViewer.getState().selection.selectedIds\n        commitFloorplanSelection(Array.from(new Set([...currentSelectedIds, ...nextSelectedIds])))\n        return\n      }\n\n      commitFloorplanSelection(nextSelectedIds)\n    },\n    [commitFloorplanSelection],\n  )\n\n  const toggleFloorplanSelection = useCallback(\n    (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => {\n      const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)\n\n      if (shouldToggle) {\n        const currentSelectedIds = useViewer.getState().selection.selectedIds\n        commitFloorplanSelection(\n          currentSelectedIds.includes(nodeId)\n            ? currentSelectedIds.filter((selectedId) => selectedId !== nodeId)\n            : [...currentSelectedIds, nodeId],\n        )\n        return\n      }\n\n      commitFloorplanSelection([nodeId])\n    },\n    [commitFloorplanSelection],\n  )\n\n  const getFloorplanHitIdAtPoint = useCallback(\n    (planPoint: WallPlanPoint) => {\n      const point = toPoint2D(planPoint)\n\n      const openingHit = openingsPolygons.find(({ polygon }) => {\n        if (isPointInsidePolygon(point, polygon)) {\n          return true\n        }\n\n        const centerLine = getOpeningCenterLine(polygon)\n        if (!centerLine) {\n          return false\n        }\n\n        return (\n          getDistanceToWallSegment(\n            point,\n            [centerLine.start.x, centerLine.start.y],\n            [centerLine.end.x, centerLine.end.y],\n          ) <= floorplanOpeningHitTolerance\n        )\n      })\n      if (openingHit) {\n        return openingHit.opening.id\n      }\n\n      const wallHit = displayWallPolygons.find(\n        ({ wall, polygon }) =>\n          isPointInsidePolygon(point, polygon) ||\n          getDistanceToWallSegment(point, wall.start, wall.end) <= floorplanWallHitTolerance,\n      )\n      if (wallHit) {\n        return wallHit.wall.id\n      }\n\n      const slabHit = displaySlabPolygons.find(({ polygon, holes }) =>\n        isPointInsidePolygonWithHoles(point, polygon, holes),\n      )\n      if (slabHit) {\n        return slabHit.slab.id\n      }\n\n      return null\n    },\n    [\n      displaySlabPolygons,\n      displayWallPolygons,\n      floorplanOpeningHitTolerance,\n      floorplanWallHitTolerance,\n      openingsPolygons,\n    ],\n  )\n\n  const getFloorplanSelectionIdsInBounds = useCallback(\n    (bounds: FloorplanSelectionBounds) => {\n      const wallIds = displayWallPolygons\n        .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))\n        .map(({ wall }) => wall.id)\n      const openingIds = openingsPolygons\n        .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))\n        .map(({ opening }) => opening.id)\n      const slabIds = displaySlabPolygons\n        .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))\n        .map(({ slab }) => slab.id)\n\n      return Array.from(new Set([...wallIds, ...openingIds, ...slabIds]))\n    },\n    [displaySlabPolygons, displayWallPolygons, openingsPolygons],\n  )\n\n  const handleWallSelect = useCallback(\n    (wall: WallNode) => {\n      commitFloorplanSelection([wall.id])\n    },\n    [commitFloorplanSelection],\n  )\n\n  const handleWallClick = useCallback(\n    (wall: WallNode, event: ReactMouseEvent<SVGElement>) => {\n      const centerX = (wall.start[0] + wall.end[0]) / 2\n      const centerZ = (wall.start[1] + wall.end[1]) / 2\n      const halfLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) / 2\n      const localY = isOpeningPlacementActive ? floorplanOpeningLocalY : 0\n\n      setSelectedReferenceId(null)\n      emitter.emit('wall:click', {\n        node: wall,\n        position: [centerX, 0, centerZ],\n        localPosition: [halfLength, localY, 0],\n        stopPropagation: () => event.stopPropagation(),\n        nativeEvent: event.nativeEvent as any,\n      } as any)\n    },\n    [floorplanOpeningLocalY, isOpeningPlacementActive, setSelectedReferenceId],\n  )\n\n  const handleWallDoubleClick = useCallback(\n    (wall: WallNode, event: ReactMouseEvent<SVGElement>) => {\n      const centerX = (wall.start[0] + wall.end[0]) / 2\n      const centerZ = (wall.start[1] + wall.end[1]) / 2\n      const halfLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) / 2\n\n      emitter.emit('wall:double-click', {\n        node: wall,\n        position: [centerX, 0, centerZ],\n        localPosition: [halfLength, 0, 0],\n        stopPropagation: () => event.stopPropagation(),\n        nativeEvent: event.nativeEvent as any,\n      } as any)\n      emitter.emit('camera-controls:focus', { nodeId: wall.id })\n    },\n    [],\n  )\n  const emitFloorplanNodeClick = useCallback(\n    (\n      nodeId: SlabNode['id'] | OpeningNode['id'] | ZoneNodeType['id'],\n      event: ReactMouseEvent<SVGElement>,\n    ) => {\n      const node = useScene.getState().nodes[nodeId as AnyNodeId]\n      if (\n        !(\n          node &&\n          (node.type === 'slab' ||\n            node.type === 'door' ||\n            node.type === 'window' ||\n            node.type === 'zone')\n        )\n      ) {\n        return\n      }\n\n      setSelectedReferenceId(null)\n      emitter.emit(\n        `${node.type}:click` as any,\n        {\n          localPosition: [0, 0, 0],\n          nativeEvent: event.nativeEvent as any,\n          node,\n          position: [0, 0, 0],\n          stopPropagation: () => event.stopPropagation(),\n        } as any,\n      )\n    },\n    [setSelectedReferenceId],\n  )\n  const handleGuideSelect = useCallback(\n    (guideId: GuideNode['id']) => {\n      setSelectedReferenceId(guideId)\n      setSelection({ selectedIds: [], zoneId: null })\n    },\n    [setSelectedReferenceId, setSelection],\n  )\n  const handleGuideCornerPointerDown = useCallback(\n    (\n      guide: GuideNode,\n      dimensions: GuideImageDimensions,\n      corner: GuideCorner,\n      event: ReactPointerEvent<SVGCircleElement>,\n    ) => {\n      if (event.button !== 0 || !canInteractWithGuides) {\n        return\n      }\n\n      const aspectRatio = dimensions.width / dimensions.height\n      if (!(aspectRatio > 0)) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      setHoveredGuideCorner(null)\n      handleGuideSelect(guide.id)\n\n      const centerSvg = getGuideCenterSvgPoint(guide)\n      const rotationSvg = -guide.rotation[1]\n      const width = getGuideWidth(guide.scale)\n      const height = getGuideHeight(width, aspectRatio)\n      const [cornerOffsetX, cornerOffsetY] = getGuideCornerLocalOffset(width, height, corner)\n      const shouldRotate = event.ctrlKey || event.metaKey\n\n      guideInteractionRef.current = {\n        pointerId: event.pointerId,\n        guideId: guide.id,\n        corner,\n        mode: shouldRotate ? 'rotate' : 'resize',\n        aspectRatio,\n        centerSvg,\n        oppositeCornerSvg: shouldRotate\n          ? null\n          : getGuideCornerSvgPoint(\n              centerSvg,\n              width,\n              height,\n              rotationSvg,\n              oppositeGuideCorner[corner],\n            ),\n        pointerOffsetSvg: [0, 0],\n        rotationSvg,\n        cornerBaseAngle: Math.atan2(cornerOffsetY, cornerOffsetX),\n        scale: guide.scale,\n      }\n\n      document.body.style.userSelect = 'none'\n      document.body.style.cursor = shouldRotate\n        ? getGuideRotateCursor(theme === 'dark')\n        : getGuideResizeCursor(corner, rotationSvg)\n\n      const nextDraft: GuideTransformDraft = {\n        guideId: guide.id,\n        position: [guide.position[0], guide.position[2]],\n        scale: guide.scale,\n        rotation: guide.rotation[1],\n      }\n\n      guideTransformDraftRef.current = nextDraft\n      setGuideTransformDraft(nextDraft)\n    },\n    [canInteractWithGuides, handleGuideSelect, theme],\n  )\n  const handleGuideTranslateStart = useCallback(\n    (guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {\n      if (event.button !== 0 || !canInteractWithGuides || selectedGuideId !== guide.id) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)\n      if (!svgPoint) {\n        return\n      }\n\n      const centerSvg = getGuideCenterSvgPoint(guide)\n\n      guideInteractionRef.current = {\n        pointerId: event.pointerId,\n        guideId: guide.id,\n        corner: 'nw',\n        mode: 'translate',\n        aspectRatio: 1,\n        centerSvg,\n        oppositeCornerSvg: null,\n        pointerOffsetSvg: subtractSvgPoints(svgPoint, centerSvg),\n        rotationSvg: -guide.rotation[1],\n        cornerBaseAngle: 0,\n        scale: guide.scale,\n      }\n\n      document.body.style.userSelect = 'none'\n      document.body.style.cursor = 'grabbing'\n\n      const nextDraft: GuideTransformDraft = {\n        guideId: guide.id,\n        position: [guide.position[0], guide.position[2]],\n        scale: guide.scale,\n        rotation: guide.rotation[1],\n      }\n\n      guideTransformDraftRef.current = nextDraft\n      setGuideTransformDraft(nextDraft)\n    },\n    [canInteractWithGuides, getSvgPointFromClientPoint, selectedGuideId],\n  )\n\n  const handleOpeningSelect = useCallback(\n    (openingId: OpeningNode['id'], event: ReactMouseEvent<SVGElement>) => {\n      emitFloorplanNodeClick(openingId, event)\n    },\n    [emitFloorplanNodeClick],\n  )\n  const handleOpeningPointerDown = useCallback(\n    (openingId: OpeningNode['id'], event: ReactPointerEvent<SVGElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      const opening = selectedOpeningEntry?.opening\n      if (!opening || opening.id !== openingId) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      // Suppress the click event that follows this pointer interaction so it\n      // doesn't re-select or interfere with placement.\n      const suppressClick = (clickEvent: MouseEvent) => {\n        clickEvent.stopImmediatePropagation()\n        clickEvent.preventDefault()\n        window.removeEventListener('click', suppressClick, true)\n      }\n      window.addEventListener('click', suppressClick, true)\n      requestAnimationFrame(() => {\n        window.removeEventListener('click', suppressClick, true)\n      })\n\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(opening)\n      setSelection({ selectedIds: [] })\n    },\n    [selectedOpeningEntry, setMovingNode, setSelection],\n  )\n  const handleSlabSelect = useCallback(\n    (slabId: SlabNode['id'], event: ReactMouseEvent<SVGElement>) => {\n      emitFloorplanNodeClick(slabId, event)\n    },\n    [emitFloorplanNodeClick],\n  )\n  const handleZoneSelect = useCallback(\n    (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => {\n      emitFloorplanNodeClick(zoneId, event)\n    },\n    [emitFloorplanNodeClick],\n  )\n  const handleSlabDoubleClick = useCallback((slab: SlabNode) => {\n    emitter.emit('camera-controls:focus', { nodeId: slab.id })\n  }, [])\n  const handleOpeningDoubleClick = useCallback((opening: OpeningNode) => {\n    emitter.emit('camera-controls:focus', { nodeId: opening.id })\n  }, [])\n  const handleSelectedOpeningMove = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      event.stopPropagation()\n\n      const opening = selectedOpeningEntry?.opening\n      if (!opening) {\n        return\n      }\n\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(opening)\n      setSelection({ selectedIds: [] })\n    },\n    [selectedOpeningEntry, setMovingNode, setSelection],\n  )\n  const duplicateSelectedOpening = useCallback(() => {\n    const opening = selectedOpeningEntry?.opening\n    if (!opening?.parentId) {\n      return\n    }\n\n    sfxEmitter.emit('sfx:item-pick')\n    useScene.temporal.getState().pause()\n\n    const cloned = structuredClone(opening) as Record<string, unknown>\n    delete cloned.id\n    cloned.metadata = {\n      ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),\n      isNew: true,\n    }\n\n    const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned)\n\n    useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId)\n    setMovingNode(duplicate)\n    setSelection({ selectedIds: [] })\n  }, [selectedOpeningEntry, setMovingNode, setSelection])\n  const handleSelectedOpeningDuplicate = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      event.stopPropagation()\n      duplicateSelectedOpening()\n    },\n    [duplicateSelectedOpening],\n  )\n  const handleSelectedOpeningDelete = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      event.stopPropagation()\n\n      const opening = selectedOpeningEntry?.opening\n      if (!opening) {\n        return\n      }\n\n      sfxEmitter.emit('sfx:item-delete')\n      deleteNode(opening.id as AnyNodeId)\n      if (opening.parentId) {\n        useScene.getState().dirtyNodes.add(opening.parentId as AnyNodeId)\n      }\n      setSelection({ selectedIds: [] })\n    },\n    [deleteNode, selectedOpeningEntry, setSelection],\n  )\n\n  const handleWallEndpointPointerDown = useCallback(\n    (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredEndpointId(null)\n\n      const movingPoint = endpoint === 'start' ? wall.start : wall.end\n\n      if (isWallBuildActive) {\n        handleWallPlacementPoint(movingPoint)\n        return\n      }\n\n      if (mode !== 'select') {\n        return\n      }\n\n      clearWallPlacementDraft()\n      handleWallSelect(wall)\n\n      const fixedPoint = endpoint === 'start' ? wall.end : wall.start\n\n      wallEndpointDragRef.current = {\n        pointerId: event.pointerId,\n        wallId: wall.id,\n        endpoint,\n        fixedPoint,\n        currentPoint: movingPoint,\n      }\n\n      setWallEndpointDraft(buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint))\n      setCursorPoint(movingPoint)\n    },\n    [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],\n  )\n  const handleSlabVertexPointerDown = useCallback(\n    (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredSlabHandleId(null)\n\n      const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId)\n      const vertexPoint = slabEntry?.polygon[vertexIndex]\n      if (!(slabEntry && vertexPoint)) {\n        return\n      }\n\n      setSlabBoundaryDraft({\n        slabId,\n        polygon: slabEntry.polygon.map(toWallPlanPoint),\n      })\n      setSlabVertexDragState({\n        pointerId: event.pointerId,\n        slabId,\n        vertexIndex,\n      })\n      setCursorPoint(toWallPlanPoint(vertexPoint))\n    },\n    [displaySlabPolygons],\n  )\n  const handleSlabVertexDoubleClick = useCallback(\n    (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      const slab = slabById.get(slabId)\n      if (!(slab && slab.polygon.length > 3)) {\n        return\n      }\n\n      slabBoundaryDraftRef.current = null\n      clearSlabBoundaryInteraction()\n\n      updateNode(slabId, {\n        polygon: slab.polygon.filter((_, index) => index !== vertexIndex),\n      })\n    },\n    [clearSlabBoundaryInteraction, slabById, updateNode],\n  )\n  const handleSlabMidpointPointerDown = useCallback(\n    (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredSlabHandleId(null)\n\n      const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId)\n      if (!slabEntry) {\n        return\n      }\n\n      const basePolygon = slabEntry.polygon.map(toWallPlanPoint)\n      const startPoint = basePolygon[edgeIndex]\n      const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length]\n      if (!(startPoint && endPoint)) {\n        return\n      }\n\n      const insertedPoint: WallPlanPoint = [\n        (startPoint[0] + endPoint[0]) / 2,\n        (startPoint[1] + endPoint[1]) / 2,\n      ]\n      const insertIndex = edgeIndex + 1\n      const nextPolygon = [\n        ...basePolygon.slice(0, insertIndex),\n        insertedPoint,\n        ...basePolygon.slice(insertIndex),\n      ]\n\n      setSlabBoundaryDraft({\n        slabId,\n        polygon: nextPolygon,\n      })\n      setSlabVertexDragState({\n        pointerId: event.pointerId,\n        slabId,\n        vertexIndex: insertIndex,\n      })\n      setCursorPoint(insertedPoint)\n    },\n    [displaySlabPolygons],\n  )\n  const handleSiteVertexPointerDown = useCallback(\n    (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredSiteHandleId(null)\n\n      if (!(displaySitePolygon && displaySitePolygon.site.id === siteId)) {\n        return\n      }\n\n      const vertexPoint = displaySitePolygon.polygon[vertexIndex]\n      if (!vertexPoint) {\n        return\n      }\n\n      setSiteBoundaryDraft({\n        siteId,\n        polygon: displaySitePolygon.polygon.map(toWallPlanPoint),\n      })\n      setSiteVertexDragState({\n        pointerId: event.pointerId,\n        siteId,\n        vertexIndex,\n      })\n      setCursorPoint(toWallPlanPoint(vertexPoint))\n    },\n    [displaySitePolygon],\n  )\n  const handleSiteVertexDoubleClick = useCallback(\n    (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      if (!(site && site.id === siteId && (site.polygon?.points?.length ?? 0) > 3)) {\n        return\n      }\n\n      siteBoundaryDraftRef.current = null\n      clearSiteBoundaryInteraction()\n\n      updateNode(siteId, {\n        polygon: {\n          type: 'polygon',\n          points: site.polygon.points.filter((_, index) => index !== vertexIndex),\n        },\n      })\n    },\n    [clearSiteBoundaryInteraction, site, updateNode],\n  )\n  const handleSiteMidpointPointerDown = useCallback(\n    (siteId: SiteNode['id'], edgeIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredSiteHandleId(null)\n\n      if (!(displaySitePolygon && displaySitePolygon.site.id === siteId)) {\n        return\n      }\n\n      const basePolygon = displaySitePolygon.polygon.map(toWallPlanPoint)\n      const startPoint = basePolygon[edgeIndex]\n      const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length]\n      if (!(startPoint && endPoint)) {\n        return\n      }\n\n      const insertedPoint: WallPlanPoint = [\n        (startPoint[0] + endPoint[0]) / 2,\n        (startPoint[1] + endPoint[1]) / 2,\n      ]\n      const insertIndex = edgeIndex + 1\n      const nextPolygon = [\n        ...basePolygon.slice(0, insertIndex),\n        insertedPoint,\n        ...basePolygon.slice(insertIndex),\n      ]\n\n      setSiteBoundaryDraft({\n        siteId,\n        polygon: nextPolygon,\n      })\n      setSiteVertexDragState({\n        pointerId: event.pointerId,\n        siteId,\n        vertexIndex: insertIndex,\n      })\n      setCursorPoint(insertedPoint)\n    },\n    [displaySitePolygon],\n  )\n  const handleZoneVertexPointerDown = useCallback(\n    (\n      zoneId: ZoneNodeType['id'],\n      vertexIndex: number,\n      event: ReactPointerEvent<SVGCircleElement>,\n    ) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredZoneHandleId(null)\n\n      const zoneEntry = displayZonePolygons.find(({ zone }) => zone.id === zoneId)\n      const vertexPoint = zoneEntry?.polygon[vertexIndex]\n      if (!(zoneEntry && vertexPoint)) {\n        return\n      }\n\n      setZoneBoundaryDraft({\n        zoneId,\n        polygon: zoneEntry.polygon.map(toWallPlanPoint),\n      })\n      setZoneVertexDragState({\n        pointerId: event.pointerId,\n        zoneId,\n        vertexIndex,\n      })\n      setCursorPoint(toWallPlanPoint(vertexPoint))\n    },\n    [displayZonePolygons],\n  )\n  const handleZoneVertexDoubleClick = useCallback(\n    (\n      zoneId: ZoneNodeType['id'],\n      vertexIndex: number,\n      event: ReactPointerEvent<SVGCircleElement>,\n    ) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      const zone = zoneById.get(zoneId)\n      if (!(zone && zone.polygon.length > 3)) {\n        return\n      }\n\n      zoneBoundaryDraftRef.current = null\n      clearZoneBoundaryInteraction()\n\n      updateNode(zoneId, {\n        polygon: zone.polygon.filter((_, index) => index !== vertexIndex),\n      })\n    },\n    [clearZoneBoundaryInteraction, updateNode, zoneById],\n  )\n  const handleZoneMidpointPointerDown = useCallback(\n    (zoneId: ZoneNodeType['id'], edgeIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      setHoveredZoneHandleId(null)\n\n      const zoneEntry = displayZonePolygons.find(({ zone }) => zone.id === zoneId)\n      if (!zoneEntry) {\n        return\n      }\n\n      const basePolygon = zoneEntry.polygon.map(toWallPlanPoint)\n      const startPoint = basePolygon[edgeIndex]\n      const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length]\n      if (!(startPoint && endPoint)) {\n        return\n      }\n\n      const insertedPoint: WallPlanPoint = [\n        (startPoint[0] + endPoint[0]) / 2,\n        (startPoint[1] + endPoint[1]) / 2,\n      ]\n      const insertIndex = edgeIndex + 1\n      const nextPolygon = [\n        ...basePolygon.slice(0, insertIndex),\n        insertedPoint,\n        ...basePolygon.slice(insertIndex),\n      ]\n\n      setZoneBoundaryDraft({\n        zoneId,\n        polygon: nextPolygon,\n      })\n      setZoneVertexDragState({\n        pointerId: event.pointerId,\n        zoneId,\n        vertexIndex: insertIndex,\n      })\n      setCursorPoint(insertedPoint)\n    },\n    [displayZonePolygons],\n  )\n\n  const handlePointerLeave = useCallback(() => {\n    if (\n      !(\n        panStateRef.current ||\n        wallEndpointDragRef.current ||\n        siteVertexDragState ||\n        slabVertexDragState ||\n        zoneVertexDragState\n      )\n    ) {\n      setCursorPoint(null)\n    }\n    setHoveredOpeningId(null)\n    setHoveredWallId(null)\n    setHoveredEndpointId(null)\n    setHoveredSiteHandleId(null)\n    setHoveredSlabHandleId(null)\n    setHoveredZoneHandleId(null)\n    if (hoveredWallIdRef.current) {\n      emitFloorplanWallLeave(hoveredWallIdRef.current)\n      hoveredWallIdRef.current = null\n    }\n  }, [emitFloorplanWallLeave, siteVertexDragState, slabVertexDragState, zoneVertexDragState])\n\n  const handleSvgPointerMove = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>) => {\n      if (\n        activeFloorplanCursorIndicator &&\n        !panStateRef.current &&\n        !guideInteractionRef.current &&\n        !wallEndpointDragRef.current &&\n        !siteVertexDragState &&\n        !slabVertexDragState &&\n        !zoneVertexDragState\n      ) {\n        const rect = event.currentTarget.getBoundingClientRect()\n        setFloorplanCursorPosition({\n          x: event.clientX - rect.left,\n          y: event.clientY - rect.top,\n        })\n      } else {\n        setFloorplanCursorPosition(null)\n      }\n\n      handlePointerMove(event)\n    },\n    [\n      activeFloorplanCursorIndicator,\n      handlePointerMove,\n      siteVertexDragState,\n      slabVertexDragState,\n      zoneVertexDragState,\n    ],\n  )\n\n  const handleSvgPointerLeave = useCallback(() => {\n    setFloorplanCursorPosition(null)\n    setHoveredGuideCorner(null)\n    handlePointerLeave()\n  }, [handlePointerLeave])\n\n  const handleMarqueePointerDown = useCallback(\n    (event: ReactPointerEvent<SVGRectElement>) => {\n      if (event.button !== 0) {\n        return\n      }\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n      const rect = svgRef.current?.getBoundingClientRect()\n      if (rect) {\n        setFloorplanCursorPosition({\n          x: event.clientX - rect.left,\n          y: event.clientY - rect.top,\n        })\n      }\n      setHoveredOpeningId(null)\n      setHoveredWallId(null)\n      setHoveredEndpointId(null)\n      setFloorplanMarqueeState({\n        pointerId: event.pointerId,\n        startClientX: event.clientX,\n        startClientY: event.clientY,\n        startPlanPoint: planPoint,\n        currentPlanPoint: planPoint,\n      })\n\n      event.currentTarget.setPointerCapture(event.pointerId)\n    },\n    [getPlanPointFromClientPoint],\n  )\n\n  const handleMarqueePointerMove = useCallback(\n    (event: ReactPointerEvent<SVGRectElement>) => {\n      const rect = svgRef.current?.getBoundingClientRect()\n      if (rect) {\n        setFloorplanCursorPosition({\n          x: event.clientX - rect.left,\n          y: event.clientY - rect.top,\n        })\n      }\n\n      if (floorplanMarqueeState?.pointerId !== event.pointerId) {\n        return\n      }\n\n      const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)\n      if (!planPoint) {\n        return\n      }\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      setFloorplanMarqueeState((currentState) => {\n        if (!currentState || currentState.pointerId !== event.pointerId) {\n          return currentState\n        }\n\n        return {\n          ...currentState,\n          currentPlanPoint: planPoint,\n        }\n      })\n    },\n    [floorplanMarqueeState?.pointerId, getPlanPointFromClientPoint],\n  )\n\n  const handleMarqueePointerUp = useCallback(\n    (event: ReactPointerEvent<SVGRectElement>) => {\n      const marqueeState = floorplanMarqueeState\n      if (!marqueeState || marqueeState.pointerId !== event.pointerId) {\n        return\n      }\n\n      const endPlanPoint =\n        getPlanPointFromClientPoint(event.clientX, event.clientY) ?? marqueeState.currentPlanPoint\n      const modifierKeys = getSelectionModifierKeys(event)\n      const dragDistance = Math.hypot(\n        event.clientX - marqueeState.startClientX,\n        event.clientY - marqueeState.startClientY,\n      )\n\n      event.preventDefault()\n      event.stopPropagation()\n\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId)\n      }\n\n      if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {\n        const bounds = getFloorplanSelectionBounds(marqueeState.startPlanPoint, endPlanPoint)\n        const nextSelectedIds = getFloorplanSelectionIdsInBounds(bounds)\n        addFloorplanSelection(nextSelectedIds, modifierKeys)\n      } else {\n        const hitId = getFloorplanHitIdAtPoint(endPlanPoint)\n\n        if (hitId) {\n          toggleFloorplanSelection(hitId, modifierKeys)\n        } else if (!(modifierKeys.meta || modifierKeys.ctrl)) {\n          commitFloorplanSelection([])\n        }\n      }\n\n      setFloorplanMarqueeState(null)\n    },\n    [\n      addFloorplanSelection,\n      commitFloorplanSelection,\n      floorplanMarqueeState,\n      getFloorplanHitIdAtPoint,\n      getFloorplanSelectionIdsInBounds,\n      getPlanPointFromClientPoint,\n      toggleFloorplanSelection,\n    ],\n  )\n\n  const handleMarqueePointerCancel = useCallback(\n    (event: ReactPointerEvent<SVGRectElement>) => {\n      if (floorplanMarqueeState?.pointerId !== event.pointerId) {\n        return\n      }\n\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId)\n      }\n\n      setFloorplanMarqueeState(null)\n      setFloorplanCursorPosition(null)\n    },\n    [floorplanMarqueeState?.pointerId],\n  )\n\n  useEffect(() => {\n    if (!isMarqueeSelectionToolActive) {\n      setFloorplanMarqueeState(null)\n      return\n    }\n\n    setFloorplanCursorPosition(null)\n    setHoveredOpeningId(null)\n    setHoveredWallId(null)\n    setHoveredEndpointId(null)\n  }, [isMarqueeSelectionToolActive])\n\n  useEffect(() => {\n    const svg = svgRef.current\n    if (!svg) {\n      return\n    }\n\n    const getFallbackClientPoint = () => {\n      const rect = svg.getBoundingClientRect()\n      return {\n        clientX: rect.left + rect.width / 2,\n        clientY: rect.top + rect.height / 2,\n      }\n    }\n\n    const handleNativeWheel = (event: WheelEvent) => {\n      event.preventDefault()\n      event.stopPropagation()\n\n      const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015))\n      zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor)\n    }\n\n    const handleGestureStart = (event: Event) => {\n      const gestureEvent = event as GestureLikeEvent\n      gestureScaleRef.current = gestureEvent.scale ?? 1\n      event.preventDefault()\n      event.stopPropagation()\n    }\n\n    const handleGestureChange = (event: Event) => {\n      const gestureEvent = event as GestureLikeEvent\n      const nextScale = gestureEvent.scale ?? 1\n      const previousScale = gestureScaleRef.current || 1\n      const widthFactor = previousScale / nextScale\n      const fallbackClientPoint = getFallbackClientPoint()\n\n      zoomViewportAtClientPoint(\n        gestureEvent.clientX ?? fallbackClientPoint.clientX,\n        gestureEvent.clientY ?? fallbackClientPoint.clientY,\n        widthFactor,\n      )\n\n      gestureScaleRef.current = nextScale\n      event.preventDefault()\n      event.stopPropagation()\n    }\n\n    const handleGestureEnd = (event: Event) => {\n      gestureScaleRef.current = 1\n      event.preventDefault()\n      event.stopPropagation()\n    }\n\n    svg.addEventListener('wheel', handleNativeWheel, { passive: false })\n    svg.addEventListener('gesturestart', handleGestureStart, { passive: false })\n    svg.addEventListener('gesturechange', handleGestureChange, { passive: false })\n    svg.addEventListener('gestureend', handleGestureEnd, { passive: false })\n\n    return () => {\n      svg.removeEventListener('wheel', handleNativeWheel)\n      svg.removeEventListener('gesturestart', handleGestureStart)\n      svg.removeEventListener('gesturechange', handleGestureChange)\n      svg.removeEventListener('gestureend', handleGestureEnd)\n    }\n  }, [zoomViewportAtClientPoint])\n\n  const restoreGroundLevelStructureSelection = useCallback(() => {\n    const sceneNodes = useScene.getState().nodes\n    const nextBuildingId =\n      currentBuildingId ??\n      site?.children\n        .map((child) => (typeof child === 'string' ? sceneNodes[child as AnyNodeId] : child))\n        .find((node): node is BuildingNode => node?.type === 'building')?.id ??\n      null\n\n    const nextGroundLevelId =\n      nextBuildingId && nextBuildingId === currentBuildingId\n        ? (floorplanLevels.find((level) => level.level === 0)?.id ??\n          floorplanLevels[0]?.id ??\n          (levelNode?.type === 'level' ? levelNode.id : null))\n        : (() => {\n            if (!nextBuildingId) {\n              return null\n            }\n\n            const buildingNode = sceneNodes[nextBuildingId]\n            if (!buildingNode || buildingNode.type !== 'building') {\n              return null\n            }\n\n            const buildingLevels = buildingNode.children\n              .map((child) => (typeof child === 'string' ? sceneNodes[child as AnyNodeId] : child))\n              .filter((node): node is LevelNode => node?.type === 'level')\n              .sort((a, b) => a.level - b.level)\n\n            return (\n              buildingLevels.find((level) => level.level === 0)?.id ?? buildingLevels[0]?.id ?? null\n            )\n          })()\n\n    setPhase('structure')\n    setStructureLayer('elements')\n    setMode('select')\n\n    const nextSelection: Parameters<typeof setSelection>[0] = {\n      selectedIds: [],\n      zoneId: null,\n    }\n\n    if (nextBuildingId) {\n      nextSelection.buildingId = nextBuildingId\n    }\n\n    if (nextGroundLevelId) {\n      nextSelection.levelId = nextGroundLevelId\n    }\n\n    setSelection(nextSelection)\n  }, [\n    currentBuildingId,\n    floorplanLevels,\n    levelNode,\n    setMode,\n    setPhase,\n    setSelection,\n    setStructureLayer,\n    site,\n  ])\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      const target = event.target as HTMLElement | null\n      const isEditableTarget =\n        target instanceof HTMLInputElement ||\n        target instanceof HTMLTextAreaElement ||\n        Boolean(target?.isContentEditable)\n\n      if (\n        isEditableTarget ||\n        !isFloorplanHovered ||\n        phase !== 'site' ||\n        event.metaKey ||\n        event.ctrlKey ||\n        event.altKey ||\n        event.key.toLowerCase() !== 'v'\n      ) {\n        return\n      }\n\n      setFloorplanSelectionTool('click')\n      restoreGroundLevelStructureSelection()\n    }\n\n    window.addEventListener('keydown', handleKeyDown, true)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown, true)\n    }\n  }, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection])\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {\n        return\n      }\n\n      if (!(isFloorplanHovered && selectedOpeningEntry)) {\n        return\n      }\n\n      const target = event.target as HTMLElement | null\n      const isEditableTarget =\n        target instanceof HTMLInputElement ||\n        target instanceof HTMLTextAreaElement ||\n        Boolean(target?.isContentEditable)\n\n      if (isEditableTarget) {\n        return\n      }\n\n      event.preventDefault()\n      duplicateSelectedOpening()\n    }\n\n    window.addEventListener('keydown', handleKeyDown, true)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown, true)\n    }\n  }, [duplicateSelectedOpening, isFloorplanHovered, selectedOpeningEntry])\n  const activeDraftAnchorPoint = draftStart ?? activePolygonDraftPoints[0] ?? null\n  const floorplanCursorColor = wallEndpointDraft\n    ? palette.editCursor\n    : activeDraftAnchorPoint\n      ? palette.draftStroke\n      : palette.cursor\n\n  return (\n    <div\n      className=\"pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95\"\n      onPointerEnter={() => setFloorplanHovered(true)}\n      onPointerLeave={() => {\n        setFloorplanHovered(false)\n        setFloorplanCursorPosition(null)\n      }}\n      ref={containerRef}\n    >\n      <div className=\"relative min-h-0 flex-1\" ref={viewportHostRef}>\n        {activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-20 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]\"\n            style={{\n              left: floorplanCursorPosition.x + FLOORPLAN_CURSOR_INDICATOR_OFFSET_X,\n              top: floorplanCursorPosition.y + FLOORPLAN_CURSOR_INDICATOR_OFFSET_Y,\n            }}\n          >\n            {activeFloorplanCursorIndicator.kind === 'asset' ? (\n              <img\n                alt=\"\"\n                aria-hidden=\"true\"\n                className=\"h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]\"\n                src={activeFloorplanCursorIndicator.iconSrc}\n              />\n            ) : (\n              <Icon\n                aria-hidden=\"true\"\n                className=\"drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]\"\n                color=\"white\"\n                height={18}\n                icon={activeFloorplanCursorIndicator.icon}\n                width={18}\n              />\n            )}\n          </div>\n        )}\n        {showGuides && canInteractWithGuides && selectedGuide && (\n          <FloorplanGuideHandleHint\n            anchor={guideHandleHintAnchor}\n            isDarkMode={theme === 'dark'}\n            isMacPlatform={isMacPlatform}\n            rotationModifierPressed={rotationModifierPressed}\n          />\n        )}\n        {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && (\n          <div\n            className=\"absolute z-30\"\n            style={{\n              left: selectedOpeningActionMenuPosition.x,\n              top: selectedOpeningActionMenuPosition.y,\n              transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,\n            }}\n          >\n            <NodeActionMenu\n              onDelete={handleSelectedOpeningDelete}\n              onDuplicate={handleSelectedOpeningDuplicate}\n              onMove={handleSelectedOpeningMove}\n              onPointerDown={(event) => event.stopPropagation()}\n              onPointerUp={(event) => event.stopPropagation()}\n            />\n          </div>\n        )}\n\n        {!levelNode || levelNode.type !== 'level' ? (\n          <div className=\"flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm\">\n            Switch to a building level to view and edit the floorplan.\n          </div>\n        ) : (\n          <svg\n            className=\"h-full w-full touch-none\"\n            onClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundClick}\n            onContextMenu={(event) => event.preventDefault()}\n            onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick}\n            onPointerCancel={endPanning}\n            onPointerDown={handlePointerDown}\n            onPointerLeave={handleSvgPointerLeave}\n            onPointerMove={handleSvgPointerMove}\n            onPointerUp={endPanning}\n            ref={svgRef}\n            style={{ cursor: EDITOR_CURSOR }}\n            viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`}\n          >\n            <rect\n              fill={palette.surface}\n              height={viewBox.height}\n              width={viewBox.width}\n              x={viewBox.minX}\n              y={viewBox.minY}\n            />\n\n            <FloorplanGridLayer\n              majorGridPath={majorGridPath}\n              minorGridPath={minorGridPath}\n              palette={palette}\n              showGrid={showGrid}\n            />\n\n            <FloorplanGuideLayer\n              activeGuideInteractionGuideId={activeGuideInteractionGuideId}\n              activeGuideInteractionMode={activeGuideInteractionMode}\n              guides={displayGuides}\n              isInteractive={canInteractWithGuides}\n              onGuideSelect={handleGuideSelect}\n              onGuideTranslateStart={handleGuideTranslateStart}\n              selectedGuideId={selectedGuideId}\n            />\n\n            <FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />\n\n            <FloorplanGeometryLayer\n              canSelectGeometry={canSelectElementFloorplanGeometry}\n              canSelectSlabs={canSelectElementFloorplanGeometry && structureLayer !== 'zones'}\n              hoveredOpeningId={hoveredOpeningId}\n              hoveredWallId={hoveredWallId}\n              onOpeningDoubleClick={handleOpeningDoubleClick}\n              onOpeningHoverChange={setHoveredOpeningId}\n              onOpeningPointerDown={handleOpeningPointerDown}\n              onOpeningSelect={handleOpeningSelect}\n              onSlabDoubleClick={handleSlabDoubleClick}\n              onSlabSelect={handleSlabSelect}\n              onWallClick={handleWallClick}\n              onWallDoubleClick={handleWallDoubleClick}\n              onWallHoverChange={setHoveredWallId}\n              openingsPolygons={openingsPolygons}\n              palette={palette}\n              selectedIdSet={selectedIdSet}\n              slabPolygons={displaySlabPolygons}\n              unit={unit}\n              wallPolygons={displayWallPolygons}\n            />\n\n            <FloorplanZoneLayer\n              canSelectZones={canSelectFloorplanZones}\n              onZoneSelect={handleZoneSelect}\n              palette={palette}\n              selectedZoneId={selectedZoneId}\n              zonePolygons={visibleZonePolygons}\n            />\n\n            <FloorplanPolygonHandleLayer\n              hoveredHandleId={hoveredSiteHandleId}\n              midpointHandles={siteMidpointHandles}\n              onHandleHoverChange={setHoveredSiteHandleId}\n              onMidpointPointerDown={(nodeId, edgeIndex, event) =>\n                handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)\n              }\n              onVertexDoubleClick={(nodeId, vertexIndex, event) =>\n                handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)\n              }\n              onVertexPointerDown={(nodeId, vertexIndex, event) =>\n                handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)\n              }\n              palette={palette}\n              vertexHandles={siteVertexHandles}\n            />\n\n            {isMarqueeSelectionToolActive && (\n              <rect\n                fill=\"transparent\"\n                height={viewBox.height}\n                onClick={(event) => {\n                  event.preventDefault()\n                  event.stopPropagation()\n                }}\n                onDoubleClick={(event) => {\n                  event.preventDefault()\n                  event.stopPropagation()\n                }}\n                onPointerCancel={handleMarqueePointerCancel}\n                onPointerDown={handleMarqueePointerDown}\n                onPointerMove={handleMarqueePointerMove}\n                onPointerUp={handleMarqueePointerUp}\n                style={{ cursor: EDITOR_CURSOR }}\n                width={viewBox.width}\n                x={viewBox.minX}\n                y={viewBox.minY}\n              />\n            )}\n\n            {visibleSvgMarqueeBounds && (\n              <rect\n                fill={palette.selectedFill}\n                fillOpacity={0.14}\n                height={visibleSvgMarqueeBounds.height}\n                pointerEvents=\"none\"\n                stroke={palette.selectedStroke}\n                strokeDasharray=\"0.16 0.1\"\n                strokeWidth=\"0.05\"\n                vectorEffect=\"non-scaling-stroke\"\n                width={visibleSvgMarqueeBounds.width}\n                x={visibleSvgMarqueeBounds.x}\n                y={visibleSvgMarqueeBounds.y}\n              />\n            )}\n\n            {draftPolygon && (\n              <polygon\n                fill={palette.draftFill}\n                fillOpacity={0.35}\n                points={draftPolygonPoints ?? undefined}\n                stroke={palette.draftStroke}\n                strokeDasharray=\"0.24 0.12\"\n                strokeWidth=\"0.07\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n\n            {polygonDraftPolygonPoints && (\n              <polygon\n                fill={palette.draftFill}\n                fillOpacity={0.2}\n                points={polygonDraftPolygonPoints}\n                stroke=\"none\"\n              />\n            )}\n\n            {polygonDraftPolylinePoints && (\n              <polyline\n                fill=\"none\"\n                points={polygonDraftPolylinePoints}\n                stroke={palette.draftStroke}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"0.08\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n\n            {polygonDraftClosingSegment && (\n              <line\n                stroke={palette.draftStroke}\n                strokeDasharray=\"0.16 0.1\"\n                strokeLinecap=\"round\"\n                strokeOpacity={0.75}\n                strokeWidth=\"0.05\"\n                vectorEffect=\"non-scaling-stroke\"\n                x1={polygonDraftClosingSegment.x1}\n                x2={polygonDraftClosingSegment.x2}\n                y1={polygonDraftClosingSegment.y1}\n                y2={polygonDraftClosingSegment.y2}\n              />\n            )}\n\n            {activePolygonDraftPoints.map((point, index) => (\n              <circle\n                cx={toSvgX(point[0])}\n                cy={toSvgY(point[1])}\n                fill={index === 0 ? palette.anchor : palette.draftStroke}\n                fillOpacity={0.95}\n                key={`polygon-draft-${index}`}\n                pointerEvents=\"none\"\n                r={index === 0 ? 0.12 : 0.1}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            ))}\n\n            <FloorplanWallEndpointLayer\n              endpointHandles={wallEndpointHandles}\n              hoveredEndpointId={hoveredEndpointId}\n              onEndpointHoverChange={setHoveredEndpointId}\n              onWallEndpointPointerDown={handleWallEndpointPointerDown}\n              palette={palette}\n            />\n\n            <FloorplanPolygonHandleLayer\n              hoveredHandleId={hoveredSlabHandleId}\n              midpointHandles={slabMidpointHandles}\n              onHandleHoverChange={setHoveredSlabHandleId}\n              onMidpointPointerDown={(nodeId, edgeIndex, event) =>\n                handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event)\n              }\n              onVertexDoubleClick={(nodeId, vertexIndex, event) =>\n                handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event)\n              }\n              onVertexPointerDown={(nodeId, vertexIndex, event) =>\n                handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event)\n              }\n              palette={palette}\n              vertexHandles={slabVertexHandles}\n            />\n\n            <FloorplanPolygonHandleLayer\n              hoveredHandleId={hoveredZoneHandleId}\n              midpointHandles={zoneMidpointHandles}\n              onHandleHoverChange={setHoveredZoneHandleId}\n              onMidpointPointerDown={(nodeId, edgeIndex, event) =>\n                handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event)\n              }\n              onVertexDoubleClick={(nodeId, vertexIndex, event) =>\n                handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event)\n              }\n              onVertexPointerDown={(nodeId, vertexIndex, event) =>\n                handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event)\n              }\n              palette={palette}\n              vertexHandles={zoneVertexHandles}\n            />\n\n            {selectedGuide && showGuides && (\n              <FloorplanGuideSelectionOverlay\n                guide={selectedGuide}\n                isDarkMode={theme === 'dark'}\n                onCornerHoverChange={setHoveredGuideCorner}\n                onCornerPointerDown={handleGuideCornerPointerDown}\n                rotationModifierPressed={rotationModifierPressed}\n                showHandles={canInteractWithGuides}\n              />\n            )}\n\n            {cursorPoint && (\n              <g>\n                <circle\n                  cx={toSvgX(cursorPoint[0])}\n                  cy={toSvgY(cursorPoint[1])}\n                  fill={floorplanCursorColor}\n                  fillOpacity={0.25}\n                  r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS}\n                />\n                <circle\n                  cx={toSvgX(cursorPoint[0])}\n                  cy={toSvgY(cursorPoint[1])}\n                  fill={floorplanCursorColor}\n                  fillOpacity={0.9}\n                  r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS}\n                />\n              </g>\n            )}\n\n            {activeDraftAnchorPoint && (\n              <circle\n                cx={toSvgX(activeDraftAnchorPoint[0])}\n                cy={toSvgY(activeDraftAnchorPoint[1])}\n                fill={palette.anchor}\n                fillOpacity={0.95}\n                r=\"0.14\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n          </svg>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/grid.tsx",
    "content": "'use client'\n\nimport { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useFrame } from '@react-three/fiber'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { MathUtils, type Mesh, Vector2 } from 'three'\nimport { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'\nimport { MeshBasicNodeMaterial } from 'three/webgpu'\nimport { useGridEvents } from '../../hooks/use-grid-events'\nimport { EDITOR_LAYER } from '../../lib/constants'\n\nexport const Grid = ({\n  cellSize = 0.5,\n  cellThickness = 0.5,\n  cellColor = '#888888',\n  sectionSize = 1,\n  sectionThickness = 1,\n  sectionColor = '#000000',\n  fadeDistance = 100,\n  fadeStrength = 1,\n  revealRadius = 10,\n}: {\n  cellSize?: number\n  cellThickness?: number\n  cellColor?: string\n  sectionSize?: number\n  sectionThickness?: number\n  sectionColor?: string\n  fadeDistance?: number\n  fadeStrength?: number\n  revealRadius?: number\n}) => {\n  const theme = useViewer((state) => state.theme)\n\n  // Use slightly lighter colors for dark mode grid to make it apparent\n  const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor\n  const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor\n\n  const cursorPositionRef = useRef(new Vector2(0, 0))\n\n  const material = useMemo(() => {\n    // Use xy since plane geometry is in XY space (before rotation)\n    const pos = positionLocal.xy\n\n    // Cursor position uniform\n    const cursorPos = uniform(cursorPositionRef.current)\n\n    // Grid line function using fwidth for anti-aliasing\n    // Returns 1 on grid lines, 0 elsewhere\n    const getGrid = (size: number, thickness: number) => {\n      const r = pos.div(size)\n      const fw = fwidth(r)\n      // Distance to nearest grid line for each axis\n      const grid = fract(r.sub(0.5)).sub(0.5).abs()\n      // Anti-aliased step: divide by fwidth and clamp\n      const lineX = float(1).sub(\n        grid.x\n          .div(fw.x)\n          .add(1 - thickness)\n          .min(1),\n      )\n      const lineY = float(1).sub(\n        grid.y\n          .div(fw.y)\n          .add(1 - thickness)\n          .min(1),\n      )\n      // Combine both axes - max gives us lines in both directions\n      return lineX.max(lineY)\n    }\n\n    const g1 = getGrid(cellSize, cellThickness)\n    const g2 = getGrid(sectionSize, sectionThickness)\n\n    // Distance fade from center\n    const dist = pos.length()\n    const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)\n\n    // Cursor reveal effect - distance from cursor\n    const cursorDist = pos.sub(cursorPos).length()\n    const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)\n\n    // Mix colors based on section grid\n    const gridColor = mix(\n      color(effectiveCellColor),\n      color(effectiveSectionColor),\n      float(sectionThickness).mul(g2).min(1),\n    )\n\n    // Baseline alpha: small amount of opacity everywhere the grid exists\n    const baseAlpha = float(0.4) // Subtle global visibility\n\n    // Combined alpha with cursor fade and baseline minimum\n    const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))\n    const finalAlpha = mix(alpha.mul(0.75), alpha, g2)\n\n    return new MeshBasicNodeMaterial({\n      transparent: true,\n      colorNode: gridColor,\n      opacityNode: finalAlpha,\n      depthWrite: false,\n    })\n  }, [\n    cellSize,\n    cellThickness,\n    effectiveCellColor,\n    sectionSize,\n    sectionThickness,\n    effectiveSectionColor,\n    fadeDistance,\n    fadeStrength,\n    revealRadius,\n  ])\n\n  const gridRef = useRef<Mesh>(null!)\n  const [gridY, setGridY] = useState(0)\n\n  // Use custom raycasting for grid events (independent of mesh events)\n  useGridEvents(gridY)\n\n  // Update cursor position from grid:move events\n  useEffect(() => {\n    const onGridMove = (event: GridEvent) => {\n      cursorPositionRef.current.set(event.position[0], -event.position[2])\n    }\n\n    emitter.on('grid:move', onGridMove)\n    return () => {\n      emitter.off('grid:move', onGridMove)\n    }\n  }, [])\n\n  useFrame((_, delta) => {\n    const currentLevelId = useViewer.getState().selection.levelId\n    let targetY = 0\n    if (currentLevelId) {\n      const levelMesh = sceneRegistry.nodes.get(currentLevelId)\n      if (levelMesh) {\n        targetY = levelMesh.position.y\n      }\n    }\n    const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)\n    gridRef.current.position.y = newY\n    setGridY(newY)\n  })\n\n  const showGrid = useViewer((state) => state.showGrid)\n\n  return (\n    <mesh\n      layers={EDITOR_LAYER}\n      material={material}\n      ref={gridRef}\n      rotation-x={-Math.PI / 2}\n      visible={showGrid}\n    >\n      <planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/index.tsx",
    "content": "'use client'\n\nimport { Icon } from '@iconify/react'\nimport { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'\nimport { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'\nimport { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'\nimport { ViewerOverlay } from '../../components/viewer-overlay'\nimport { ViewerZoneSystem } from '../../components/viewer-zone-system'\nimport { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'\nimport { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'\nimport { useKeyboard } from '../../hooks/use-keyboard'\nimport {\n  applySceneGraphToEditor,\n  loadSceneFromLocalStorage,\n  type SceneGraph,\n  writePersistedSelection,\n} from '../../lib/scene'\nimport { initSFXBus } from '../../lib/sfx-bus'\nimport useEditor from '../../store/use-editor'\nimport { CeilingSystem } from '../systems/ceiling/ceiling-system'\nimport { RoofEditSystem } from '../systems/roof/roof-edit-system'\nimport { StairEditSystem } from '../systems/stair/stair-edit-system'\nimport { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'\nimport { ZoneSystem } from '../systems/zone/zone-system'\nimport { BoxSelectTool } from '../tools/select/box-select-tool'\nimport { ToolManager } from '../tools/tool-manager'\nimport { ActionMenu } from '../ui/action-menu'\nimport { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-palette'\nimport { EditorCommands } from '../ui/command-palette/editor-commands'\nimport { FloatingLevelSelector } from '../ui/floating-level-selector'\nimport { HelperManager } from '../ui/helpers/helper-manager'\nimport { PanelManager } from '../ui/panels/panel-manager'\nimport { ErrorBoundary } from '../ui/primitives/error-boundary'\nimport { useSidebarStore } from '../ui/primitives/sidebar'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'\nimport { SceneLoader } from '../ui/scene-loader'\nimport { AppSidebar } from '../ui/sidebar/app-sidebar'\nimport type { ExtraPanel } from '../ui/sidebar/icon-rail'\nimport { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'\nimport { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'\nimport type { SidebarTab } from '../ui/sidebar/tab-bar'\nimport { CustomCameraControls } from './custom-camera-controls'\nimport { EditorLayoutV2 } from './editor-layout-v2'\nimport { ExportManager } from './export-manager'\nimport { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'\nimport { FloatingActionMenu } from './floating-action-menu'\nimport { FloorplanPanel } from './floorplan-panel'\nimport { Grid } from './grid'\nimport { PresetThumbnailGenerator } from './preset-thumbnail-generator'\nimport { SelectionManager } from './selection-manager'\nimport { SiteEdgeLabels } from './site-edge-labels'\nimport { ThumbnailGenerator } from './thumbnail-generator'\nimport { WallMeasurementLabel } from './wall-measurement-label'\n\nlet hasInitializedEditorRuntime = false\nconst CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'\n\nfunction initializeEditorRuntime() {\n  if (hasInitializedEditorRuntime) return\n  initSpatialGridSync()\n  initSpaceDetectionSync(useScene, useEditor)\n  initSFXBus()\n\n  hasInitializedEditorRuntime = true\n}\nexport interface EditorProps {\n  // Layout version — 'v1' (default) or 'v2' (navbar + two-column)\n  layoutVersion?: 'v1' | 'v2'\n\n  // UI slots (v1)\n  appMenuButton?: ReactNode\n  sidebarTop?: ReactNode\n\n  // UI slots (v2)\n  navbarSlot?: ReactNode\n  sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]\n  viewerToolbarLeft?: ReactNode\n  viewerToolbarRight?: ReactNode\n\n  projectId?: string | null\n\n  // Persistence — defaults to localStorage when omitted\n  onLoad?: () => Promise<SceneGraph | null>\n  onSave?: (scene: SceneGraph) => Promise<void>\n  onDirty?: () => void\n  onSaveStatusChange?: (status: SaveStatus) => void\n\n  // Version preview\n  previewScene?: SceneGraph\n  isVersionPreviewMode?: boolean\n\n  // Loading indicator (e.g. project fetching in community mode)\n  isLoading?: boolean\n\n  // Thumbnail\n  onThumbnailCapture?: (blob: Blob) => void\n\n  // Panel config (passed through to sidebar panels — v1 only)\n  settingsPanelProps?: SettingsPanelProps\n  sitePanelProps?: SitePanelProps\n  extraSidebarPanels?: ExtraPanel[]\n\n  // Presets storage backend (defaults to localStorage)\n  presetsAdapter?: PresetsAdapter\n\n  // Command palette fallback when no commands match\n  commandPaletteEmptyAction?: CommandPaletteEmptyAction\n}\n\nfunction EditorSceneCrashFallback() {\n  return (\n    <div className=\"fixed inset-0 z-80 flex items-center justify-center bg-background/95 p-4 text-foreground\">\n      <div className=\"w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl\">\n        <h2 className=\"font-semibold text-lg\">The editor scene failed to render</h2>\n        <p className=\"mt-2 text-muted-foreground text-sm\">\n          You can retry the scene or return home without reloading the whole app shell.\n        </p>\n        <div className=\"mt-4 flex items-center gap-2\">\n          <button\n            className=\"rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80\"\n            onClick={() => window.location.reload()}\n            type=\"button\"\n          >\n            Reload editor\n          </button>\n          <a\n            className=\"rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40\"\n            href=\"/\"\n          >\n            Back to home\n          </a>\n        </div>\n      </div>\n    </div>\n  )\n}\n\n// ── Sidebar slot: in-flow, resizable, collapses to a grab strip ──────────────\n\nfunction SidebarSlot({ children }: { children: ReactNode }) {\n  const width = useSidebarStore((s) => s.width)\n  const isCollapsed = useSidebarStore((s) => s.isCollapsed)\n  const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)\n  const setWidth = useSidebarStore((s) => s.setWidth)\n  const isDragging = useSidebarStore((s) => s.isDragging)\n  const setIsDragging = useSidebarStore((s) => s.setIsDragging)\n\n  const isResizing = useRef(false)\n  const isExpanding = useRef(false)\n\n  const handleResizerDown = useCallback(\n    (e: React.PointerEvent) => {\n      e.preventDefault()\n      isResizing.current = true\n      setIsDragging(true)\n      document.body.style.cursor = 'col-resize'\n      document.body.style.userSelect = 'none'\n    },\n    [setIsDragging],\n  )\n\n  const handleGrabDown = useCallback(\n    (e: React.PointerEvent) => {\n      e.preventDefault()\n      isExpanding.current = true\n      setIsDragging(true)\n      document.body.style.cursor = 'col-resize'\n      document.body.style.userSelect = 'none'\n    },\n    [setIsDragging],\n  )\n\n  useEffect(() => {\n    const handlePointerMove = (e: PointerEvent) => {\n      if (isResizing.current) {\n        setWidth(e.clientX)\n      } else if (isExpanding.current && e.clientX > 60) {\n        setIsCollapsed(false)\n        setWidth(Math.max(240, e.clientX))\n      }\n    }\n    const handlePointerUp = () => {\n      isResizing.current = false\n      isExpanding.current = false\n      setIsDragging(false)\n      document.body.style.cursor = ''\n      document.body.style.userSelect = ''\n    }\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n    }\n  }, [setWidth, setIsCollapsed, setIsDragging])\n\n  return (\n    // Outer: no overflow-hidden so the handle can extend into the gap\n    <div\n      className=\"relative h-full flex-shrink-0 rounded-xl\"\n      style={{\n        width: isCollapsed ? 8 : width,\n        transition: isDragging ? 'none' : 'width 150ms ease',\n      }}\n    >\n      {/* Inner: overflow-hidden clips content to rounded corners */}\n      <div className=\"h-full w-full overflow-hidden rounded-xl\">\n        {isCollapsed ? (\n          <div\n            className=\"absolute inset-0 z-10 cursor-col-resize transition-colors hover:bg-primary/20\"\n            onPointerDown={handleGrabDown}\n            title=\"Expand sidebar\"\n          />\n        ) : (\n          children\n        )}\n      </div>\n\n      {/* Handle: extends into the gap, centered on the gap midpoint */}\n      {!isCollapsed && (\n        <div\n          className=\"group absolute inset-y-0 -right-3.5 z-10 flex w-4 cursor-col-resize items-stretch justify-center py-4\"\n          onPointerDown={handleResizerDown}\n        >\n          <div className=\"w-px self-stretch rounded-full bg-transparent transition-colors group-hover:bg-neutral-300\" />\n        </div>\n      )}\n    </div>\n  )\n}\n\n// ── UI overlays: fixed, scoped to viewer area via transform containing block ──\n\nfunction ViewerOverlays({ left, children }: { left: number; children: ReactNode }) {\n  return (\n    <div\n      className=\"pointer-events-none\"\n      style={{\n        position: 'fixed',\n        top: 0,\n        right: 0,\n        bottom: 0,\n        left,\n        // Creates a containing block so position:fixed children are scoped here\n        transform: 'translateZ(0)',\n        zIndex: 30,\n      }}\n    >\n      {children}\n    </div>\n  )\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction SelectionPersistenceManager({ enabled }: { enabled: boolean }) {\n  const selection = useViewer((state) => state.selection)\n\n  useEffect(() => {\n    if (!enabled) {\n      return\n    }\n\n    writePersistedSelection(selection)\n  }, [enabled, selection])\n\n  return null\n}\n\ntype ShortcutKey = {\n  value: string\n}\n\ntype CameraControlHint = {\n  action: string\n  keys: ShortcutKey[]\n  alternativeKeys?: ShortcutKey[]\n}\n\nconst EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [\n  {\n    action: 'Pan',\n    keys: [{ value: 'Space' }, { value: 'Left click' }],\n  },\n  { action: 'Rotate', keys: [{ value: 'Right click' }] },\n  { action: 'Zoom', keys: [{ value: 'Scroll' }] },\n]\n\nconst PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [\n  { action: 'Pan', keys: [{ value: 'Left click' }] },\n  { action: 'Rotate', keys: [{ value: 'Right click' }] },\n  { action: 'Zoom', keys: [{ value: 'Scroll' }] },\n]\n\nconst CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {\n  'Left click': {\n    icon: 'ph:mouse-left-click-fill',\n    label: 'Left click',\n  },\n  'Middle click': {\n    icon: 'qlementine-icons:mouse-middle-button-16',\n    label: 'Middle click',\n  },\n  'Right click': {\n    icon: 'ph:mouse-right-click-fill',\n    label: 'Right click',\n  },\n  Scroll: {\n    icon: 'qlementine-icons:mouse-middle-button-16',\n    label: 'Scroll wheel',\n  },\n  Space: {\n    icon: 'lucide:space',\n    label: 'Space',\n  },\n}\n\nfunction readCameraControlsHintDismissed(): boolean {\n  if (typeof window === 'undefined') {\n    return false\n  }\n\n  try {\n    return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'\n  } catch {\n    return false\n  }\n}\n\nfunction writeCameraControlsHintDismissed(dismissed: boolean) {\n  if (typeof window === 'undefined') {\n    return\n  }\n\n  try {\n    if (dismissed) {\n      window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')\n      return\n    }\n\n    window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)\n  } catch {}\n}\n\nfunction InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {\n  const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]\n\n  if (meta?.icon) {\n    return (\n      <span\n        aria-label={meta.label}\n        className=\"inline-flex items-center text-foreground/90\"\n        role=\"img\"\n        title={meta.label}\n      >\n        <Icon aria-hidden=\"true\" color=\"currentColor\" height={16} icon={meta.icon} width={16} />\n        <span className=\"sr-only\">{meta.label}</span>\n      </span>\n    )\n  }\n\n  return (\n    <span className=\"font-medium text-[11px] text-foreground/90\">\n      {meta?.text ?? shortcutKey.value}\n    </span>\n  )\n}\n\nfunction ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {\n  return (\n    <div className=\"flex flex-wrap items-center gap-1\">\n      {keys.map((key, index) => (\n        <div className=\"flex items-center gap-1\" key={`${key.value}-${index}`}>\n          {index > 0 ? <span className=\"text-[10px] text-muted-foreground/70\">+</span> : null}\n          <InlineShortcutKey shortcutKey={key} />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction CameraControlHintItem({ hint }: { hint: CameraControlHint }) {\n  return (\n    <div className=\"flex min-w-0 flex-col items-center gap-1.5 px-4 text-center first:pl-0 last:pr-0\">\n      <span className=\"font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]\">\n        {hint.action}\n      </span>\n      <div className=\"flex flex-wrap items-center justify-center gap-1.5\">\n        <ShortcutSequence keys={hint.keys} />\n        {hint.alternativeKeys ? (\n          <>\n            <span className=\"text-[10px] text-muted-foreground/40\">/</span>\n            <ShortcutSequence keys={hint.alternativeKeys} />\n          </>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nfunction ViewerCanvasControlsHint({\n  isPreviewMode,\n  onDismiss,\n}: {\n  isPreviewMode: boolean\n  onDismiss: () => void\n}) {\n  const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS\n\n  return (\n    <div className=\"pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2\">\n      <section\n        aria-label=\"Camera controls hint\"\n        className=\"pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl\"\n      >\n        <div className=\"grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18\">\n          {hints.map((hint) => (\n            <CameraControlHintItem hint={hint} key={hint.action} />\n          ))}\n        </div>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <button\n              aria-label=\"Dismiss camera controls hint\"\n              className=\"flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground\"\n              onClick={onDismiss}\n              type=\"button\"\n            >\n              <Icon\n                aria-hidden=\"true\"\n                color=\"currentColor\"\n                height={14}\n                icon=\"lucide:x\"\n                width={14}\n              />\n            </button>\n          </TooltipTrigger>\n          <TooltipContent side=\"bottom\" sideOffset={8}>\n            Dismiss\n          </TooltipContent>\n        </Tooltip>\n      </section>\n    </div>\n  )\n}\n\nexport default function Editor({\n  layoutVersion = 'v1',\n  appMenuButton,\n  sidebarTop,\n  navbarSlot,\n  sidebarTabs,\n  viewerToolbarLeft,\n  viewerToolbarRight,\n  projectId,\n  onLoad,\n  onSave,\n  onDirty,\n  onSaveStatusChange,\n  previewScene,\n  isVersionPreviewMode = false,\n  isLoading = false,\n  onThumbnailCapture,\n  settingsPanelProps,\n  sitePanelProps,\n  extraSidebarPanels,\n  presetsAdapter,\n  commandPaletteEmptyAction,\n}: EditorProps) {\n  useKeyboard()\n\n  const { isLoadingSceneRef } = useAutoSave({\n    onSave,\n    onDirty,\n    onSaveStatusChange,\n    isVersionPreviewMode,\n  })\n\n  const [isSceneLoading, setIsSceneLoading] = useState(false)\n  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)\n  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(\n    null,\n  )\n  const isPreviewMode = useEditor((s) => s.isPreviewMode)\n  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)\n  const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)\n  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)\n  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)\n\n  const sidebarWidth = useSidebarStore((s) => s.width)\n  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)\n  const viewerAreaRef = useRef<HTMLDivElement>(null)\n  const isResizingFloorplan = useRef(false)\n\n  const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {\n    e.preventDefault()\n    isResizingFloorplan.current = true\n    document.body.style.cursor = 'col-resize'\n    document.body.style.userSelect = 'none'\n  }, [])\n\n  useEffect(() => {\n    const handlePointerMove = (e: PointerEvent) => {\n      if (!isResizingFloorplan.current) return\n      if (!viewerAreaRef.current) return\n      const rect = viewerAreaRef.current.getBoundingClientRect()\n      const newRatio = (e.clientX - rect.left) / rect.width\n      setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))\n    }\n    const handlePointerUp = () => {\n      isResizingFloorplan.current = false\n      document.body.style.cursor = ''\n      document.body.style.userSelect = ''\n    }\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n    }\n  }, [])\n\n  useEffect(() => {\n    initializeEditorRuntime()\n  }, [])\n\n  useEffect(() => {\n    useViewer.getState().setProjectId(projectId ?? null)\n\n    return () => {\n      useViewer.getState().setProjectId(null)\n    }\n  }, [projectId])\n\n  // Load scene on mount (or when onLoad identity changes, e.g. project switch)\n  useEffect(() => {\n    let cancelled = false\n\n    async function load() {\n      isLoadingSceneRef.current = true\n      setHasLoadedInitialScene(false)\n      setIsSceneLoading(true)\n\n      try {\n        const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()\n        if (!cancelled) {\n          applySceneGraphToEditor(sceneGraph)\n        }\n      } catch {\n        if (!cancelled) applySceneGraphToEditor(null)\n      } finally {\n        if (!cancelled) {\n          setIsSceneLoading(false)\n          setHasLoadedInitialScene(true)\n          requestAnimationFrame(() => {\n            isLoadingSceneRef.current = false\n          })\n        }\n      }\n    }\n\n    load()\n\n    return () => {\n      cancelled = true\n    }\n  }, [onLoad, isLoadingSceneRef])\n\n  // Apply preview scene when version preview mode changes\n  useEffect(() => {\n    if (isVersionPreviewMode && previewScene) {\n      applySceneGraphToEditor(previewScene)\n    }\n  }, [isVersionPreviewMode, previewScene])\n\n  useEffect(() => {\n    document.body.classList.add('dark')\n    return () => {\n      document.body.classList.remove('dark')\n    }\n  }, [])\n\n  useEffect(() => {\n    setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())\n  }, [])\n\n  const showLoader = isLoading || isSceneLoading\n  const dismissCameraControlsHint = useCallback(() => {\n    setIsCameraControlsHintVisible(false)\n    writeCameraControlsHintDismissed(true)\n  }, [])\n\n  // ── Shared viewer scene content ──\n  const viewerSceneContent = (\n    <>\n      {!isFirstPersonMode && <SelectionManager />}\n      {!isFirstPersonMode && <BoxSelectTool />}\n      {!isFirstPersonMode && <FloatingActionMenu />}\n      {!isFirstPersonMode && <WallMeasurementLabel />}\n      <ExportManager />\n      {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}\n      <CeilingSystem />\n      <RoofEditSystem />\n      <StairEditSystem />\n      {!isLoading && !isFirstPersonMode && <Grid cellColor=\"#aaa\" fadeDistance={500} sectionColor=\"#ccc\" />}\n      {!isLoading && !isFirstPersonMode && <ToolManager />}\n      <CustomCameraControls />\n      {isFirstPersonMode && <FirstPersonControls />}\n      <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />\n      <PresetThumbnailGenerator />\n      {!isFirstPersonMode && <SiteEdgeLabels />}\n      {isFirstPersonMode && <InteractiveSystem />}\n    </>\n  )\n\n  const previewViewerContent = (\n    <Viewer selectionManager=\"default\">\n      <ExportManager />\n      <ViewerZoneSystem />\n      <CeilingSystem />\n      <RoofEditSystem />\n      <StairEditSystem />\n      <CustomCameraControls />\n      <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />\n      <PresetThumbnailGenerator />\n      <InteractiveSystem />\n    </Viewer>\n  )\n\n  // ── Shared viewer canvas (handles split/2d/3d) ──\n  const viewMode = useEditor((s) => s.viewMode)\n\n  const show2d = viewMode === '2d' || viewMode === 'split'\n  const show3d = viewMode === '3d' || viewMode === 'split'\n\n  const viewerCanvas = (\n    <ErrorBoundary fallback={<EditorSceneCrashFallback />}>\n      <div className=\"flex h-full\" ref={viewerAreaRef}>\n        {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}\n        <div\n          className=\"relative h-full flex-shrink-0\"\n          style={{\n            width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,\n            display: show2d ? undefined : 'none',\n          }}\n        >\n          <div className=\"h-full w-full overflow-hidden\">\n            <FloorplanPanel />\n          </div>\n          {viewMode === 'split' && (\n            <div\n              className=\"absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center\"\n              onPointerDown={handleFloorplanDividerDown}\n            >\n              <div className=\"h-8 w-1 rounded-full bg-neutral-400\" />\n            </div>\n          )}\n        </div>\n\n        {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}\n        <div\n          className=\"relative min-w-0 flex-1 overflow-hidden\"\n          style={{ display: show3d ? undefined : 'none' }}\n        >\n          {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (\n            <ViewerCanvasControlsHint\n              isPreviewMode={isPreviewMode}\n              onDismiss={dismissCameraControlsHint}\n            />\n          ) : null}\n          <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />\n          <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>\n        </div>\n      </div>\n      {!isLoading && <ZoneLabelEditorSystem />}\n    </ErrorBoundary>\n  )\n\n  // ── V2 layout ──\n  if (layoutVersion === 'v2') {\n    const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? [])\n\n    const renderTabContent = (tabId: string) => {\n      // Built-in panels\n      if (tabId === 'site') {\n        return <SitePanel {...sitePanelProps} />\n      }\n      if (tabId === 'settings') {\n        return <SettingsPanel {...settingsPanelProps} />\n      }\n      // External tabs (AI chat, catalog, etc.)\n      const tab = tabMap.get(tabId)\n      if (!tab) return null\n      const Component = tab.component\n      return <Component />\n    }\n\n    const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []\n\n    return (\n      <PresetsProvider adapter={presetsAdapter}>\n        {showLoader && (\n          <div className=\"fixed inset-0 z-60\">\n            <SceneLoader />\n          </div>\n        )}\n\n        {!isLoading && isPreviewMode ? (\n          <div className=\"dark flex h-full w-full flex-col bg-neutral-100 text-foreground\">\n            <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />\n            <div className=\"h-full w-full\">{previewViewerContent}</div>\n          </div>\n        ) : (\n          <>\n            {/* First-person overlay — rendered on top of normal layout */}\n            {isFirstPersonMode && (\n              <div className=\"fixed inset-0 z-50 pointer-events-none\">\n                <FirstPersonOverlay\n                  onExit={() => useEditor.getState().setFirstPersonMode(false)}\n                />\n              </div>\n            )}\n            <EditorLayoutV2\n              navbarSlot={navbarSlot}\n              overlays={\n                <>\n                  <FloatingLevelSelector />\n                  <div className=\"pointer-events-auto\">\n                    <ActionMenu />\n                  </div>\n                  <div className=\"pointer-events-auto\">\n                    <PanelManager />\n                  </div>\n                  <div className=\"pointer-events-auto\">\n                    <HelperManager />\n                  </div>\n                </>\n              }\n              renderTabContent={renderTabContent}\n              sidebarTabs={tabBarTabs}\n              viewerContent={viewerCanvas}\n              viewerToolbarLeft={viewerToolbarLeft}\n              viewerToolbarRight={viewerToolbarRight}\n            />\n            <EditorCommands />\n            <CommandPalette emptyAction={commandPaletteEmptyAction} />\n          </>\n        )}\n      </PresetsProvider>\n    )\n  }\n\n  // ── V1 layout (existing) ──\n  // p-3 (12px) padding on root + gap-3 (12px) between sidebar and viewer + sidebar width\n  const LAYOUT_PADDING = 12\n  const LAYOUT_GAP = 12\n  const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP\n\n  return (\n    <PresetsProvider adapter={presetsAdapter}>\n      <div className=\"dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground\">\n        {showLoader && (\n          <div className=\"fixed inset-0 z-60\">\n            <SceneLoader />\n          </div>\n        )}\n\n        {!isLoading && isPreviewMode ? (\n          <>\n            <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />\n            <div className=\"h-full w-full\">{previewViewerContent}</div>\n          </>\n        ) : (\n          <>\n            {/* Sidebar */}\n            <SidebarSlot>\n              <AppSidebar\n                appMenuButton={appMenuButton}\n                commandPaletteEmptyAction={commandPaletteEmptyAction}\n                extraPanels={extraSidebarPanels}\n                settingsPanelProps={settingsPanelProps}\n                sidebarTop={sidebarTop}\n                sitePanelProps={sitePanelProps}\n              />\n            </SidebarSlot>\n\n            {/* Viewer area */}\n            <div className=\"relative flex-1 overflow-hidden rounded-xl\" ref={viewerAreaRef}>\n              {viewerCanvas}\n            </div>\n\n            {/* Fixed UI overlays scoped to the viewer area */}\n            <ViewerOverlays left={overlayLeft}>\n              <div className=\"pointer-events-auto\">\n                <ActionMenu />\n              </div>\n              <div className=\"pointer-events-auto\">\n                <PanelManager />\n              </div>\n              <div className=\"pointer-events-auto\">\n                <HelperManager />\n              </div>\n            </ViewerOverlays>\n          </>\n        )}\n      </div>\n    </PresetsProvider>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/node-action-menu.tsx",
    "content": "'use client'\n\nimport { Copy, Move, Trash2 } from 'lucide-react'\nimport type { MouseEventHandler, PointerEventHandler } from 'react'\n\ntype NodeActionMenuProps = {\n  onDelete: MouseEventHandler<HTMLButtonElement>\n  onDuplicate?: MouseEventHandler<HTMLButtonElement>\n  onMove?: MouseEventHandler<HTMLButtonElement>\n  onPointerDown?: PointerEventHandler<HTMLDivElement>\n  onPointerUp?: PointerEventHandler<HTMLDivElement>\n  onPointerEnter?: PointerEventHandler<HTMLDivElement>\n  onPointerLeave?: PointerEventHandler<HTMLDivElement>\n}\n\nexport function NodeActionMenu({\n  onDelete,\n  onDuplicate,\n  onMove,\n  onPointerDown,\n  onPointerUp,\n  onPointerEnter,\n  onPointerLeave,\n}: NodeActionMenuProps) {\n  return (\n    <div\n      className=\"pointer-events-auto flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md\"\n      onPointerDown={onPointerDown}\n      onPointerEnter={onPointerEnter}\n      onPointerLeave={onPointerLeave}\n      onPointerUp={onPointerUp}\n    >\n      {onMove && (\n        <button\n          aria-label=\"Move\"\n          className=\"tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          onClick={onMove}\n          title=\"Move\"\n          type=\"button\"\n        >\n          <Move className=\"h-4 w-4\" />\n        </button>\n      )}\n      {onDuplicate && (\n        <button\n          aria-label=\"Duplicate\"\n          className=\"tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          onClick={onDuplicate}\n          title=\"Duplicate\"\n          type=\"button\"\n        >\n          <Copy className=\"h-4 w-4\" />\n        </button>\n      )}\n      <button\n        aria-label=\"Delete\"\n        className=\"tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive\"\n        onClick={onDelete}\n        title=\"Delete\"\n        type=\"button\"\n      >\n        <Trash2 className=\"h-4 w-4\" />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/preset-thumbnail-generator.tsx",
    "content": "'use client'\n\nimport { emitter, sceneRegistry } from '@pascal-app/core'\nimport { useThree } from '@react-three/fiber'\nimport { useCallback, useEffect } from 'react'\nimport * as THREE from 'three'\nimport { usePresetsAdapter } from '../../contexts/presets-context'\n\nconst THUMBNAIL_SIZE = 1080\nconst CAMERA_FOV = 45\n\nexport const PresetThumbnailGenerator = () => {\n  const gl = useThree((state) => state.gl)\n  const scene = useThree((state) => state.scene)\n  const adapter = usePresetsAdapter()\n\n  const generate = useCallback(\n    async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {\n      const target = sceneRegistry.nodes.get(nodeId)\n      if (!target) {\n        console.error('❌ PresetThumbnail: node not found', nodeId)\n        return\n      }\n\n      // Compute each mesh's transform relative to the target node (cancels world\n      // position/rotation), so the item is always rendered at origin with a known\n      // neutral orientation regardless of where it's placed in the scene.\n      target.updateWorldMatrix(true, true)\n      const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert()\n      const relMatrix = new THREE.Matrix4()\n\n      const clones: THREE.Object3D[] = []\n      target.traverse((obj) => {\n        if (\n          !(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)\n        )\n          return\n        const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children\n        relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)\n        relMatrix.decompose(c.position, c.quaternion, c.scale)\n        scene.add(c)\n        clones.push(c)\n      })\n\n      if (clones.length === 0) {\n        console.error('❌ PresetThumbnail: no renderable objects found', nodeId)\n        return\n      }\n\n      // Combined bounding box across all clones\n      const box = new THREE.Box3()\n      for (const c of clones) box.expandByObject(c)\n\n      if (box.isEmpty()) {\n        for (const c of clones) scene.remove(c)\n        console.error('❌ PresetThumbnail: empty bounding box', nodeId)\n        return\n      }\n\n      const sphere = new THREE.Sphere()\n      box.getBoundingSphere(sphere)\n\n      // Camera: aspect matches canvas (center-cropped to square after render)\n      const { width, height } = gl.domElement\n      const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000)\n      const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize()\n      const fovRad = (CAMERA_FOV * Math.PI) / 180\n      const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3\n      camera.position.copy(sphere.center).addScaledVector(dir, dist)\n      camera.lookAt(sphere.center)\n      camera.updateProjectionMatrix()\n\n      // Hide all scene geometry except the clones — leave lights, cameras, etc. intact\n      const cloneSet = new Set<THREE.Object3D>(clones)\n      const snapshot = new Map<THREE.Object3D, boolean>()\n      scene.traverse((obj) => {\n        if (cloneSet.has(obj)) return\n        if (\n          !(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)\n        )\n          return\n        snapshot.set(obj, obj.visible)\n        obj.visible = false\n      })\n\n      gl.render(scene, camera)\n\n      // Restore visibility and remove clones\n      snapshot.forEach((wasVisible, obj) => {\n        obj.visible = wasVisible\n      })\n      for (const c of clones) scene.remove(c)\n\n      // Center-crop to square and scale to THUMBNAIL_SIZE\n      const minDim = Math.min(width, height)\n      const sx = Math.round((width - minDim) / 2)\n      const sy = Math.round((height - minDim) / 2)\n      const offscreen = document.createElement('canvas')\n      offscreen.width = THUMBNAIL_SIZE\n      offscreen.height = THUMBNAIL_SIZE\n      const ctx = offscreen.getContext('2d')!\n      ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE)\n\n      offscreen.toBlob(async (blob) => {\n        if (!blob) {\n          console.error('❌ PresetThumbnail: failed to create blob')\n          return\n        }\n        if (!adapter.uploadPresetThumbnail) return\n        const thumbnailUrl = await adapter.uploadPresetThumbnail(presetId, blob)\n        if (thumbnailUrl) {\n          emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl })\n        }\n      }, 'image/png')\n    },\n    [gl, scene, adapter],\n  )\n\n  useEffect(() => {\n    emitter.on('preset:generate-thumbnail', generate)\n    return () => emitter.off('preset:generate-thumbnail', generate)\n  }, [generate])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/selection-manager.tsx",
    "content": "import {\n  type AnyNode,\n  type AnyNodeId,\n  type BuildingNode,\n  emitter,\n  type ItemNode,\n  type NodeEvent,\n  resolveLevelId,\n  sceneRegistry,\n  useScene,\n} from '@pascal-app/core'\n\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\nimport { sfxEmitter } from '../../lib/sfx-bus'\nimport useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'\nimport { boxSelectHandled } from '../tools/select/box-select-tool'\n\nconst isNodeInCurrentLevel = (node: AnyNode): boolean => {\n  const currentLevelId = useViewer.getState().selection.levelId\n  if (!currentLevelId) return true // No level selected, allow all\n  const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)\n  return nodeLevelId === currentLevelId\n}\n\ntype SelectableNodeType =\n  | 'wall'\n  | 'item'\n  | 'building'\n  | 'zone'\n  | 'slab'\n  | 'ceiling'\n  | 'roof'\n  | 'roof-segment'\n  | 'stair'\n  | 'stair-segment'\n  | 'window'\n  | 'door'\n\ntype ModifierKeys = {\n  meta: boolean\n  ctrl: boolean\n}\n\ninterface SelectionStrategy {\n  types: SelectableNodeType[]\n  handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void\n  handleDeselect: () => void\n  isValid: (node: AnyNode) => boolean\n}\n\ntype SelectionTarget = {\n  phase: Phase\n  structureLayer?: StructureLayer\n}\n\nexport const resolveBuildingId = (\n  levelId: string,\n  nodes: Record<string, AnyNode>,\n): string | null => {\n  const level = nodes[levelId]\n  if (!level) return null\n  if (level.parentId && nodes[level.parentId]?.type === 'building') {\n    return level.parentId\n  }\n  return null\n}\n\nconst computeNextIds = (\n  node: AnyNode,\n  selectedIds: string[],\n  event?: any,\n  modifierKeys?: ModifierKeys,\n): string[] => {\n  const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta\n  const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl\n\n  if (isMeta || isCtrl) {\n    if (selectedIds.includes(node.id)) {\n      return selectedIds.filter((id) => id !== node.id)\n    }\n    return [...selectedIds, node.id]\n  }\n\n  // Not holding modifiers: select only this node\n  return [node.id]\n}\n\nconst SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {\n  site: {\n    types: ['building'],\n    handleSelect: (node) => {\n      useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })\n    },\n    handleDeselect: () => {\n      useViewer.getState().setSelection({ buildingId: null })\n    },\n    isValid: (node) => node.type === 'building',\n  },\n\n  structure: {\n    types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],\n    handleSelect: (node, nativeEvent, modifierKeys) => {\n      const { selection, setSelection } = useViewer.getState()\n      const nodes = useScene.getState().nodes\n      const nodeLevelId = resolveLevelId(node, nodes)\n      const buildingId = resolveBuildingId(nodeLevelId, nodes)\n\n      const updates: any = {}\n      if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {\n        updates.levelId = nodeLevelId\n      }\n      if (buildingId && buildingId !== selection.buildingId) {\n        updates.buildingId = buildingId\n      }\n\n      if (node.type === 'zone') {\n        updates.zoneId = node.id\n        // Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.\n        // Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.\n        setSelection(updates)\n      } else {\n        updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)\n        setSelection(updates)\n      }\n    },\n    handleDeselect: () => {\n      const structureLayer = useEditor.getState().structureLayer\n      if (structureLayer === 'zones') {\n        useViewer.getState().setSelection({ zoneId: null })\n      } else {\n        useViewer.getState().setSelection({ selectedIds: [] })\n      }\n    },\n    isValid: (node) => {\n      if (!isNodeInCurrentLevel(node)) return false\n      const structureLayer = useEditor.getState().structureLayer\n      if (structureLayer === 'zones') {\n        if (node.type === 'zone') return true\n        return false\n      }\n      if (\n        node.type === 'wall' ||\n        node.type === 'slab' ||\n        node.type === 'ceiling' ||\n        node.type === 'roof' ||\n        node.type === 'roof-segment'\n      )\n        return true\n      if (node.type === 'item') {\n        return (\n          (node as ItemNode).asset.category === 'door' ||\n          (node as ItemNode).asset.category === 'window'\n        )\n      }\n      if (node.type === 'window' || node.type === 'door') return true\n\n      return false\n    },\n  },\n\n  furnish: {\n    types: ['item'],\n    handleSelect: (node, nativeEvent, modifierKeys) => {\n      const { selection, setSelection } = useViewer.getState()\n      const nodes = useScene.getState().nodes\n      const nodeLevelId = resolveLevelId(node, nodes)\n      const buildingId = resolveBuildingId(nodeLevelId, nodes)\n\n      const updates: any = {}\n      if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {\n        updates.levelId = nodeLevelId\n      }\n      if (buildingId && buildingId !== selection.buildingId) {\n        updates.buildingId = buildingId\n      }\n\n      updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)\n      setSelection(updates)\n    },\n    handleDeselect: () => {\n      useViewer.getState().setSelection({ selectedIds: [] })\n    },\n    isValid: (node) => {\n      if (!isNodeInCurrentLevel(node)) return false\n      if (node.type !== 'item') return false\n      const item = node as ItemNode\n      return item.asset.category !== 'door' && item.asset.category !== 'window'\n    },\n  },\n}\n\nconst getSelectionTarget = (node: AnyNode): SelectionTarget | null => {\n  if (node.type === 'zone') {\n    return {\n      phase: 'structure',\n      structureLayer: 'zones',\n    }\n  }\n\n  if (\n    node.type === 'wall' ||\n    node.type === 'slab' ||\n    node.type === 'ceiling' ||\n    node.type === 'roof' ||\n    node.type === 'roof-segment' ||\n    node.type === 'window' ||\n    node.type === 'door'\n  ) {\n    return {\n      phase: 'structure',\n      structureLayer: 'elements',\n    }\n  }\n\n  if (node.type === 'item') {\n    const item = node as ItemNode\n    if (item.asset.category === 'door' || item.asset.category === 'window') {\n      return {\n        phase: 'structure',\n        structureLayer: 'elements',\n      }\n    }\n\n    return {\n      phase: 'furnish',\n    }\n  }\n\n  return null\n}\n\nexport const SelectionManager = () => {\n  const phase = useEditor((s) => s.phase)\n  const mode = useEditor((s) => s.mode)\n  const modifierKeysRef = useRef<ModifierKeys>({\n    meta: false,\n    ctrl: false,\n  })\n  const clickHandledRef = useRef(false)\n\n  const movingNode = useEditor((s) => s.movingNode)\n\n  useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Meta') modifierKeysRef.current.meta = true\n      if (event.key === 'Control') modifierKeysRef.current.ctrl = true\n    }\n\n    const onKeyUp = (event: KeyboardEvent) => {\n      if (event.key === 'Meta') modifierKeysRef.current.meta = false\n      if (event.key === 'Control') modifierKeysRef.current.ctrl = false\n    }\n\n    const clearModifiers = () => {\n      modifierKeysRef.current.meta = false\n      modifierKeysRef.current.ctrl = false\n    }\n\n    window.addEventListener('keydown', onKeyDown)\n    window.addEventListener('keyup', onKeyUp)\n    window.addEventListener('blur', clearModifiers)\n\n    return () => {\n      window.removeEventListener('keydown', onKeyDown)\n      window.removeEventListener('keyup', onKeyUp)\n      window.removeEventListener('blur', clearModifiers)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (mode !== 'select') return\n    if (movingNode) return\n\n    const onClick = (event: NodeEvent) => {\n      // Skip if box-select just completed (drag ended over a node)\n      if (boxSelectHandled) return\n\n      const node = event.node\n      let currentPhase = useEditor.getState().phase\n      let currentStructureLayer = useEditor.getState().structureLayer\n\n      // Auto-switch between zones, structure, and furnish when clicking elements on the same level.\n      if (currentPhase === 'structure' || currentPhase === 'furnish') {\n        if (isNodeInCurrentLevel(node)) {\n          const target = getSelectionTarget(node)\n          if (target) {\n            if (target.phase !== currentPhase) {\n              useEditor.getState().setPhase(target.phase)\n              currentPhase = target.phase\n            }\n\n            if (\n              target.phase === 'structure' &&\n              target.structureLayer &&\n              target.structureLayer !== currentStructureLayer\n            ) {\n              useEditor.getState().setStructureLayer(target.structureLayer)\n              currentStructureLayer = target.structureLayer\n            }\n          }\n        }\n      }\n\n      const activeStrategy = SELECTION_STRATEGIES[currentPhase]\n      if (activeStrategy?.isValid(node)) {\n        event.stopPropagation()\n        clickHandledRef.current = true\n\n        let nodeToSelect = node\n        if (node.type === 'roof-segment' && node.parentId) {\n          const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]\n          if (parentNode && parentNode.type === 'roof') {\n            nodeToSelect = parentNode\n          }\n        }\n\n        activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)\n\n        // Reset the handled flag after a short delay to allow grid:click to be ignored\n        setTimeout(() => {\n          clickHandledRef.current = false\n        }, 50)\n      }\n    }\n\n    const allTypes = [\n      'wall',\n      'item',\n      'building',\n      'zone',\n      'slab',\n      'ceiling',\n      'roof',\n      'roof-segment',\n      'window',\n      'door',\n    ]\n    allTypes.forEach((type) => {\n      emitter.on(`${type}:click` as any, onClick as any)\n    })\n\n    const onGridClick = () => {\n      if (clickHandledRef.current) return\n      if (boxSelectHandled) return\n      const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]\n      if (activeStrategy) activeStrategy.handleDeselect()\n    }\n    emitter.on('grid:click', onGridClick)\n\n    return () => {\n      allTypes.forEach((type) => {\n        emitter.off(`${type}:click` as any, onClick as any)\n      })\n      emitter.off('grid:click', onGridClick)\n    }\n  }, [mode, movingNode])\n\n  // Global double-click handler for auto-switching phases and cross-phase hover\n  useEffect(() => {\n    if (mode !== 'select') return\n    if (movingNode) return\n\n    const onEnter = (event: NodeEvent) => {\n      const node = event.node\n      const currentPhase = useEditor.getState().phase\n\n      // Ignore site/building if we are already inside a building\n      if (node.type === 'building' || node.type === 'site') {\n        if (currentPhase === 'structure' || currentPhase === 'furnish') {\n          return\n        }\n      }\n\n      // Ignore zones unless specifically in zones layer\n      if (node.type === 'zone') {\n        if (currentPhase !== 'structure' || useEditor.getState().structureLayer !== 'zones') {\n          return\n        }\n      }\n\n      // Check level constraint for interior nodes\n      if (currentPhase === 'structure' || currentPhase === 'furnish') {\n        if (!isNodeInCurrentLevel(node)) return\n      }\n\n      event.stopPropagation()\n      useViewer.setState({ hoveredId: node.id })\n    }\n\n    const onLeave = (event: NodeEvent) => {\n      const nodeId = event?.node?.id\n      if (nodeId && useViewer.getState().hoveredId === nodeId) {\n        useViewer.setState({ hoveredId: null })\n      }\n    }\n\n    const onDoubleClick = (event: NodeEvent) => {\n      const node = event.node\n      const currentPhase = useEditor.getState().phase\n\n      let targetPhase: 'site' | 'structure' | 'furnish' | null = null\n      let forceSelect = false\n\n      if (node.type === 'building' || node.type === 'site') {\n        if (currentPhase === 'structure' || currentPhase === 'furnish') {\n          return // Ignore building/site double clicks if we are already inside a building\n        }\n        if (node.type === 'building') {\n          targetPhase = 'structure'\n        }\n      } else if (\n        node.type === 'wall' ||\n        node.type === 'slab' ||\n        node.type === 'ceiling' ||\n        node.type === 'roof' ||\n        node.type === 'roof-segment' ||\n        node.type === 'window' ||\n        node.type === 'door'\n      ) {\n        targetPhase = 'structure'\n        if (node.type === 'roof-segment' && currentPhase === 'structure') {\n          forceSelect = true // allow double click to dive into roof-segment even if already in structure phase\n        }\n      } else if (node.type === 'item') {\n        const item = node as ItemNode\n        if (item.asset.category === 'door' || item.asset.category === 'window') {\n          targetPhase = 'structure'\n        } else {\n          targetPhase = 'furnish'\n        }\n      }\n\n      if (node.type === 'zone') {\n        return\n      }\n\n      if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {\n        event.stopPropagation()\n\n        if (targetPhase && targetPhase !== useEditor.getState().phase) {\n          useEditor.getState().setPhase(targetPhase)\n        }\n\n        if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {\n          useEditor.getState().setStructureLayer('elements')\n        }\n\n        const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]\n        if (strategy) {\n          strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)\n        }\n      }\n    }\n\n    const allTypes = [\n      'wall',\n      'item',\n      'building',\n      'slab',\n      'ceiling',\n      'roof',\n      'roof-segment',\n      'window',\n      'door',\n      'zone',\n      'site',\n    ]\n    allTypes.forEach((type) => {\n      emitter.on(`${type}:enter` as any, onEnter as any)\n      emitter.on(`${type}:leave` as any, onLeave as any)\n      emitter.on(`${type}:double-click` as any, onDoubleClick as any)\n    })\n\n    return () => {\n      allTypes.forEach((type) => {\n        emitter.off(`${type}:enter` as any, onEnter as any)\n        emitter.off(`${type}:leave` as any, onLeave as any)\n        emitter.off(`${type}:double-click` as any, onDoubleClick as any)\n      })\n    }\n  }, [mode, movingNode])\n\n  // Delete mode: click-to-delete (sledgehammer tool)\n  useEffect(() => {\n    if (mode !== 'delete') return\n\n    const onClick = (event: NodeEvent) => {\n      const node = event.node\n      if (!isNodeInCurrentLevel(node)) return\n\n      event.stopPropagation()\n\n      // Play appropriate SFX\n      if (node.type === 'item') {\n        sfxEmitter.emit('sfx:item-delete')\n      } else {\n        sfxEmitter.emit('sfx:structure-delete')\n      }\n\n      useScene.getState().deleteNode(node.id as AnyNodeId)\n      if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)\n\n      // Clear hover since the node is gone\n      if (useViewer.getState().hoveredId === node.id) {\n        useViewer.setState({ hoveredId: null })\n      }\n    }\n\n    const onEnter = (event: NodeEvent) => {\n      const node = event.node\n      if (!isNodeInCurrentLevel(node)) return\n      if (node.type === 'building' || node.type === 'site') return\n      event.stopPropagation()\n      useViewer.setState({ hoveredId: node.id })\n    }\n\n    const onLeave = (event: NodeEvent) => {\n      const nodeId = event?.node?.id\n      if (nodeId && useViewer.getState().hoveredId === nodeId) {\n        useViewer.setState({ hoveredId: null })\n      }\n    }\n\n    const allTypes = [\n      'wall',\n      'item',\n      'slab',\n      'ceiling',\n      'roof',\n      'roof-segment',\n      'window',\n      'door',\n      'zone',\n    ] as const\n\n    for (const type of allTypes) {\n      emitter.on(`${type}:click` as any, onClick as any)\n      emitter.on(`${type}:enter` as any, onEnter as any)\n      emitter.on(`${type}:leave` as any, onLeave as any)\n    }\n\n    return () => {\n      for (const type of allTypes) {\n        emitter.off(`${type}:click` as any, onClick as any)\n        emitter.off(`${type}:enter` as any, onEnter as any)\n        emitter.off(`${type}:leave` as any, onLeave as any)\n      }\n      useViewer.setState({ hoveredId: null })\n    }\n  }, [mode])\n\n  return (\n    <>\n      <SelectionStateSync />\n      <EditorOutlinerSync />\n    </>\n  )\n}\n\nconst SelectionStateSync = () => {\n  useEffect(() => {\n    return useScene.subscribe((state) => {\n      const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection\n\n      if (buildingId && !state.nodes[buildingId as AnyNodeId]) {\n        useViewer.getState().setSelection({ buildingId: null })\n        return\n      }\n\n      if (levelId && !state.nodes[levelId as AnyNodeId]) {\n        useViewer.getState().setSelection({ levelId: null })\n        return\n      }\n\n      if (zoneId && !state.nodes[zoneId as AnyNodeId]) {\n        useViewer.getState().setSelection({ zoneId: null })\n        return\n      }\n\n      if (selectedIds.length === 0) return\n\n      const nextSelectedIds = selectedIds.filter((id) => state.nodes[id as AnyNodeId])\n      if (nextSelectedIds.length !== selectedIds.length) {\n        useViewer.getState().setSelection({ selectedIds: nextSelectedIds })\n      }\n    })\n  }, [])\n\n  return null\n}\n\nconst EditorOutlinerSync = () => {\n  const phase = useEditor((s) => s.phase)\n  const selection = useViewer((s) => s.selection)\n  const hoveredId = useViewer((s) => s.hoveredId)\n  const outliner = useViewer((s) => s.outliner)\n\n  useEffect(() => {\n    let idsToHighlight: string[] = []\n\n    // 1. Determine what should be highlighted based on Phase\n    switch (phase) {\n      case 'site':\n        // Only highlight the building if one is selected\n        if (selection.buildingId) idsToHighlight = [selection.buildingId]\n        break\n\n      case 'structure':\n        // Highlight selected items (walls/slabs)\n        // We IGNORE buildingId even if it's set in the store\n        idsToHighlight = selection.selectedIds\n        break\n\n      case 'furnish':\n        // Highlight selected furniture/items\n        idsToHighlight = selection.selectedIds\n        break\n\n      default:\n        // Pure Viewer mode: Highlight based on the \"deepest\" selection\n        if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds\n        else if (selection.levelId) idsToHighlight = [selection.levelId]\n        else if (selection.buildingId) idsToHighlight = [selection.buildingId]\n    }\n\n    // 2. Sync with the imperative outliner arrays (mutate in place to keep references)\n    outliner.selectedObjects.length = 0\n    for (const id of idsToHighlight) {\n      const obj = sceneRegistry.nodes.get(id)\n      if (obj) outliner.selectedObjects.push(obj)\n    }\n\n    outliner.hoveredObjects.length = 0\n    if (hoveredId) {\n      const obj = sceneRegistry.nodes.get(hoveredId)\n      if (obj) outliner.hoveredObjects.push(obj)\n    }\n  }, [phase, selection, hoveredId, outliner])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/site-edge-labels.tsx",
    "content": "'use client'\n\nimport type { SiteNode } from '@pascal-app/core'\nimport { sceneRegistry, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Html } from '@react-three/drei'\nimport { createPortal, useFrame } from '@react-three/fiber'\nimport { useMemo, useRef, useState } from 'react'\nimport type { Object3D } from 'three'\n\nfunction formatMeasurement(value: number, unit: 'metric' | 'imperial') {\n  if (unit === 'imperial') {\n    const feet = value * 3.280_84\n    const wholeFeet = Math.floor(feet)\n    const inches = Math.round((feet - wholeFeet) * 12)\n    if (inches === 12) return `${wholeFeet + 1}'0\"`\n    return `${wholeFeet}'${inches}\"`\n  }\n  return `${Number.parseFloat(value.toFixed(2))}m`\n}\n\nexport function SiteEdgeLabels() {\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n  const nodes = useScene((state) => state.nodes)\n  const unit = useViewer((state) => state.unit)\n  const theme = useViewer((state) => state.theme)\n\n  const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null\n  const siteNodeId = siteNode?.id\n\n  const isNight = theme === 'dark'\n  const color = isNight ? '#ffffff' : '#111111'\n  const shadowColor = isNight ? '#111111' : '#ffffff'\n\n  const [siteObj, setSiteObj] = useState<Object3D | null>(null)\n  const prevSiteNodeIdRef = useRef<string | undefined>(undefined)\n\n  // Poll each frame until the site group is registered.\n  // Also resets when the site node ID changes (new project loaded).\n  useFrame(() => {\n    if (siteNodeId !== prevSiteNodeIdRef.current) {\n      prevSiteNodeIdRef.current = siteNodeId\n      setSiteObj(null)\n      return\n    }\n    if (siteObj || !siteNodeId) return\n    const obj = sceneRegistry.nodes.get(siteNodeId)\n    if (obj) setSiteObj(obj)\n  })\n\n  const edges = useMemo(() => {\n    const polygon = siteNode?.polygon?.points ?? []\n    if (polygon.length < 2) return []\n    return polygon.map(([x1, z1], i) => {\n      const [x2, z2] = polygon[(i + 1) % polygon.length]!\n      const midX = (x1! + x2) / 2\n      const midZ = (z1! + z2) / 2\n      const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)\n      return { midX, midZ, dist }\n    })\n  }, [siteNode?.polygon?.points])\n\n  if (!siteObj || edges.length === 0) return null\n\n  return createPortal(\n    <>\n      {edges.map((edge, i) => (\n        <Html\n          center\n          key={`edge-${i}`}\n          occlude\n          position={[edge.midX, 0.5, edge.midZ]}\n          style={{ pointerEvents: 'none', userSelect: 'none' }}\n          zIndexRange={[10, 0]}\n        >\n          <div\n            className=\"whitespace-nowrap font-bold font-mono text-[15px]\"\n            style={{\n              color,\n              textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,\n            }}\n          >\n            {formatMeasurement(edge.dist, unit)}\n          </div>\n        </Html>\n      ))}\n    </>,\n    siteObj,\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/thumbnail-generator.tsx",
    "content": "'use client'\n\nimport { emitter, sceneRegistry, useScene } from '@pascal-app/core'\nimport { snapLevelsToTruePositions } from '@pascal-app/viewer'\nimport { useThree } from '@react-three/fiber'\nimport { useCallback, useEffect, useRef } from 'react'\nimport * as THREE from 'three'\nimport { EDITOR_LAYER } from '../../lib/constants'\n\nconst THUMBNAIL_WIDTH = 1920\nconst THUMBNAIL_HEIGHT = 1080\nconst AUTO_SAVE_DELAY = 10_000\n\ninterface ThumbnailGeneratorProps {\n  onThumbnailCapture?: (blob: Blob) => void\n}\n\nexport const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {\n  const gl = useThree((state) => state.gl)\n  const scene = useThree((state) => state.scene)\n  const isGenerating = useRef(false)\n  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const pendingAutoRef = useRef(false)\n  const onThumbnailCaptureRef = useRef(onThumbnailCapture)\n\n  useEffect(() => {\n    onThumbnailCaptureRef.current = onThumbnailCapture\n  }, [onThumbnailCapture])\n\n  const generate = useCallback(async () => {\n    if (isGenerating.current) return\n    if (!onThumbnailCaptureRef.current) return\n\n    isGenerating.current = true\n\n    try {\n      const thumbnailCamera = new THREE.PerspectiveCamera(\n        60,\n        THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,\n        0.1,\n        1000,\n      )\n\n      const nodes = useScene.getState().nodes\n      const siteNode = Object.values(nodes).find((n) => n.type === 'site')\n\n      if (siteNode?.camera) {\n        const { position, target } = siteNode.camera\n        thumbnailCamera.position.set(position[0], position[1], position[2])\n        thumbnailCamera.lookAt(target[0], target[1], target[2])\n      } else {\n        thumbnailCamera.position.set(8, 8, 8)\n        thumbnailCamera.lookAt(0, 0, 0)\n      }\n      thumbnailCamera.layers.disable(EDITOR_LAYER)\n\n      const { width, height } = gl.domElement\n      thumbnailCamera.aspect = width / height\n      thumbnailCamera.updateProjectionMatrix()\n\n      const restoreLevels = snapLevelsToTruePositions()\n\n      const visibilitySnapshot = new Map<string, boolean>()\n      for (const type of ['scan', 'guide'] as const) {\n        sceneRegistry.byType[type].forEach((id) => {\n          const obj = sceneRegistry.nodes.get(id)\n          if (obj) {\n            visibilitySnapshot.set(id, obj.visible)\n            obj.visible = false\n          }\n        })\n      }\n\n      gl.render(scene, thumbnailCamera)\n\n      restoreLevels()\n      visibilitySnapshot.forEach((wasVisible, id) => {\n        const obj = sceneRegistry.nodes.get(id)\n        if (obj) obj.visible = wasVisible\n      })\n\n      const srcAspect = width / height\n      const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT\n      let sx = 0,\n        sy = 0,\n        sWidth = width,\n        sHeight = height\n      if (srcAspect > dstAspect) {\n        sWidth = Math.round(height * dstAspect)\n        sx = Math.round((width - sWidth) / 2)\n      } else if (srcAspect < dstAspect) {\n        sHeight = Math.round(width / dstAspect)\n        sy = Math.round((height - sHeight) / 2)\n      }\n\n      const offscreen = document.createElement('canvas')\n      offscreen.width = THUMBNAIL_WIDTH\n      offscreen.height = THUMBNAIL_HEIGHT\n      const ctx = offscreen.getContext('2d')!\n      ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)\n\n      offscreen.toBlob((blob) => {\n        if (blob) {\n          onThumbnailCaptureRef.current?.(blob)\n        } else {\n          console.error('❌ Failed to create blob from canvas')\n        }\n        isGenerating.current = false\n      }, 'image/png')\n    } catch (error) {\n      console.error('❌ Failed to generate thumbnail:', error)\n      isGenerating.current = false\n    }\n  }, [gl, scene])\n\n  // Manual trigger via emitter\n  useEffect(() => {\n    const handleGenerateThumbnail = async () => {\n      await generate()\n    }\n\n    emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)\n    return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)\n  }, [generate])\n\n  // Auto-trigger: debounced on scene changes, deferred if tab is hidden\n  useEffect(() => {\n    if (!onThumbnailCapture) return\n\n    const triggerNow = () => generate()\n\n    const scheduleOrDefer = () => {\n      if (document.visibilityState === 'visible') {\n        triggerNow()\n      } else {\n        pendingAutoRef.current = true\n      }\n    }\n\n    const onSceneChange = () => {\n      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)\n      debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)\n    }\n\n    const onVisibilityChange = () => {\n      if (document.visibilityState === 'visible' && pendingAutoRef.current) {\n        pendingAutoRef.current = false\n        triggerNow()\n      }\n    }\n\n    const unsubscribe = useScene.subscribe((state, prevState) => {\n      if (state.nodes !== prevState.nodes) onSceneChange()\n    })\n\n    document.addEventListener('visibilitychange', onVisibilityChange)\n\n    return () => {\n      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)\n      unsubscribe()\n      document.removeEventListener('visibilitychange', onVisibilityChange)\n    }\n  }, [onThumbnailCapture, generate])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor/wall-measurement-label.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNodeId,\n  calculateLevelMiters,\n  DEFAULT_WALL_HEIGHT,\n  getWallPlanFootprint,\n  type Point2D,\n  pointToKey,\n  sceneRegistry,\n  useScene,\n  type WallMiterData,\n  type WallNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Html } from '@react-three/drei'\nimport { createPortal, useFrame } from '@react-three/fiber'\nimport { useEffect, useMemo, useState } from 'react'\nimport * as THREE from 'three'\n\nconst GUIDE_Y_OFFSET = 0.08\nconst LABEL_LIFT = 0.08\nconst BAR_THICKNESS = 0.012\nconst LINE_OPACITY = 0.95\n\nconst BAR_AXIS = new THREE.Vector3(0, 1, 0)\n\ntype Vec3 = [number, number, number]\n\ntype MeasurementGuide = {\n  guideStart: Vec3\n  guideEnd: Vec3\n  extStartStart: Vec3\n  extStartEnd: Vec3\n  extEndStart: Vec3\n  extEndEnd: Vec3\n  labelPosition: Vec3\n}\n\nfunction formatMeasurement(value: number, unit: 'metric' | 'imperial') {\n  if (unit === 'imperial') {\n    const feet = value * 3.280_84\n    const wholeFeet = Math.floor(feet)\n    const inches = Math.round((feet - wholeFeet) * 12)\n    if (inches === 12) return `${wholeFeet + 1}'0\"`\n    return `${wholeFeet}'${inches}\"`\n  }\n  return `${Number.parseFloat(value.toFixed(2))}m`\n}\n\nexport function WallMeasurementLabel() {\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const nodes = useScene((state) => state.nodes)\n\n  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null\n  const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null\n  const wall = selectedNode?.type === 'wall' ? selectedNode : null\n\n  const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes\n  useEffect(() => {\n    setWallObject(null)\n  }, [selectedId])\n\n  useFrame(() => {\n    if (!selectedId || wallObject) return\n\n    const nextWallObject = sceneRegistry.nodes.get(selectedId)\n    if (nextWallObject) {\n      setWallObject(nextWallObject)\n    }\n  })\n\n  if (!(wall && wallObject)) return null\n\n  return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)\n}\n\nfunction getLevelWalls(\n  wall: WallNode,\n  nodes: Record<string, WallNode | { type: string; children?: string[] }>,\n): WallNode[] {\n  if (!wall.parentId) return [wall]\n\n  const levelNode = nodes[wall.parentId as AnyNodeId]\n  if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) {\n    return [wall]\n  }\n\n  return levelNode.children\n    .map((childId) => nodes[childId as AnyNodeId])\n    .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))\n}\n\nfunction getWallMiddlePoints(\n  wall: WallNode,\n  miterData: WallMiterData,\n): { start: Point2D; end: Point2D } | null {\n  const footprint = getWallPlanFootprint(wall, miterData)\n  if (footprint.length < 4) return null\n\n  const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })\n  const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)\n\n  const rightStart = footprint[0]\n  const rightEnd = footprint[1]\n  const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2]\n  const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1]\n\n  if (!(leftStart && leftEnd && rightStart && rightEnd)) return null\n\n  return {\n    start: {\n      x: (leftStart.x + rightStart.x) / 2,\n      y: (leftStart.y + rightStart.y) / 2,\n    },\n    end: {\n      x: (leftEnd.x + rightEnd.x) / 2,\n      y: (leftEnd.y + rightEnd.y) / 2,\n    },\n  }\n}\n\nfunction worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {\n  const dx = point.x - wall.start[0]\n  const dz = point.y - wall.start[1]\n  const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])\n  const cosA = Math.cos(-angle)\n  const sinA = Math.sin(-angle)\n\n  return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]\n}\n\nfunction buildMeasurementGuide(\n  wall: WallNode,\n  nodes: Record<string, WallNode | { type: string; children?: string[] }>,\n): MeasurementGuide | null {\n  const levelWalls = getLevelWalls(wall, nodes)\n  const miterData = calculateLevelMiters(levelWalls)\n  const middlePoints = getWallMiddlePoints(wall, miterData)\n  if (!middlePoints) return null\n\n  const height = wall.height ?? DEFAULT_WALL_HEIGHT\n  const startLocal = worldPointToWallLocal(wall, middlePoints.start)\n  const endLocal = worldPointToWallLocal(wall, middlePoints.end)\n\n  const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]]\n  const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]\n\n  const dirX = guideEnd[0] - guideStart[0]\n  const dirZ = guideEnd[2] - guideStart[2]\n  const dirLength = Math.hypot(dirX, dirZ)\n\n  if (!Number.isFinite(dirLength) || dirLength < 0.001) return null\n\n  // Extension lines coming out of the extremity markers of the wall\n  const extOvershoot = 0.04\n\n  return {\n    guideStart,\n    guideEnd,\n    extStartStart: [startLocal[0], height, startLocal[2]],\n    extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],\n    extEndStart: [endLocal[0], height, endLocal[2]],\n    extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],\n    labelPosition: [\n      (guideStart[0] + guideEnd[0]) / 2,\n      guideStart[1] + LABEL_LIFT,\n      (guideStart[2] + guideEnd[2]) / 2,\n    ],\n  }\n}\n\nfunction MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {\n  const segment = useMemo(() => {\n    const startVector = new THREE.Vector3(...start)\n    const endVector = new THREE.Vector3(...end)\n    const direction = endVector.clone().sub(startVector)\n    const length = direction.length()\n\n    if (!Number.isFinite(length) || length < 0.0001) return null\n\n    return {\n      length,\n      position: startVector.clone().add(endVector).multiplyScalar(0.5),\n      quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()),\n    }\n  }, [end, start])\n\n  if (!segment) return null\n\n  return (\n    <mesh\n      position={[segment.position.x, segment.position.y, segment.position.z]}\n      quaternion={segment.quaternion}\n      renderOrder={1000}\n    >\n      <boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />\n      <meshBasicMaterial\n        color={color}\n        depthTest={false}\n        depthWrite={false}\n        opacity={LINE_OPACITY}\n        toneMapped={false}\n        transparent\n      />\n    </mesh>\n  )\n}\n\nfunction WallMeasurementAnnotation({ wall }: { wall: WallNode }) {\n  const nodes = useScene((state) => state.nodes)\n  const theme = useViewer((state) => state.theme)\n  const unit = useViewer((state) => state.unit)\n  const isNight = theme === 'dark'\n  const color = isNight ? '#ffffff' : '#111111'\n  const shadowColor = isNight ? '#111111' : '#ffffff'\n\n  const dx = wall.end[0] - wall.start[0]\n  const dz = wall.end[1] - wall.start[1]\n  const length = Math.hypot(dx, dz)\n  const label = formatMeasurement(length, unit)\n  const guide = useMemo(\n    () =>\n      buildMeasurementGuide(\n        wall,\n        nodes as Record<string, WallNode | { type: string; children?: string[] }>,\n      ),\n    [nodes, wall],\n  )\n\n  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null\n\n  return (\n    <group>\n      <MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} />\n      <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />\n      <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />\n\n      <Html\n        center\n        position={guide.labelPosition}\n        style={{ pointerEvents: 'none', userSelect: 'none' }}\n        zIndexRange={[20, 0]}\n      >\n        <div\n          className=\"whitespace-nowrap font-bold font-mono text-[15px]\"\n          style={{\n            color,\n            textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,\n          }}\n        >\n          {label}\n        </div>\n      </Html>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/feedback-dialog.tsx",
    "content": "'use client'\n\nimport { useScene } from '@pascal-app/core'\nimport { ImageIcon, MessageSquare, X } from 'lucide-react'\nimport { useCallback, useRef, useState } from 'react'\nimport { Button } from './ui/primitives/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from './ui/primitives/dialog'\n\nconst MAX_IMAGES = 5\nconst MAX_IMAGE_SIZE = 5 * 1024 * 1024\n\ntype ImagePreview = { file: File; url: string }\n\nexport function FeedbackDialog({\n  projectId: projectIdProp,\n  onSubmit,\n}: {\n  projectId?: string\n  onSubmit?: (data: {\n    message: string\n    projectId?: string\n    sceneGraph: unknown\n    images: File[]\n  }) => Promise<{ success: boolean; error?: string }>\n}) {\n  const projectId = projectIdProp\n\n  const [open, setOpen] = useState(false)\n  const [message, setMessage] = useState('')\n  const [images, setImages] = useState<ImagePreview[]>([])\n  const [isDragging, setIsDragging] = useState(false)\n  const [isSubmitting, setIsSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [sent, setSent] = useState(false)\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const dragCounter = useRef(0)\n\n  const handleOpen = () => {\n    setOpen(true)\n    setSent(false)\n    setError(null)\n    setMessage('')\n    setImages([])\n    setIsDragging(false)\n    dragCounter.current = 0\n  }\n\n  const handleClose = () => {\n    if (isSubmitting) return\n    setOpen(false)\n    images.forEach((img) => {\n      URL.revokeObjectURL(img.url)\n    })\n  }\n\n  const addFiles = useCallback((files: FileList | File[]) => {\n    const incoming = Array.from(files).filter(\n      (f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,\n    )\n    setImages((prev) => {\n      const remaining = MAX_IMAGES - prev.length\n      const added = incoming.slice(0, remaining).map((file) => ({\n        file,\n        url: URL.createObjectURL(file),\n      }))\n      return [...prev, ...added]\n    })\n  }, [])\n\n  const removeImage = (index: number) => {\n    setImages((prev) => {\n      const img = prev[index]\n      if (img) URL.revokeObjectURL(img.url)\n      return prev.filter((_, i) => i !== index)\n    })\n  }\n\n  // ── Drag handlers (on the entire dialog content) ──\n  const onDragEnter = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    dragCounter.current++\n    if (e.dataTransfer.types.includes('Files')) {\n      setIsDragging(true)\n    }\n  }\n\n  const onDragLeave = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    dragCounter.current--\n    if (dragCounter.current === 0) {\n      setIsDragging(false)\n    }\n  }\n\n  const onDragOver = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n  }\n\n  const onDrop = (e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    dragCounter.current = 0\n    setIsDragging(false)\n    if (e.dataTransfer.files.length > 0) {\n      addFiles(e.dataTransfer.files)\n    }\n  }\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError(null)\n    setIsSubmitting(true)\n\n    try {\n      if (!onSubmit) return\n      const { nodes, rootNodeIds } = useScene.getState()\n      const sceneGraph = { nodes, rootNodeIds }\n      const result = await onSubmit({\n        message,\n        projectId,\n        sceneGraph,\n        images: images.map((img) => img.file),\n      })\n      if (result.success) {\n        setSent(true)\n        setTimeout(() => setOpen(false), 1500)\n      } else {\n        setError(result.error ?? 'Something went wrong')\n      }\n    } finally {\n      setIsSubmitting(false)\n    }\n  }\n\n  return (\n    <>\n      <button\n        className=\"flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90\"\n        onClick={handleOpen}\n      >\n        <MessageSquare className=\"h-4 w-4\" />\n        Feedback\n      </button>\n\n      <Dialog onOpenChange={handleClose} open={open}>\n        <DialogContent\n          className=\"sm:max-w-[460px]\"\n          onDragEnter={onDragEnter}\n          onDragLeave={onDragLeave}\n          onDragOver={onDragOver}\n          onDrop={onDrop}\n        >\n          {/* Drag overlay — only visible when dragging files over the dialog */}\n          {isDragging && (\n            <div className=\"absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all\">\n              <div className=\"flex flex-col items-center gap-2 text-primary/70\">\n                <ImageIcon className=\"h-8 w-8\" />\n                <p className=\"font-medium text-sm\">Drop images here</p>\n              </div>\n            </div>\n          )}\n\n          <DialogHeader>\n            <DialogTitle>Send Feedback</DialogTitle>\n            <DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>\n          </DialogHeader>\n\n          {sent ? (\n            <p className=\"py-4 text-center text-muted-foreground text-sm\">\n              Thanks for your feedback!\n            </p>\n          ) : (\n            <form className=\"space-y-4\" onSubmit={handleSubmit}>\n              <div>\n                <label className=\"font-medium text-sm\" htmlFor=\"feedback-message\">\n                  Your feedback\n                </label>\n                <textarea\n                  autoFocus\n                  className=\"mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary\"\n                  disabled={isSubmitting}\n                  id=\"feedback-message\"\n                  onChange={(e) => setMessage(e.target.value)}\n                  placeholder=\"Share your thoughts, suggestions, feature requests, or report issues...\"\n                  rows={5}\n                  value={message}\n                />\n              </div>\n\n              {/* Image thumbnails */}\n              {images.length > 0 && (\n                <div className=\"flex flex-wrap gap-2\">\n                  {images.map((img, i) => (\n                    <div\n                      className=\"group relative h-14 w-14 overflow-hidden rounded-md border border-border\"\n                      key={img.url}\n                    >\n                      <img alt=\"\" className=\"h-full w-full object-cover\" src={img.url} />\n                      <button\n                        className=\"absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100\"\n                        onClick={() => removeImage(i)}\n                        type=\"button\"\n                      >\n                        <X className=\"h-4 w-4 text-white\" />\n                      </button>\n                    </div>\n                  ))}\n                </div>\n              )}\n\n              {error && <p className=\"text-destructive text-sm\">{error}</p>}\n\n              <div className=\"flex items-center justify-between\">\n                {/* Subtle attach button */}\n                <button\n                  className=\"flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40\"\n                  disabled={isSubmitting || images.length >= MAX_IMAGES}\n                  onClick={() => fileInputRef.current?.click()}\n                  type=\"button\"\n                >\n                  <ImageIcon className=\"h-3.5 w-3.5\" />\n                  {images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}\n                </button>\n                <input\n                  accept=\"image/*\"\n                  className=\"hidden\"\n                  multiple\n                  onChange={(e) => {\n                    if (e.target.files) addFiles(e.target.files)\n                    e.target.value = ''\n                  }}\n                  ref={fileInputRef}\n                  type=\"file\"\n                />\n\n                <div className=\"flex gap-2\">\n                  <Button\n                    disabled={isSubmitting}\n                    onClick={handleClose}\n                    type=\"button\"\n                    variant=\"outline\"\n                  >\n                    Cancel\n                  </Button>\n                  <Button disabled={isSubmitting || !message.trim() || !onSubmit} type=\"submit\">\n                    {isSubmitting ? 'Sending...' : 'Send Feedback'}\n                  </Button>\n                </div>\n              </div>\n            </form>\n          )}\n        </DialogContent>\n      </Dialog>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/pascal-radio.tsx",
    "content": "'use client'\n\nimport { Howl } from 'howler'\nimport { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'\nimport { AnimatePresence, motion } from 'motion/react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Slider } from '../components/ui/slider'\nimport { cn } from '../lib/utils'\nimport useAudio from '../store/use-audio'\n\nconst PLAYLIST = [\n  {\n    title: 'Ballroom in Miniature',\n    file: '/audios/radios/classic/Ballroom in Miniature.mp3',\n  },\n  {\n    title: 'Blueprints in Springtime',\n    file: '/audios/radios/classic/Blueprints in Springtime.mp3',\n  },\n  {\n    title: 'Clockwork Tea Party',\n    file: '/audios/radios/classic/Clockwork Tea Party.mp3',\n  },\n  {\n    title: 'Clockwork Tea Party (Alternate)',\n    file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',\n  },\n  {\n    title: 'Clockwork Teacups',\n    file: '/audios/radios/classic/Clockwork Teacups.mp3',\n  },\n  {\n    title: 'Evening in the Parlor',\n    file: '/audios/radios/classic/Evening in the Parlor.mp3',\n  },\n  {\n    title: 'Glass Atrium',\n    file: '/audios/radios/classic/Glass Atrium.mp3',\n  },\n  {\n    title: 'Moonlight On The Drafting Table',\n    file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',\n  },\n  {\n    title: 'Sunlit Garden Reverie',\n    file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',\n  },\n  {\n    title: 'Sunlit Waltz in Pastel Hues',\n    file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',\n  },\n]\n\n// Shuffle array helper\nfunction shuffleArray<T>(array: T[]): T[] {\n  const shuffled = [...array]\n  for (let i = shuffled.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1))\n    ;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]\n  }\n  return shuffled\n}\n\nexport function PascalRadio() {\n  const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))\n  const [currentTrackIndex, setCurrentTrackIndex] = useState(0)\n  const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()\n  const soundRef = useRef<Howl | null>(null)\n  const [isOpen, setIsOpen] = useState(false)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  const currentTrack = shuffledPlaylist[currentTrackIndex]!\n\n  // Calculate effective volume (masterVolume * radioVolume, both are 0-100)\n  const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)\n\n  // Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying\n  // without those values being part of its dependency array (which would restart the song).\n  const effectiveVolumeRef = useRef(effectiveVolume)\n  const mutedRef = useRef(muted)\n  const isPlayingRef = useRef(isRadioPlaying)\n  effectiveVolumeRef.current = effectiveVolume\n  mutedRef.current = muted\n  isPlayingRef.current = isRadioPlaying\n\n  const handleNext = useCallback(() => {\n    setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)\n  }, [shuffledPlaylist.length])\n\n  const handlePrevious = useCallback(() => {\n    setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)\n  }, [shuffledPlaylist.length])\n\n  // Initialize Howler only when the track changes — not on volume/mute/play-state changes.\n  // Volume and mute are handled by the separate effect below.\n  useEffect(() => {\n    if (soundRef.current) {\n      soundRef.current.unload()\n    }\n\n    const wasPlaying = isPlayingRef.current\n\n    soundRef.current = new Howl({\n      src: [currentTrack.file],\n      volume: mutedRef.current ? 0 : effectiveVolumeRef.current,\n      onend: handleNext,\n    })\n\n    if (wasPlaying && !mutedRef.current) {\n      soundRef.current?.play()\n    }\n\n    return () => {\n      soundRef.current?.unload()\n    }\n  }, [handleNext, currentTrack.file])\n\n  // Update volume when settings change\n  useEffect(() => {\n    if (soundRef.current) {\n      soundRef.current.volume(muted ? 0 : effectiveVolume)\n\n      // Pause if muted, resume if unmuted and was playing\n      if (muted && isRadioPlaying) {\n        soundRef.current.pause()\n      } else if (!muted && isRadioPlaying && !soundRef.current.playing()) {\n        soundRef.current.play()\n      } else if (!isRadioPlaying && soundRef.current.playing()) {\n        soundRef.current.pause()\n      }\n    }\n  }, [effectiveVolume, muted, isRadioPlaying])\n\n  const handlePlayPause = () => {\n    if (!soundRef.current || muted) return\n\n    if (isRadioPlaying) {\n      soundRef.current.pause()\n    } else {\n      soundRef.current.play()\n    }\n    setRadioPlaying(!isRadioPlaying)\n  }\n\n  const handleVolumeChange = (value: number[]) => {\n    useAudio.setState({ radioVolume: value[0] })\n  }\n\n  // Handle click outside to close\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    if (isOpen) {\n      document.addEventListener('mousedown', handleClickOutside)\n    }\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [isOpen])\n\n  return (\n    <motion.div\n      className={cn(\n        'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',\n        !isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',\n      )}\n      layout\n      onClick={() => {\n        if (!isOpen) setIsOpen(true)\n      }}\n      ref={containerRef}\n      transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}\n    >\n      <div className=\"flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm\">\n        <div className=\"flex items-center gap-2\">\n          <Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />\n          <span className=\"hidden whitespace-nowrap sm:inline\">Radio Pascal</span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <div\n            aria-label={isRadioPlaying ? 'Pause' : 'Play'}\n            className=\"cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm\"\n            onClick={(e) => {\n              e.stopPropagation()\n              handlePlayPause()\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault()\n                e.stopPropagation()\n                handlePlayPause()\n              }\n            }}\n            role=\"button\"\n            tabIndex={0}\n          >\n            {isRadioPlaying ? (\n              <Volume2 className=\"h-3.5 w-3.5\" />\n            ) : (\n              <VolumeX className=\"h-3.5 w-3.5\" />\n            )}\n          </div>\n          <button\n            aria-label=\"Radio Settings\"\n            className={cn(\n              'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',\n              isOpen && 'bg-accent text-accent-foreground',\n            )}\n            onClick={(e) => {\n              e.stopPropagation()\n              setIsOpen(!isOpen)\n            }}\n          >\n            <Settings2 className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      </div>\n\n      <AnimatePresence>\n        {isOpen && (\n          <motion.div\n            animate={{ opacity: 1, height: 'auto' }}\n            exit={{ opacity: 0, height: 0 }}\n            initial={{ opacity: 0, height: 0 }}\n            transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}\n          >\n            <div className=\"w-[16rem] space-y-3 px-3 pb-3\">\n              <div className=\"mb-3 h-px w-full bg-border/50\" />\n              {/* Current song info with prev/next */}\n              <div>\n                <p className=\"mb-2 text-muted-foreground text-xs\">Now Playing</p>\n                <div className=\"flex items-center justify-between gap-2\">\n                  <button\n                    aria-label=\"Previous\"\n                    className=\"shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent\"\n                    onClick={handlePrevious}\n                  >\n                    <SkipBack className=\"h-4 w-4\" />\n                  </button>\n                  <p\n                    className=\"flex-1 truncate text-center font-medium text-sm\"\n                    title={currentTrack.title}\n                  >\n                    {currentTrack.title}\n                  </p>\n                  <button\n                    aria-label=\"Next\"\n                    className=\"shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent\"\n                    onClick={handleNext}\n                  >\n                    <SkipForward className=\"h-4 w-4\" />\n                  </button>\n                </div>\n              </div>\n\n              {/* Volume control */}\n              <div className=\"flex items-center gap-2\">\n                <Volume2 className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" />\n                <Slider\n                  aria-label=\"Radio Volume\"\n                  className=\"flex-1\"\n                  max={100}\n                  onValueChange={handleVolumeChange}\n                  step={1}\n                  value={[radioVolume]}\n                />\n                <span className=\"w-8 shrink-0 text-right text-muted-foreground text-xs\">\n                  {radioVolume}%\n                </span>\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/preview-button.tsx",
    "content": "'use client'\n\nimport { Eye } from 'lucide-react'\nimport useEditor from '../store/use-editor'\n\nexport function PreviewButton() {\n  return (\n    <button\n      className=\"flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90\"\n      onClick={() => useEditor.getState().setPreviewMode(true)}\n    >\n      <Eye className=\"h-4 w-4 shrink-0\" />\n      <span className=\"hidden whitespace-nowrap sm:inline\">Preview</span>\n    </button>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/systems/ceiling/ceiling-system.tsx",
    "content": "import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect } from 'react'\nimport useEditor from '../../../store/use-editor'\n\nexport const CeilingSystem = () => {\n  const tool = useEditor((state) => state.tool)\n  const selectedItem = useEditor((state) => state.selectedItem)\n  const movingNode = useEditor((state) => state.movingNode)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const activeLevelId = useViewer((state) => state.selection.levelId)\n\n  useEffect(() => {\n    const nodes = useScene.getState().nodes\n\n    const levelsToShowCeilings = new Set<string>()\n\n    const isCeilingToolActive =\n      tool === 'ceiling' ||\n      selectedItem?.attachTo === 'ceiling' ||\n      (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')\n\n    if (isCeilingToolActive && activeLevelId) {\n      levelsToShowCeilings.add(activeLevelId)\n    }\n\n    for (const id of selectedIds) {\n      let currentId: string | null = id\n      let isCeilingRelated = false\n      let levelId: string | null = null\n\n      while (currentId && nodes[currentId as AnyNodeId]) {\n        const node = nodes[currentId as AnyNodeId]\n        if (node?.type === 'ceiling') {\n          isCeilingRelated = true\n        }\n        if (node?.type === 'level') {\n          levelId = node.id\n          break\n        }\n        currentId = node?.parentId as string | null\n      }\n\n      if (isCeilingRelated && levelId) {\n        levelsToShowCeilings.add(levelId)\n      }\n    }\n\n    const ceilings = sceneRegistry.byType.ceiling\n    ceilings.forEach((ceiling) => {\n      const mesh = sceneRegistry.nodes.get(ceiling)\n      if (mesh) {\n        const ceilingGrid = mesh.getObjectByName('ceiling-grid')\n        if (ceilingGrid) {\n          let belongsToVisibleLevel = false\n          let currentId: string | null = ceiling\n\n          while (currentId && nodes[currentId as AnyNodeId]) {\n            const node = nodes[currentId as AnyNodeId]\n            if (node && levelsToShowCeilings.has(node.id)) {\n              belongsToVisibleLevel = true\n              break\n            }\n            currentId = node?.parentId as string | null\n          }\n\n          const shouldShowGrid =\n            belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)\n\n          ceilingGrid.visible = shouldShowGrid\n          ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden\n        }\n      }\n    })\n  }, [tool, selectedItem, movingNode, selectedIds, activeLevelId])\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/systems/roof/roof-edit-system.tsx",
    "content": "import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\n\n/**\n * Imperatively toggles the Three.js visibility of roof objects based on the\n * editor selection — without causing React re-renders in RoofRenderer.\n *\n * When a roof (or one of its segments) is selected:\n *   - merged-roof mesh is hidden\n *   - segments-wrapper group is shown (individual segments visible for editing)\n *   - all children are marked dirty so RoofSystem rebuilds their geometry\n *\n * When deselected:\n *   - merged-roof mesh is shown\n *   - segments-wrapper group is hidden\n */\nexport const RoofEditSystem = () => {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const prevActiveRoofIds = useRef(new Set<string>())\n\n  useEffect(() => {\n    const nodes = useScene.getState().nodes\n\n    // Collect which roof nodes should be in \"edit mode\"\n    const activeRoofIds = new Set<string>()\n    for (const id of selectedIds) {\n      const node = nodes[id as AnyNodeId]\n      if (!node) continue\n      if (node.type === 'roof') {\n        activeRoofIds.add(id)\n      } else if (node.type === 'roof-segment' && node.parentId) {\n        activeRoofIds.add(node.parentId)\n      }\n    }\n\n    // Update all roofs that are currently active OR were previously active\n    const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current])\n\n    for (const roofId of roofIdsToUpdate) {\n      const group = sceneRegistry.nodes.get(roofId)\n      if (!group) continue\n\n      const mergedMesh = group.getObjectByName('merged-roof')\n      const segmentsWrapper = group.getObjectByName('segments-wrapper')\n      const isActive = activeRoofIds.has(roofId)\n\n      if (mergedMesh) mergedMesh.visible = !isActive\n      if (segmentsWrapper) segmentsWrapper.visible = isActive\n\n      const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined\n      if (roofNode?.children?.length) {\n        const wasActive = prevActiveRoofIds.current.has(roofId)\n        if (isActive !== wasActive) {\n          // Entering edit mode: rebuild individual segment geometries\n          // Exiting edit mode: sync transforms + rebuild merged mesh\n          const { markDirty } = useScene.getState()\n          for (const childId of roofNode.children) {\n            markDirty(childId as AnyNodeId)\n          }\n        }\n      }\n    }\n\n    prevActiveRoofIds.current = activeRoofIds\n  }, [selectedIds])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/systems/stair/stair-edit-system.tsx",
    "content": "import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\n\n/**\n * Imperatively toggles the Three.js visibility of stair objects based on the\n * editor selection — without causing React re-renders in StairRenderer.\n *\n * When a stair (or one of its segments) is selected:\n *   - merged-stair mesh is hidden\n *   - segments-wrapper group is shown (individual segments visible for editing)\n *   - all children are marked dirty so StairSystem rebuilds their geometry\n *\n * When deselected:\n *   - merged-stair mesh is shown\n *   - segments-wrapper group is hidden\n */\nexport const StairEditSystem = () => {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const prevActiveStairIds = useRef(new Set<string>())\n\n  useEffect(() => {\n    const nodes = useScene.getState().nodes\n\n    // Collect which stair nodes should be in \"edit mode\"\n    const activeStairIds = new Set<string>()\n    for (const id of selectedIds) {\n      const node = nodes[id as AnyNodeId]\n      if (!node) continue\n      if (node.type === 'stair') {\n        activeStairIds.add(id)\n      } else if (node.type === 'stair-segment' && node.parentId) {\n        activeStairIds.add(node.parentId)\n      }\n    }\n\n    // Update all stairs that are currently active OR were previously active\n    const stairIdsToUpdate = new Set([...activeStairIds, ...prevActiveStairIds.current])\n\n    for (const stairId of stairIdsToUpdate) {\n      const group = sceneRegistry.nodes.get(stairId)\n      if (!group) continue\n\n      const mergedMesh = group.getObjectByName('merged-stair')\n      const segmentsWrapper = group.getObjectByName('segments-wrapper')\n      const isActive = activeStairIds.has(stairId)\n\n      if (mergedMesh) mergedMesh.visible = !isActive\n      if (segmentsWrapper) segmentsWrapper.visible = isActive\n\n      const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined\n      if (stairNode?.children?.length) {\n        const wasActive = prevActiveStairIds.current.has(stairId)\n        if (isActive !== wasActive) {\n          // Entering edit mode: rebuild individual segment geometries\n          // Exiting edit mode: sync transforms + rebuild merged mesh\n          const { markDirty } = useScene.getState()\n          for (const childId of stairNode.children) {\n            markDirty(childId as AnyNodeId)\n          }\n        }\n      }\n    }\n\n    prevActiveStairIds.current = activeStairIds\n  }, [selectedIds])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/systems/zone/zone-label-editor-system.tsx",
    "content": "'use client'\n\nimport { useScene, type ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Check, Pencil } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useShallow } from 'zustand/react/shallow'\nimport useEditor from '../../../store/use-editor'\n\n// ─── Per-zone label editor ────────────────────────────────────────────────────\n\nfunction ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {\n  const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)\n  const updateNode = useScene((s) => s.updateNode)\n  const setSelection = useViewer((s) => s.setSelection)\n  const [editing, setEditing] = useState(false)\n  const [value, setValue] = useState('')\n  const inputRef = useRef<HTMLInputElement>(null)\n  const [labelEl, setLabelEl] = useState<HTMLElement | null>(null)\n\n  // Keep a ref so the click handler never has a stale zone name\n  const zoneNameRef = useRef(zone?.name ?? '')\n  useEffect(() => {\n    zoneNameRef.current = zone?.name ?? ''\n  }, [zone?.name])\n\n  // Setup: find the label element, enable pointer events, and hide the\n  // zone-renderer's own text node (children[0]) — we replace it via portal.\n  useEffect(() => {\n    const el = document.getElementById(`${zoneId}-label`)\n    if (!el) return\n    setLabelEl(el)\n\n    const textEl = el.children[0] as HTMLElement | undefined\n    if (textEl) textEl.style.display = 'none'\n\n    return () => {\n      if (textEl) textEl.style.display = ''\n    }\n  }, [zoneId])\n\n  // Focus + select-all when entering edit mode\n  useEffect(() => {\n    if (editing) {\n      inputRef.current?.focus()\n      inputRef.current?.select()\n    }\n  }, [editing])\n\n  const save = useCallback(() => {\n    const trimmed = value.trim()\n    if (trimmed !== (zone?.name ?? '')) {\n      updateNode(zoneId, { name: trimmed || undefined })\n    }\n    setEditing(false)\n  }, [value, zone?.name, updateNode, zoneId])\n\n  const cancel = useCallback(() => {\n    setValue(zone?.name ?? '')\n    setEditing(false)\n  }, [zone?.name])\n\n  if (!labelEl) return null\n\n  const shadowColor = zone?.color ?? '#6366f1'\n  const textShadow = [\n    `-1px -1px 0 ${shadowColor}`,\n    ` 1px -1px 0 ${shadowColor}`,\n    `-1px  1px 0 ${shadowColor}`,\n    ` 1px  1px 0 ${shadowColor}`,\n  ].join(',')\n\n  // order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)\n  const sharedStyle: React.CSSProperties = {\n    order: -1,\n    color: 'white',\n    textShadow,\n    fontSize: 14,\n    fontFamily: 'sans-serif',\n    userSelect: 'none',\n    pointerEvents: 'auto',\n    display: 'inline-flex',\n    alignItems: 'center',\n    gap: 4,\n    whiteSpace: 'nowrap',\n  }\n\n  return createPortal(\n    editing ? (\n      <div\n        onMouseDown={(e) => e.stopPropagation()}\n        onPointerDown={(e) => e.stopPropagation()}\n        style={sharedStyle}\n      >\n        <input\n          onBlur={save}\n          onChange={(e) => setValue(e.target.value)}\n          onClick={(e) => e.stopPropagation()}\n          onKeyDown={(e) => {\n            e.stopPropagation()\n            if (e.key === 'Enter') {\n              e.preventDefault()\n              save()\n            }\n            if (e.key === 'Escape') {\n              e.preventDefault()\n              cancel()\n            }\n          }}\n          ref={inputRef}\n          style={{\n            width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,\n            border: 'none',\n            borderBottom: `1px solid ${shadowColor}`,\n            background: 'transparent',\n            color: 'white',\n            textShadow,\n            outline: 'none',\n            padding: 0,\n            margin: 0,\n            fontSize: 'inherit',\n            lineHeight: 'inherit',\n            fontFamily: 'inherit',\n            textAlign: 'center',\n          }}\n          type=\"text\"\n          value={value}\n        />\n        <button\n          onClick={(e) => {\n            e.stopPropagation()\n            save()\n          }}\n          onMouseDown={(e) => e.stopPropagation()}\n          style={{\n            background: 'none',\n            border: 'none',\n            color: 'white',\n            cursor: 'pointer',\n            padding: 0,\n            display: 'inline-flex',\n            alignItems: 'center',\n          }}\n          type=\"button\"\n        >\n          <Check size={12} />\n        </button>\n      </div>\n    ) : (\n      <button\n        onClick={(e) => {\n          e.stopPropagation()\n          setSelection({ zoneId })\n          setValue(zoneNameRef.current)\n          setEditing(true)\n        }}\n        onMouseDown={(e) => e.stopPropagation()}\n        style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}\n        type=\"button\"\n      >\n        <span>{zone?.name}</span>\n        <span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>\n          <Pencil size={10} />\n        </span>\n      </button>\n    ),\n    labelEl,\n  )\n}\n\n// ─── System: rendered in the main React tree (outside Canvas) ─────────────────\n\nexport function ZoneLabelEditorSystem() {\n  const zoneIds = useScene(\n    useShallow((s) =>\n      Object.values(s.nodes)\n        .filter((n) => n.type === 'zone')\n        .map((n) => n.id as ZoneNode['id']),\n    ),\n  )\n  const structureLayer = useEditor((s) => s.structureLayer)\n  const mode = useEditor((s) => s.mode)\n\n  if (structureLayer !== 'zones' || mode !== 'select') return null\n\n  return (\n    <>\n      {zoneIds.map((id) => (\n        <ZoneLabelEditor key={id} zoneId={id} />\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/systems/zone/zone-system.tsx",
    "content": "import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useFrame } from '@react-three/fiber'\nimport useEditor from '../../../store/use-editor'\n\nexport const ZoneSystem = () => {\n  useFrame(() => {\n    const structureLayer = useEditor.getState().structureLayer\n    const levelMode = useViewer.getState().levelMode\n    const selectedLevelId = useViewer.getState().selection.levelId\n\n    const visible = structureLayer === 'zones'\n    const zones = sceneRegistry.byType.zone || new Set()\n    const nodes = useScene.getState().nodes\n\n    zones.forEach((zoneId) => {\n      const obj = sceneRegistry.nodes.get(zoneId)\n      if (!obj) return\n\n      const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined\n\n      // In solo mode, hide labels for zones not on the current level\n      const isOnSelectedLevel = zone?.parentId === selectedLevelId\n      const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel\n\n      if (obj.visible !== visible) {\n        obj.visible = visible\n      }\n\n      // Hide label if zone layer is off OR if in solo mode on a different level\n      const showLabel = visible && !hideInSoloMode\n      const targetOpacity = showLabel ? '1' : '0'\n      const labelEl = document.getElementById(`${zoneId}-label`)\n      if (labelEl && labelEl.style.opacity !== targetOpacity) {\n        labelEl.style.opacity = targetOpacity\n      }\n    })\n  })\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx",
    "content": "import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\ninterface CeilingBoundaryEditorProps {\n  ceilingId: CeilingNode['id']\n}\n\n/**\n * Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling\n * Uses the generic PolygonEditor component\n */\nexport const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {\n  const ceilingNode = useScene((state) => state.nodes[ceilingId])\n  const updateNode = useScene((state) => state.updateNode)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      updateNode(ceilingId, { polygon: newPolygon })\n      // Re-assert selection so the ceiling stays selected after the edit\n      setSelection({ selectedIds: [ceilingId] })\n    },\n    [ceilingId, updateNode, setSelection],\n  )\n\n  if (!ceiling?.polygon || ceiling.polygon.length < 3) return null\n\n  return (\n    <PolygonEditor\n      color=\"#d4d4d4\"\n      levelId={resolveLevelId(ceiling, useScene.getState().nodes)}\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={ceiling.polygon}\n      surfaceHeight={ceiling.height ?? 2.5}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx",
    "content": "import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\ninterface CeilingHoleEditorProps {\n  ceilingId: CeilingNode['id']\n  holeIndex: number\n}\n\n/**\n * Ceiling hole editor - allows editing a specific hole polygon within a ceiling\n * Uses the generic PolygonEditor component\n */\nexport const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {\n  const ceilingNode = useScene((state) => state.nodes[ceilingId])\n  const updateNode = useScene((state) => state.updateNode)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null\n  const holes = ceiling?.holes || []\n  const hole = holes[holeIndex]\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      const updatedHoles = [...holes]\n      updatedHoles[holeIndex] = newPolygon\n      updateNode(ceilingId, { holes: updatedHoles })\n      // Re-assert selection so the ceiling stays selected after the edit\n      setSelection({ selectedIds: [ceilingId] })\n    },\n    [ceilingId, holeIndex, holes, updateNode, setSelection],\n  )\n\n  if (!(ceiling && hole) || hole.length < 3) return null\n\n  return (\n    <PolygonEditor\n      color=\"#ef4444\"\n      levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={hole}\n      surfaceHeight={ceiling.height ?? 2.5}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/ceiling/ceiling-tool.tsx",
    "content": "import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'\nimport { mix, positionLocal } from 'three/tsl'\nimport { markToolCancelConsumed } from '../../../hooks/use-keyboard'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nconst CEILING_HEIGHT = 2.52\nconst GRID_OFFSET = 0.02\n\n/**\n * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point\n */\nconst calculateSnapPoint = (\n  lastPoint: [number, number],\n  currentPoint: [number, number],\n): [number, number] => {\n  const [x1, y1] = lastPoint\n  const [x, y] = currentPoint\n\n  const dx = x - x1\n  const dy = y - y1\n  const absDx = Math.abs(dx)\n  const absDy = Math.abs(dy)\n\n  // Calculate distances to horizontal, vertical, and diagonal lines\n  const horizontalDist = absDy\n  const verticalDist = absDx\n  const diagonalDist = Math.abs(absDx - absDy)\n\n  // Find the minimum distance to determine which axis to snap to\n  const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)\n\n  if (minDist === diagonalDist) {\n    // Snap to 45° diagonal\n    const diagonalLength = Math.min(absDx, absDy)\n    return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]\n  }\n  if (minDist === horizontalDist) {\n    // Snap to horizontal\n    return [x, y1]\n  }\n  // Snap to vertical\n  return [x1, y]\n}\n\n/**\n * Creates a ceiling with the given polygon points and returns its ID\n */\nconst commitCeilingDrawing = (\n  levelId: LevelNode['id'],\n  points: Array<[number, number]>,\n): string => {\n  const { createNode, nodes } = useScene.getState()\n\n  // Count existing ceilings for naming\n  const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length\n  const name = `Ceiling ${ceilingCount + 1}`\n\n  const ceiling = CeilingNode.parse({\n    name,\n    polygon: points,\n  })\n\n  createNode(ceiling, levelId)\n  sfxEmitter.emit('sfx:structure-build')\n  return ceiling.id\n}\n\nexport const CeilingTool: React.FC = () => {\n  const cursorRef = useRef<Group>(null)\n  const gridCursorRef = useRef<Group>(null)\n  const mainLineRef = useRef<Line>(null!)\n  const closingLineRef = useRef<Line>(null!)\n  const groundMainLineRef = useRef<Line>(null!)\n  const groundClosingLineRef = useRef<Line>(null!)\n  const verticalLineRef = useRef<Line>(null!)\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const [points, setPoints] = useState<Array<[number, number]>>([])\n  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])\n  const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])\n  const [levelY, setLevelY] = useState(0)\n  const previousSnappedPointRef = useRef<[number, number] | null>(null)\n  const shiftPressed = useRef(false)\n\n  // Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY\n  const verticalGeo = useMemo(\n    () =>\n      new BufferGeometry().setFromPoints([\n        new Vector3(0, 0, 0),\n        new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),\n      ]),\n    [],\n  )\n\n  // opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0\n  const gradientOpacityNode = useMemo(\n    () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),\n    [],\n  )\n\n  // Update cursor position and lines on grid move\n  useEffect(() => {\n    if (!currentLevelId) return\n\n    const onGridMove = (event: GridEvent) => {\n      if (!(cursorRef.current && gridCursorRef.current)) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const gridPosition: [number, number] = [gridX, gridZ]\n\n      setCursorPosition(gridPosition)\n      setLevelY(event.position[1])\n\n      const ceilingY = event.position[1] + CEILING_HEIGHT\n      const gridY = event.position[1] + GRID_OFFSET\n\n      // Calculate snapped display position (bypass snap when Shift is held)\n      const lastPoint = points[points.length - 1]\n      const displayPoint =\n        shiftPressed.current || !lastPoint\n          ? gridPosition\n          : calculateSnapPoint(lastPoint, gridPosition)\n      setSnappedCursorPosition(displayPoint)\n\n      // Play snap sound when the snapped position actually changes (only when drawing)\n      if (\n        points.length > 0 &&\n        previousSnappedPointRef.current &&\n        (displayPoint[0] !== previousSnappedPointRef.current[0] ||\n          displayPoint[1] !== previousSnappedPointRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousSnappedPointRef.current = displayPoint\n      cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])\n      gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])\n\n      if (verticalLineRef.current) {\n        verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])\n      }\n    }\n\n    const onGridClick = (_event: GridEvent) => {\n      if (!currentLevelId) return\n\n      // Use the last displayed snapped position (respects Shift state from onGridMove)\n      const clickPoint = previousSnappedPointRef.current ?? cursorPosition\n\n      // Check if clicking on the first point to close the shape\n      const firstPoint = points[0]\n      if (\n        points.length >= 3 &&\n        firstPoint &&\n        Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&\n        Math.abs(clickPoint[1] - firstPoint[1]) < 0.25\n      ) {\n        // Create the ceiling and select it\n        const ceilingId = commitCeilingDrawing(currentLevelId, points)\n        setSelection({ selectedIds: [ceilingId] })\n        setPoints([])\n      } else {\n        // Add point to polygon\n        setPoints([...points, clickPoint])\n      }\n    }\n\n    const onGridDoubleClick = (_event: GridEvent) => {\n      if (!currentLevelId) return\n\n      // Need at least 3 points to form a polygon\n      if (points.length >= 3) {\n        const ceilingId = commitCeilingDrawing(currentLevelId, points)\n        setSelection({ selectedIds: [ceilingId] })\n        setPoints([])\n      }\n    }\n\n    const onCancel = () => {\n      if (points.length > 0) markToolCancelConsumed()\n      setPoints([])\n    }\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') shiftPressed.current = true\n    }\n    const onKeyUp = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') shiftPressed.current = false\n    }\n    document.addEventListener('keydown', onKeyDown)\n    document.addEventListener('keyup', onKeyUp)\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('grid:double-click', onGridDoubleClick)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      document.removeEventListener('keydown', onKeyDown)\n      document.removeEventListener('keyup', onKeyUp)\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('grid:double-click', onGridDoubleClick)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [currentLevelId, points, cursorPosition, setSelection])\n\n  // Update line geometries when points change\n  useEffect(() => {\n    if (!(mainLineRef.current && closingLineRef.current)) return\n\n    if (points.length === 0) {\n      mainLineRef.current.visible = false\n      closingLineRef.current.visible = false\n      return\n    }\n\n    const ceilingY = levelY + CEILING_HEIGHT\n    const snappedCursor = snappedCursorPosition\n\n    // Build main line points\n    const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))\n    linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))\n\n    const gridY = levelY + GRID_OFFSET\n    const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))\n    groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))\n\n    // Update main line\n    if (linePoints.length >= 2) {\n      mainLineRef.current.geometry.dispose()\n      mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)\n      mainLineRef.current.visible = true\n\n      groundMainLineRef.current.geometry.dispose()\n      groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)\n      groundMainLineRef.current.visible = true\n    } else {\n      mainLineRef.current.visible = false\n      groundMainLineRef.current.visible = false\n    }\n\n    // Update closing line (from cursor back to first point)\n    const firstPoint = points[0]\n    if (points.length >= 2 && firstPoint) {\n      const closingPoints = [\n        new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),\n        new Vector3(firstPoint[0], ceilingY, firstPoint[1]),\n      ]\n      closingLineRef.current.geometry.dispose()\n      closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)\n      closingLineRef.current.visible = true\n\n      const groundClosingPoints = [\n        new Vector3(snappedCursor[0], gridY, snappedCursor[1]),\n        new Vector3(firstPoint[0], gridY, firstPoint[1]),\n      ]\n      groundClosingLineRef.current.geometry.dispose()\n      groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(\n        groundClosingPoints,\n      )\n      groundClosingLineRef.current.visible = true\n    } else {\n      closingLineRef.current.visible = false\n      groundClosingLineRef.current.visible = false\n    }\n  }, [points, snappedCursorPosition, levelY])\n\n  // Create preview shape when we have 3+ points\n  const previewShape = useMemo(() => {\n    if (points.length < 3) return null\n\n    const snappedCursor = snappedCursorPosition\n\n    const allPoints = [...points, snappedCursor]\n\n    // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:\n    // - Shape X -> World X\n    // - Shape Y -> World -Z (so we negate Z to get correct orientation)\n    const firstPt = allPoints[0]\n    if (!firstPt) return null\n\n    const shape = new Shape()\n    shape.moveTo(firstPt[0], -firstPt[1])\n\n    for (let i = 1; i < allPoints.length; i++) {\n      const pt = allPoints[i]\n      if (pt) {\n        shape.lineTo(pt[0], -pt[1])\n      }\n    }\n    shape.closePath()\n\n    return shape\n  }, [points, snappedCursorPosition])\n\n  return (\n    <group>\n      {/* Cursor at ceiling height */}\n      <CursorSphere ref={cursorRef} />\n\n      {/* Grid-level cursor indicator */}\n      <mesh\n        layers={EDITOR_LAYER}\n        ref={gridCursorRef}\n        renderOrder={2}\n        rotation={[-Math.PI / 2, 0, 0]}\n      >\n        <ringGeometry args={[0.15, 0.2, 32]} />\n        <meshBasicMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={true}\n          opacity={0.5}\n          side={DoubleSide}\n          transparent\n        />\n      </mesh>\n\n      {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}\n      {/* @ts-ignore */}\n      <line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          opacityNode={gradientOpacityNode}\n          transparent\n        />\n      </line>\n\n      {/* Preview fill (Top) */}\n      {previewShape && (\n        <mesh\n          frustumCulled={false}\n          layers={EDITOR_LAYER}\n          position={[0, levelY + CEILING_HEIGHT, 0]}\n          rotation={[-Math.PI / 2, 0, 0]}\n        >\n          <shapeGeometry args={[previewShape]} />\n          <meshBasicMaterial\n            color=\"#818cf8\"\n            depthTest={false}\n            opacity={0.15}\n            side={DoubleSide}\n            transparent\n          />\n        </mesh>\n      )}\n\n      {/* Preview fill (Ground) */}\n      {previewShape && (\n        <mesh\n          frustumCulled={false}\n          layers={EDITOR_LAYER}\n          position={[0, levelY + GRID_OFFSET, 0]}\n          rotation={[-Math.PI / 2, 0, 0]}\n        >\n          <shapeGeometry args={[previewShape]} />\n          <meshBasicMaterial\n            color=\"#818cf8\"\n            depthTest={false}\n            opacity={0.1}\n            side={DoubleSide}\n            transparent\n          />\n        </mesh>\n      )}\n\n      {/* Main line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={mainLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial color=\"#818cf8\" depthTest={false} depthWrite={false} linewidth={3} />\n      </line>\n\n      {/* Closing line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={closingLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.5}\n          transparent\n        />\n      </line>\n\n      {/* Ground main line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={groundMainLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={3}\n          opacity={0.3}\n          transparent\n        />\n      </line>\n\n      {/* Ground closing line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={groundClosingLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.15}\n          transparent\n        />\n      </line>\n\n      {/* Point markers */}\n      {points.map(([x, z], index) => (\n        <CursorSphere\n          color=\"#818cf8\"\n          key={index}\n          position={[x, levelY + CEILING_HEIGHT + 0.01, z]}\n          showTooltip={false}\n        />\n      ))}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/door/door-math.ts",
    "content": "import {\n  type AnyNodeId,\n  type DoorNode,\n  getScaledDimensions,\n  type ItemNode,\n  useScene,\n  type WallNode,\n  type WindowNode,\n} from '@pascal-app/core'\n\n/**\n * Converts wall-local (X along wall, Y = height above wall base) to world XYZ.\n */\nexport function wallLocalToWorld(\n  wallNode: WallNode,\n  localX: number,\n  localY: number,\n  levelYOffset = 0,\n  slabElevation = 0,\n): [number, number, number] {\n  const wallAngle = Math.atan2(\n    wallNode.end[1] - wallNode.start[1],\n    wallNode.end[0] - wallNode.start[0],\n  )\n  return [\n    wallNode.start[0] + localX * Math.cos(wallAngle),\n    slabElevation + localY + levelYOffset,\n    wallNode.start[1] + localX * Math.sin(wallAngle),\n  ]\n}\n\n/**\n * Clamps door center X so it stays fully within wall bounds.\n * Y is always height/2 — doors sit at floor level.\n */\nexport function clampToWall(\n  wallNode: WallNode,\n  localX: number,\n  width: number,\n  height: number,\n): { clampedX: number; clampedY: number } {\n  const dx = wallNode.end[0] - wallNode.start[0]\n  const dz = wallNode.end[1] - wallNode.start[1]\n  const wallLength = Math.sqrt(dx * dx + dz * dz)\n\n  const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))\n  const clampedY = height / 2 // Doors always sit at floor level\n  return { clampedX, clampedY }\n}\n\n/**\n * Checks if a proposed door position overlaps any existing wall children.\n * Handles item, window, and door types.\n */\nexport function hasWallChildOverlap(\n  wallId: string,\n  clampedX: number,\n  clampedY: number,\n  width: number,\n  height: number,\n  ignoreId?: string,\n): boolean {\n  const nodes = useScene.getState().nodes\n  const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined\n  if (!wallNode) return true\n  const halfW = width / 2\n  const halfH = height / 2\n  const newBottom = clampedY - halfH\n  const newTop = clampedY + halfH\n  const newLeft = clampedX - halfW\n  const newRight = clampedX + halfW\n\n  for (const childId of wallNode.children) {\n    if (childId === ignoreId) continue\n    const child = nodes[childId as AnyNodeId]\n    if (!child) continue\n\n    let childLeft: number, childRight: number, childBottom: number, childTop: number\n\n    if (child.type === 'item') {\n      const item = child as ItemNode\n      if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue\n      const [w, h] = getScaledDimensions(item)\n      childLeft = item.position[0] - w / 2\n      childRight = item.position[0] + w / 2\n      childBottom = item.position[1]\n      childTop = item.position[1] + h\n    } else if (child.type === 'window') {\n      const win = child as WindowNode\n      childLeft = win.position[0] - win.width / 2\n      childRight = win.position[0] + win.width / 2\n      childBottom = win.position[1] - win.height / 2\n      childTop = win.position[1] + win.height / 2\n    } else if (child.type === 'door') {\n      const door = child as DoorNode\n      childLeft = door.position[0] - door.width / 2\n      childRight = door.position[0] + door.width / 2\n      childBottom = door.position[1] - door.height / 2\n      childTop = door.position[1] + door.height / 2\n    } else {\n      continue\n    }\n\n    const xOverlap = newLeft < childRight && newRight > childLeft\n    const yOverlap = newBottom < childTop && newTop > childBottom\n    if (xOverlap && yOverlap) return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/door/door-tool.tsx",
    "content": "import {\n  type AnyNodeId,\n  DoorNode,\n  emitter,\n  sceneRegistry,\n  spatialGridManager,\n  useScene,\n  type WallEvent,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\nimport { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'\nimport { LineBasicNodeMaterial } from 'three/webgpu'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport {\n  calculateCursorRotation,\n  calculateItemRotation,\n  getSideFromNormal,\n  isValidWallSideFace,\n  snapToHalf,\n} from '../item/placement-math'\nimport { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'\n\nconst edgeMaterial = new LineBasicNodeMaterial({\n  color: 0xef_44_44,\n  linewidth: 3,\n  depthTest: false,\n  depthWrite: false,\n})\n\n/**\n * Door tool — places DoorNodes on walls only.\n * Doors always sit at floor level (clampedY = height/2).\n */\nexport const DoorTool: React.FC = () => {\n  const draftRef = useRef<DoorNode | null>(null)\n  const cursorGroupRef = useRef<Group>(null!)\n  const edgesRef = useRef<LineSegments>(null!)\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const getLevelId = () => useViewer.getState().selection.levelId\n    const getLevelYOffset = () => {\n      const id = getLevelId()\n      return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0\n    }\n    const getSlabElevation = (wallEvent: WallEvent) =>\n      spatialGridManager.getSlabElevationForWall(\n        wallEvent.node.parentId ?? '',\n        wallEvent.node.start,\n        wallEvent.node.end,\n      )\n\n    const markWallDirty = (wallId: string) => {\n      useScene.getState().dirtyNodes.add(wallId as AnyNodeId)\n    }\n\n    const destroyDraft = () => {\n      if (!draftRef.current) return\n      const wallId = draftRef.current.parentId\n      useScene.getState().deleteNode(draftRef.current.id)\n      draftRef.current = null\n      if (wallId) markWallDirty(wallId)\n    }\n\n    const hideCursor = () => {\n      if (cursorGroupRef.current) cursorGroupRef.current.visible = false\n    }\n\n    const updateCursor = (\n      worldPosition: [number, number, number],\n      cursorRotationY: number,\n      valid: boolean,\n    ) => {\n      const group = cursorGroupRef.current\n      if (!group) return\n      group.visible = true\n      group.position.set(...worldPosition)\n      group.rotation.y = cursorRotationY\n      edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)\n    }\n\n    const onWallEnter = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      const levelId = getLevelId()\n      if (!levelId) return\n      if (event.node.parentId !== levelId) return\n\n      destroyDraft()\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const width = 0.9\n      const height = 2.1\n\n      const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)\n\n      const node = DoorNode.parse({\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        wallId: event.node.id,\n        parentId: event.node.id,\n        metadata: { isTransient: true },\n      })\n\n      useScene.getState().createNode(node, event.node.id as AnyNodeId)\n      draftRef.current = node\n\n      const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallMove = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const width = draftRef.current?.width ?? 0.9\n      const height = draftRef.current?.height ?? 2.1\n\n      const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)\n\n      if (draftRef.current) {\n        useScene.getState().updateNode(draftRef.current.id, {\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          parentId: event.node.id,\n          wallId: event.node.id,\n        })\n      }\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        width,\n        height,\n        draftRef.current?.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallClick = (event: WallEvent) => {\n      if (!draftRef.current) return\n      if (!isValidWallSideFace(event.normal)) return\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        draftRef.current.width,\n        draftRef.current.height,\n      )\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        draftRef.current.width,\n        draftRef.current.height,\n        draftRef.current.id,\n      )\n      if (!valid) return\n\n      const draft = draftRef.current\n      draftRef.current = null\n\n      useScene.getState().deleteNode(draft.id)\n      useScene.temporal.getState().resume()\n\n      const levelId = getLevelId()\n      const state = useScene.getState()\n      const doorCount = Object.values(state.nodes).filter((n) => {\n        if (n.type !== 'door') return false\n        const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined\n        return wall?.parentId === levelId\n      }).length\n      const name = `Door ${doorCount + 1}`\n\n      const node = DoorNode.parse({\n        name,\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        wallId: event.node.id,\n        parentId: event.node.id,\n        width: draft.width,\n        height: draft.height,\n        frameThickness: draft.frameThickness,\n        frameDepth: draft.frameDepth,\n        threshold: draft.threshold,\n        thresholdHeight: draft.thresholdHeight,\n        hingesSide: draft.hingesSide,\n        swingDirection: draft.swingDirection,\n        segments: draft.segments,\n        handle: draft.handle,\n        handleHeight: draft.handleHeight,\n        handleSide: draft.handleSide,\n        doorCloser: draft.doorCloser,\n        panicBar: draft.panicBar,\n        panicBarHeight: draft.panicBarHeight,\n      })\n\n      useScene.getState().createNode(node, event.node.id as AnyNodeId)\n      useViewer.getState().setSelection({ selectedIds: [node.id] })\n      useScene.temporal.getState().pause()\n      sfxEmitter.emit('sfx:item-place')\n\n      event.stopPropagation()\n    }\n\n    const onWallLeave = () => {\n      destroyDraft()\n      hideCursor()\n    }\n\n    const onCancel = () => {\n      destroyDraft()\n      hideCursor()\n    }\n\n    emitter.on('wall:enter', onWallEnter)\n    emitter.on('wall:move', onWallMove)\n    emitter.on('wall:click', onWallClick)\n    emitter.on('wall:leave', onWallLeave)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      destroyDraft()\n      hideCursor()\n      useScene.temporal.getState().resume()\n      emitter.off('wall:enter', onWallEnter)\n      emitter.off('wall:move', onWallMove)\n      emitter.off('wall:click', onWallClick)\n      emitter.off('wall:leave', onWallLeave)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [])\n\n  // Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)\n  const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)\n  const edgesGeo = new EdgesGeometry(boxGeo)\n  boxGeo.dispose()\n\n  return (\n    <group ref={cursorGroupRef} visible={false}>\n      <lineSegments\n        geometry={edgesGeo}\n        layers={EDITOR_LAYER}\n        material={edgeMaterial}\n        ref={edgesRef}\n      />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/door/move-door-tool.tsx",
    "content": "import {\n  type AnyNodeId,\n  DoorNode,\n  emitter,\n  sceneRegistry,\n  spatialGridManager,\n  useScene,\n  type WallEvent,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback, useEffect, useMemo, useRef } from 'react'\nimport { BoxGeometry, EdgesGeometry, type Group } from 'three'\nimport { LineBasicNodeMaterial } from 'three/webgpu'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport {\n  calculateCursorRotation,\n  calculateItemRotation,\n  getSideFromNormal,\n  isValidWallSideFace,\n  snapToHalf,\n} from '../item/placement-math'\nimport { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'\n\nconst edgeMaterial = new LineBasicNodeMaterial({\n  color: 0xef_44_44,\n  linewidth: 3,\n  depthTest: false,\n  depthWrite: false,\n})\n\nexport const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {\n  const cursorGroupRef = useRef<Group>(null!)\n\n  const exitMoveMode = useCallback(() => {\n    useEditor.getState().setMovingNode(null)\n  }, [])\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const meta =\n      typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null\n        ? (movingDoorNode.metadata as Record<string, unknown>)\n        : {}\n    const isNew = !!meta.isNew\n\n    const original = {\n      position: [...movingDoorNode.position] as [number, number, number],\n      rotation: [...movingDoorNode.rotation] as [number, number, number],\n      side: movingDoorNode.side,\n      parentId: movingDoorNode.parentId,\n      wallId: movingDoorNode.wallId,\n      metadata: movingDoorNode.metadata,\n    }\n\n    if (!isNew) {\n      useScene.getState().updateNode(movingDoorNode.id, {\n        metadata: { ...meta, isTransient: true },\n      })\n    }\n\n    let currentWallId: string | null = movingDoorNode.parentId\n\n    const markWallDirty = (wallId: string | null) => {\n      if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)\n    }\n\n    const getLevelId = () => useViewer.getState().selection.levelId\n    const getLevelYOffset = () => {\n      const id = getLevelId()\n      return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0\n    }\n    const getSlabElevation = (wallEvent: WallEvent) =>\n      spatialGridManager.getSlabElevationForWall(\n        wallEvent.node.parentId ?? '',\n        wallEvent.node.start,\n        wallEvent.node.end,\n      )\n\n    const hideCursor = () => {\n      if (cursorGroupRef.current) cursorGroupRef.current.visible = false\n    }\n\n    const updateCursor = (\n      worldPosition: [number, number, number],\n      cursorRotationY: number,\n      valid: boolean,\n    ) => {\n      const group = cursorGroupRef.current\n      if (!group) return\n      group.visible = true\n      group.position.set(...worldPosition)\n      group.rotation.y = cursorRotationY\n      edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)\n    }\n\n    const onWallEnter = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        movingDoorNode.width,\n        movingDoorNode.height,\n      )\n\n      const prevWallId = currentWallId\n      currentWallId = event.node.id\n\n      useScene.getState().updateNode(movingDoorNode.id, {\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        parentId: event.node.id,\n        wallId: event.node.id,\n      })\n\n      if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)\n      markWallDirty(event.node.id)\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingDoorNode.width,\n        movingDoorNode.height,\n        movingDoorNode.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallMove = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        movingDoorNode.width,\n        movingDoorNode.height,\n      )\n\n      useScene.getState().updateNode(movingDoorNode.id, {\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        parentId: event.node.id,\n        wallId: event.node.id,\n      })\n\n      if (currentWallId !== event.node.id) {\n        markWallDirty(currentWallId)\n        currentWallId = event.node.id\n      }\n      markWallDirty(event.node.id)\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingDoorNode.width,\n        movingDoorNode.height,\n        movingDoorNode.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallClick = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        movingDoorNode.width,\n        movingDoorNode.height,\n      )\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingDoorNode.width,\n        movingDoorNode.height,\n        movingDoorNode.id,\n      )\n      if (!valid) return\n\n      let placedId: string\n\n      if (isNew) {\n        useScene.getState().deleteNode(movingDoorNode.id)\n        useScene.temporal.getState().resume()\n\n        const cloned = structuredClone(movingDoorNode) as any\n        delete cloned.id\n        const node = DoorNode.parse({\n          ...cloned,\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          wallId: event.node.id,\n          parentId: event.node.id,\n        })\n        useScene.getState().createNode(node, event.node.id as AnyNodeId)\n        placedId = node.id\n      } else {\n        useScene.getState().updateNode(movingDoorNode.id, {\n          position: original.position,\n          rotation: original.rotation,\n          side: original.side,\n          parentId: original.parentId,\n          wallId: original.wallId,\n          metadata: original.metadata,\n        })\n        useScene.temporal.getState().resume()\n\n        useScene.getState().updateNode(movingDoorNode.id, {\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          parentId: event.node.id,\n          wallId: event.node.id,\n          metadata: {},\n        })\n\n        if (original.parentId && original.parentId !== event.node.id) {\n          markWallDirty(original.parentId)\n        }\n        placedId = movingDoorNode.id\n      }\n\n      markWallDirty(event.node.id)\n      useScene.temporal.getState().pause()\n\n      sfxEmitter.emit('sfx:item-place')\n      hideCursor()\n      useViewer.getState().setSelection({ selectedIds: [placedId] })\n      exitMoveMode()\n      event.stopPropagation()\n    }\n\n    const onWallLeave = () => {\n      hideCursor()\n      if (isNew) return\n      if (currentWallId && currentWallId !== original.parentId) {\n        markWallDirty(currentWallId)\n      }\n      currentWallId = original.parentId\n      useScene.getState().updateNode(movingDoorNode.id, {\n        position: original.position,\n        rotation: original.rotation,\n        side: original.side,\n        parentId: original.parentId,\n        wallId: original.wallId,\n      })\n      if (original.parentId) markWallDirty(original.parentId)\n    }\n\n    const onCancel = () => {\n      if (isNew) {\n        useScene.getState().deleteNode(movingDoorNode.id)\n        if (currentWallId) markWallDirty(currentWallId)\n      } else {\n        useScene.getState().updateNode(movingDoorNode.id, {\n          position: original.position,\n          rotation: original.rotation,\n          side: original.side,\n          parentId: original.parentId,\n          wallId: original.wallId,\n          metadata: original.metadata,\n        })\n        if (original.parentId) markWallDirty(original.parentId)\n      }\n      useScene.temporal.getState().resume()\n      hideCursor()\n      exitMoveMode()\n    }\n\n    emitter.on('wall:enter', onWallEnter)\n    emitter.on('wall:move', onWallMove)\n    emitter.on('wall:click', onWallClick)\n    emitter.on('wall:leave', onWallLeave)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as\n        | DoorNode\n        | undefined\n      const currentMeta = current?.metadata as Record<string, unknown> | undefined\n      if (currentMeta?.isTransient) {\n        if (isNew) {\n          useScene.getState().deleteNode(movingDoorNode.id)\n          if (currentWallId) markWallDirty(currentWallId)\n        } else {\n          useScene.getState().updateNode(movingDoorNode.id, {\n            position: original.position,\n            rotation: original.rotation,\n            side: original.side,\n            parentId: original.parentId,\n            wallId: original.wallId,\n            metadata: original.metadata,\n          })\n          if (original.parentId) markWallDirty(original.parentId)\n        }\n      }\n      useScene.temporal.getState().resume()\n      emitter.off('wall:enter', onWallEnter)\n      emitter.off('wall:move', onWallMove)\n      emitter.off('wall:click', onWallClick)\n      emitter.off('wall:leave', onWallLeave)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [movingDoorNode, exitMoveMode])\n\n  const edgesGeo = useMemo(() => {\n    const boxGeo = new BoxGeometry(\n      movingDoorNode.width,\n      movingDoorNode.height,\n      movingDoorNode.frameDepth ?? 0.07,\n    )\n    const geo = new EdgesGeometry(boxGeo)\n    boxGeo.dispose()\n    return geo\n  }, [movingDoorNode])\n\n  return (\n    <group ref={cursorGroupRef} visible={false}>\n      <lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/item-tool.tsx",
    "content": "import { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { useDraftNode } from './use-draft-node'\nimport { usePlacementCoordinator } from './use-placement-coordinator'\n\nexport const ItemTool: React.FC = () => {\n  const selectedItem = useEditor((state) => state.selectedItem)\n  const draftNode = useDraftNode()\n\n  const cursor = usePlacementCoordinator({\n    asset: selectedItem!,\n    draftNode,\n    initDraft: (gridPosition) => {\n      if (!selectedItem?.attachTo) {\n        draftNode.create(gridPosition, selectedItem!)\n      }\n    },\n    onCommitted: () => {\n      sfxEmitter.emit('sfx:item-place')\n      return true\n    },\n  })\n\n  if (!selectedItem) return null\n  return <>{cursor}</>\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/move-tool.tsx",
    "content": "import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'\nimport { Vector3 } from 'three'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { MoveDoorTool } from '../door/move-door-tool'\nimport { MoveRoofTool } from '../roof/move-roof-tool'\nimport { MoveWindowTool } from '../window/move-window-tool'\nimport type { PlacementState } from './placement-types'\nimport { useDraftNode } from './use-draft-node'\nimport { usePlacementCoordinator } from './use-placement-coordinator'\n\nfunction getInitialState(node: {\n  asset: { attachTo?: string }\n  parentId: string | null\n}): PlacementState {\n  const attachTo = node.asset.attachTo\n  if (attachTo === 'wall' || attachTo === 'wall-side') {\n    return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }\n  }\n  if (attachTo === 'ceiling') {\n    return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }\n  }\n  return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }\n}\n\nfunction MoveItemContent({ movingNode }: { movingNode: ItemNode }) {\n  const draftNode = useDraftNode()\n\n  const meta =\n    typeof movingNode.metadata === 'object' && movingNode.metadata !== null\n      ? (movingNode.metadata as Record<string, unknown>)\n      : {}\n  const isNew = !!meta.isNew\n\n  const cursor = usePlacementCoordinator({\n    asset: movingNode.asset,\n    draftNode,\n    // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft\n    initialState: isNew\n      ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }\n      : getInitialState(movingNode),\n    // Preserve the original item's scale so Y-position calculations use the correct height\n    defaultScale: isNew ? movingNode.scale : undefined,\n    initDraft: (gridPosition) => {\n      if (isNew) {\n        // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.\n        // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.\n        gridPosition.copy(new Vector3(...movingNode.position))\n        if (!movingNode.asset.attachTo) {\n          draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)\n        }\n      } else {\n        draftNode.adopt(movingNode)\n        gridPosition.copy(new Vector3(...movingNode.position))\n      }\n    },\n    onCommitted: () => {\n      sfxEmitter.emit('sfx:item-place')\n      useEditor.getState().setMovingNode(null)\n      return false\n    },\n    onCancel: () => {\n      draftNode.destroy()\n      useEditor.getState().setMovingNode(null)\n    },\n  })\n\n  return <>{cursor}</>\n}\n\nexport const MoveTool: React.FC = () => {\n  const movingNode = useEditor((state) => state.movingNode)\n\n  if (!movingNode) return null\n  if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />\n  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />\n  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')\n    return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />\n  return <MoveItemContent movingNode={movingNode as ItemNode} />\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/placement-math.ts",
    "content": "import { isObject } from '@pascal-app/core'\n\n/**\n * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.\n * For items with dimensions like 2.5, the center would be at 1.25 from the edge,\n * which doesn't align with 0.5 grid. This adds an offset so edges align instead.\n */\nexport function snapToGrid(position: number, dimension: number): number {\n  const halfDim = dimension / 2\n  const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01\n  const offset = needsOffset ? 0.25 : 0\n  return Math.round((position - offset) * 2) / 2 + offset\n}\n\n/**\n * Snap a value to 0.5 increments (used for wall-local positions).\n */\nexport function snapToHalf(value: number): number {\n  return Math.round(value * 2) / 2\n}\n\n/**\n * Calculate cursor rotation in WORLD space from wall normal and orientation.\n */\nexport function calculateCursorRotation(\n  normal: [number, number, number] | undefined,\n  wallStart: [number, number],\n  wallEnd: [number, number],\n): number {\n  if (!normal) return 0\n\n  // Wall direction angle in world XZ plane\n  const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])\n\n  // In local wall space, front face has normal.z < 0, back face has normal.z > 0\n  if (normal[2] < 0) {\n    return -wallAngle\n  }\n  return Math.PI - wallAngle\n}\n\n/**\n * Calculate item rotation in WALL-LOCAL space from normal.\n * Items are children of the wall mesh, so their rotation is relative to wall's local space.\n */\nexport function calculateItemRotation(normal: [number, number, number] | undefined): number {\n  if (!normal) return 0\n\n  return normal[2] > 0 ? 0 : Math.PI\n}\n\n/**\n * Determine which side of the wall based on the normal vector.\n * In wall-local space, the wall runs along X-axis, so the normal points along Z-axis.\n * Positive Z normal = 'front', Negative Z normal = 'back'\n */\nexport function getSideFromNormal(normal: [number, number, number] | undefined): 'front' | 'back' {\n  if (!normal) return 'front'\n  return normal[2] >= 0 ? 'front' : 'back'\n}\n\n/**\n * Check if the normal indicates a valid wall side face (front or back).\n * Filters out top face and thickness edges.\n *\n * In wall-local geometry space (after ExtrudeGeometry + rotateX):\n * - X axis: along wall direction\n * - Y axis: up (height)\n * - Z axis: perpendicular to wall (thickness direction)\n *\n * So valid side faces have normals pointing in ±Z direction (local space).\n */\nexport function isValidWallSideFace(normal: [number, number, number] | undefined): boolean {\n  if (!normal) return false\n  return Math.abs(normal[2]) > 0.7\n}\n\n/**\n * Strip the `isTransient` flag from node metadata before committing.\n */\nexport function stripTransient(meta: any): any {\n  if (!isObject(meta)) return meta\n  const { isTransient, ...rest } = meta as Record<string, any>\n  return rest\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/placement-strategies.ts",
    "content": "import type {\n  AnyNode,\n  AnyNodeId,\n  CeilingEvent,\n  CeilingNode,\n  GridEvent,\n  ItemEvent,\n  ItemNode,\n  WallEvent,\n  WallNode,\n} from '@pascal-app/core'\nimport { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'\nimport { Vector3 } from 'three'\nimport {\n  calculateCursorRotation,\n  calculateItemRotation,\n  getSideFromNormal,\n  isValidWallSideFace,\n  snapToGrid,\n  snapToHalf,\n  stripTransient,\n} from './placement-math'\nimport type {\n  CommitResult,\n  LevelResolver,\n  PlacementContext,\n  PlacementResult,\n  SpatialValidators,\n  TransitionResult,\n} from './placement-types'\n\nconst DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]\n\n// ============================================================================\n// FLOOR STRATEGY\n// ============================================================================\n\nexport const floorStrategy = {\n  /**\n   * Handle grid:move — update position when on floor surface.\n   * Returns null if currently on wall/ceiling.\n   */\n  move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {\n    if (ctx.state.surface !== 'floor') return null\n\n    const dims = ctx.draftItem\n      ? getScaledDimensions(ctx.draftItem)\n      : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)\n    const [dimX, , dimZ] = dims\n    const x = snapToGrid(event.position[0], dimX)\n    const z = snapToGrid(event.position[2], dimZ)\n\n    return {\n      gridPosition: [x, 0, z],\n      cursorPosition: [x, event.position[1], z],\n      cursorRotationY: 0,\n      nodeUpdate: { position: [x, 0, z] },\n      stopPropagation: false,\n      dirtyNodeId: null,\n    }\n  },\n\n  /**\n   * Handle grid:click — commit placement on floor.\n   * Returns null if on wall/ceiling or validation fails.\n   */\n  click(\n    ctx: PlacementContext,\n    _event: GridEvent,\n    validators: SpatialValidators,\n  ): CommitResult | null {\n    if (ctx.state.surface !== 'floor') return null\n    if (!(ctx.levelId && ctx.draftItem)) return null\n\n    const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]\n    const valid = validators.canPlaceOnFloor(\n      ctx.levelId,\n      pos,\n      getScaledDimensions(ctx.draftItem),\n      [0, 0, 0],\n      [ctx.draftItem.id],\n    ).valid\n\n    if (!valid) return null\n\n    return {\n      nodeUpdate: {\n        position: pos,\n        parentId: ctx.levelId,\n        metadata: stripTransient(ctx.draftItem.metadata),\n      },\n      stopPropagation: false,\n      dirtyNodeId: null,\n    }\n  },\n}\n\n// ============================================================================\n// WALL STRATEGY\n// ============================================================================\n\nexport const wallStrategy = {\n  /**\n   * Handle wall:enter — transition from floor to wall surface.\n   * Returns null if item doesn't attach to walls, face is invalid, or wrong level.\n   * Auto-adjusts Y position to fit within wall bounds.\n   */\n  enter(\n    ctx: PlacementContext,\n    event: WallEvent,\n    resolveLevelId: LevelResolver,\n    nodes: Record<string, AnyNode>,\n    validators: SpatialValidators,\n  ): TransitionResult | null {\n    const attachTo = ctx.asset.attachTo\n    if (attachTo !== 'wall' && attachTo !== 'wall-side') return null\n    if (!isValidWallSideFace(event.normal)) return null\n\n    // Level guard\n    const wallLevelId = resolveLevelId(event.node, nodes)\n    if (ctx.levelId !== wallLevelId) return null\n\n    const side = getSideFromNormal(event.normal)\n    const itemRotation = calculateItemRotation(event.normal)\n    const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n    const x = snapToHalf(event.localPosition[0])\n    const y = snapToHalf(event.localPosition[1])\n    const z = snapToHalf(event.localPosition[2])\n\n    // Get auto-adjusted Y position from validator\n    const validation = validators.canPlaceOnWall(\n      ctx.levelId,\n      event.node.id,\n      x,\n      y,\n      ctx.draftItem\n        ? getScaledDimensions(ctx.draftItem)\n        : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),\n      attachTo,\n      side,\n      [],\n    )\n\n    const adjustedY = validation.adjustedY ?? y\n\n    return {\n      stateUpdate: { surface: 'wall', wallId: event.node.id },\n      nodeUpdate: {\n        position: [x, adjustedY, z],\n        parentId: event.node.id,\n        side,\n        rotation: [0, itemRotation, 0],\n      },\n      cursorRotationY: cursorRotation,\n      gridPosition: [x, adjustedY, z],\n      cursorPosition: [\n        snapToHalf(event.position[0]),\n        snapToHalf(event.position[1]),\n        snapToHalf(event.position[2]),\n      ],\n      stopPropagation: true,\n    }\n  },\n\n  /**\n   * Handle wall:move — update position while on wall.\n   * Returns null if not on a wall or face is invalid.\n   * Auto-adjusts Y position to fit within wall bounds.\n   */\n  move(\n    ctx: PlacementContext,\n    event: WallEvent,\n    validators: SpatialValidators,\n  ): PlacementResult | null {\n    if (ctx.state.surface !== 'wall') return null\n    if (!(ctx.draftItem && ctx.levelId)) return null\n    if (!isValidWallSideFace(event.normal)) return null\n\n    const side = getSideFromNormal(event.normal)\n    const itemRotation = calculateItemRotation(event.normal)\n    const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n    const snappedX = snapToHalf(event.localPosition[0])\n    const snappedY = snapToHalf(event.localPosition[1])\n    const snappedZ = snapToHalf(event.localPosition[2])\n\n    // Get auto-adjusted Y position from validator\n    const validation = validators.canPlaceOnWall(\n      ctx.levelId,\n      event.node.id,\n      snappedX,\n      snappedY,\n      getScaledDimensions(ctx.draftItem),\n      ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',\n      side,\n      [ctx.draftItem.id],\n    )\n\n    const adjustedY = validation.adjustedY ?? snappedY\n\n    return {\n      gridPosition: [snappedX, adjustedY, snappedZ],\n      cursorPosition: [\n        snapToHalf(event.position[0]),\n        snapToHalf(event.position[1]),\n        snapToHalf(event.position[2]),\n      ],\n      cursorRotationY: cursorRotation,\n      nodeUpdate: {\n        position: [snappedX, adjustedY, snappedZ],\n        side,\n        rotation: [0, itemRotation, 0],\n      },\n      stopPropagation: true,\n      dirtyNodeId: event.node.id,\n    }\n  },\n\n  /**\n   * Handle wall:click — commit placement on wall.\n   * Returns null if not on wall, face invalid, or validation fails.\n   */\n  click(\n    ctx: PlacementContext,\n    event: WallEvent,\n    validators: SpatialValidators,\n  ): CommitResult | null {\n    if (ctx.state.surface !== 'wall') return null\n    if (!isValidWallSideFace(event.normal)) return null\n    if (!(ctx.levelId && ctx.draftItem)) return null\n\n    const valid = validators.canPlaceOnWall(\n      ctx.levelId,\n      ctx.state.wallId as WallNode['id'],\n      ctx.gridPosition.x,\n      ctx.gridPosition.y,\n      getScaledDimensions(ctx.draftItem),\n      ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',\n      ctx.draftItem.side,\n      [ctx.draftItem.id],\n    ).valid\n\n    if (!valid) return null\n\n    return {\n      nodeUpdate: {\n        position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n        parentId: event.node.id,\n        side: ctx.draftItem.side,\n        rotation: ctx.draftItem.rotation,\n        metadata: stripTransient(ctx.draftItem.metadata),\n      },\n      stopPropagation: true,\n      dirtyNodeId: event.node.id,\n    }\n  },\n\n  /**\n   * Handle wall:leave — transition back to floor surface.\n   */\n  leave(ctx: PlacementContext): TransitionResult | null {\n    if (ctx.state.surface !== 'wall') return null\n\n    return {\n      stateUpdate: { surface: 'floor', wallId: null },\n      nodeUpdate: {\n        position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n        parentId: ctx.levelId,\n      },\n      cursorRotationY: 0,\n      gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n      cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n      stopPropagation: true,\n    }\n  },\n}\n\n// ============================================================================\n// CEILING STRATEGY\n// ============================================================================\n\nexport const ceilingStrategy = {\n  /**\n   * Handle ceiling:enter — transition from floor to ceiling surface.\n   * Returns null if item doesn't attach to ceilings or wrong level.\n   */\n  enter(\n    ctx: PlacementContext,\n    event: CeilingEvent,\n    resolveLevelId: LevelResolver,\n    nodes: Record<string, AnyNode>,\n  ): TransitionResult | null {\n    if (ctx.asset.attachTo !== 'ceiling') return null\n\n    // Level guard\n    const ceilingLevelId = resolveLevelId(event.node, nodes)\n    if (ctx.levelId !== ceilingLevelId) return null\n\n    const dims = ctx.draftItem\n      ? getScaledDimensions(ctx.draftItem)\n      : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)\n    const [dimX, , dimZ] = dims\n    const itemHeight = dims[1]\n\n    const x = snapToGrid(event.position[0], dimX)\n    const z = snapToGrid(event.position[2], dimZ)\n\n    return {\n      stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },\n      nodeUpdate: {\n        position: [x, -itemHeight, z],\n        parentId: event.node.id,\n      },\n      cursorRotationY: 0,\n      gridPosition: [x, -itemHeight, z],\n      cursorPosition: [x, event.position[1] - itemHeight, z],\n      stopPropagation: true,\n    }\n  },\n\n  /**\n   * Handle ceiling:move — update position while on ceiling.\n   */\n  move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {\n    if (ctx.state.surface !== 'ceiling') return null\n    if (!ctx.draftItem) return null\n\n    const dims = getScaledDimensions(ctx.draftItem)\n    const [dimX, , dimZ] = dims\n    const itemHeight = dims[1]\n\n    const x = snapToGrid(event.position[0], dimX)\n    const z = snapToGrid(event.position[2], dimZ)\n\n    return {\n      gridPosition: [x, -itemHeight, z],\n      cursorPosition: [x, event.position[1] - itemHeight, z],\n      cursorRotationY: 0,\n      nodeUpdate: null,\n      stopPropagation: true,\n      dirtyNodeId: null,\n    }\n  },\n\n  /**\n   * Handle ceiling:click — commit placement on ceiling.\n   */\n  click(\n    ctx: PlacementContext,\n    event: CeilingEvent,\n    validators: SpatialValidators,\n  ): CommitResult | null {\n    if (ctx.state.surface !== 'ceiling') return null\n    if (!ctx.draftItem) return null\n\n    const pos: [number, number, number] = [\n      ctx.gridPosition.x,\n      ctx.gridPosition.y,\n      ctx.gridPosition.z,\n    ]\n\n    const valid = validators.canPlaceOnCeiling(\n      ctx.state.ceilingId as CeilingNode['id'],\n      pos,\n      getScaledDimensions(ctx.draftItem),\n      ctx.draftItem.rotation,\n      [ctx.draftItem.id],\n    ).valid\n\n    if (!valid) return null\n\n    return {\n      nodeUpdate: {\n        position: pos,\n        parentId: event.node.id,\n        metadata: stripTransient(ctx.draftItem.metadata),\n      },\n      stopPropagation: true,\n      dirtyNodeId: null,\n    }\n  },\n\n  /**\n   * Handle ceiling:leave — transition back to floor surface.\n   */\n  leave(ctx: PlacementContext): TransitionResult | null {\n    if (ctx.state.surface !== 'ceiling') return null\n\n    return {\n      stateUpdate: { surface: 'floor', ceilingId: null },\n      nodeUpdate: {\n        position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n        parentId: ctx.levelId,\n      },\n      cursorRotationY: 0,\n      gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n      cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n      stopPropagation: true,\n    }\n  },\n}\n\n// ============================================================================\n// ITEM SURFACE STRATEGY\n// ============================================================================\n\nexport const itemSurfaceStrategy = {\n  /**\n   * Handle item:enter — transition from floor to an item surface.\n   * Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.\n   */\n  enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {\n    // Only floor items can be placed on surfaces\n    if (ctx.asset.attachTo) return null\n\n    const surfaceItem = event.node as ItemNode\n    // Don't surface-place on the draft itself\n    if (surfaceItem.id === ctx.draftItem?.id) return null\n    // Surface item must declare a surface\n    if (!surfaceItem.asset.surface) return null\n\n    // Size check: our footprint must fit on surface item's footprint\n    const ourDims = ctx.draftItem\n      ? getScaledDimensions(ctx.draftItem)\n      : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)\n    const surfDims = getScaledDimensions(surfaceItem)\n    if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null\n\n    const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)\n    if (!surfaceMesh) return null\n\n    const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])\n    const localPos = surfaceMesh.worldToLocal(worldPos)\n\n    const x = snapToGrid(localPos.x, ourDims[0])\n    const z = snapToGrid(localPos.z, ourDims[2])\n    const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]\n\n    const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))\n\n    return {\n      stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },\n      nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },\n      cursorRotationY: 0,\n      gridPosition: [x, y, z],\n      cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],\n      stopPropagation: true,\n    }\n  },\n\n  /**\n   * Handle item:move — update position while on an item surface.\n   */\n  move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {\n    if (ctx.state.surface !== 'item-surface') return null\n    if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null\n\n    const nodes = useScene.getState().nodes\n    const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined\n    if (!surfaceItem?.asset.surface) return null\n\n    const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)\n    if (!surfaceMesh) return null\n\n    const ourDims = getScaledDimensions(ctx.draftItem)\n    const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])\n    const localPos = surfaceMesh.worldToLocal(worldPos)\n\n    const x = snapToGrid(localPos.x, ourDims[0])\n    const z = snapToGrid(localPos.z, ourDims[2])\n    const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]\n\n    const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))\n\n    return {\n      gridPosition: [x, y, z],\n      cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],\n      cursorRotationY: 0,\n      nodeUpdate: { position: [x, y, z] },\n      stopPropagation: true,\n      dirtyNodeId: null,\n    }\n  },\n\n  /**\n   * Handle item:click — commit placement on item surface.\n   */\n  click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {\n    if (ctx.state.surface !== 'item-surface') return null\n    if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null\n\n    return {\n      nodeUpdate: {\n        position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n        parentId: ctx.state.surfaceItemId,\n        metadata: stripTransient(ctx.draftItem.metadata),\n      },\n      stopPropagation: true,\n      dirtyNodeId: null,\n    }\n  },\n}\n\n// ============================================================================\n// VALIDATION\n// ============================================================================\n\n/**\n * Unified validation: check if the current draft item can be placed at its current position.\n * Switches on the active surface type and calls the appropriate spatial validator.\n */\nexport function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {\n  if (!(ctx.levelId && ctx.draftItem)) return false\n\n  // Item surface: valid if we entered (size check was in enter)\n  if (ctx.state.surface === 'item-surface') {\n    return ctx.state.surfaceItemId !== null\n  }\n\n  const attachTo = ctx.draftItem.asset.attachTo\n\n  if (attachTo === 'ceiling') {\n    if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false\n    return validators.canPlaceOnCeiling(\n      ctx.state.ceilingId as CeilingNode['id'],\n      [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],\n      getScaledDimensions(ctx.draftItem),\n      ctx.draftItem.rotation,\n      [ctx.draftItem.id],\n    ).valid\n  }\n\n  if (attachTo === 'wall' || attachTo === 'wall-side') {\n    if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false\n    return validators.canPlaceOnWall(\n      ctx.levelId,\n      ctx.state.wallId as WallNode['id'],\n      ctx.gridPosition.x,\n      ctx.gridPosition.y,\n      getScaledDimensions(ctx.draftItem),\n      attachTo,\n      ctx.draftItem.side,\n      [ctx.draftItem.id],\n    ).valid\n  }\n\n  // Floor (no attachTo)\n  return validators.canPlaceOnFloor(\n    ctx.levelId,\n    [ctx.gridPosition.x, 0, ctx.gridPosition.z],\n    getScaledDimensions(ctx.draftItem),\n    [0, 0, 0],\n    [ctx.draftItem.id],\n  ).valid\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/placement-types.ts",
    "content": "import type {\n  AnyNode,\n  AssetInput,\n  CeilingNode,\n  ItemNode,\n  LevelNode,\n  WallNode,\n} from '@pascal-app/core'\nimport type { Vector3 } from 'three'\n\n// ============================================================================\n// PLACEMENT STATE\n// ============================================================================\n\nexport type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'\n\n/**\n * Tracks which surface the draft item is currently on.\n * Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables.\n */\nexport interface PlacementState {\n  surface: SurfaceType\n  wallId: string | null\n  ceilingId: string | null\n  surfaceItemId: string | null\n}\n\n// ============================================================================\n// STRATEGY CONTEXT\n// ============================================================================\n\n/**\n * Read-only snapshot passed to every strategy call.\n */\nexport interface PlacementContext {\n  asset: AssetInput\n  levelId: LevelNode['id'] | null\n  draftItem: ItemNode | null\n  gridPosition: Vector3\n  state: PlacementState\n}\n\n// ============================================================================\n// STRATEGY RESULTS\n// ============================================================================\n\n/**\n * Returned by strategy move handlers.\n */\nexport interface PlacementResult {\n  gridPosition: [number, number, number]\n  cursorPosition: [number, number, number]\n  cursorRotationY: number\n  nodeUpdate: Partial<ItemNode> | null\n  stopPropagation: boolean\n  dirtyNodeId: AnyNode['id'] | null\n}\n\n/**\n * Returned by enter/leave handlers (surface transitions).\n */\nexport interface TransitionResult {\n  stateUpdate: Partial<PlacementState>\n  nodeUpdate: Partial<ItemNode>\n  gridPosition: [number, number, number]\n  cursorPosition: [number, number, number]\n  cursorRotationY: number\n  stopPropagation: boolean\n}\n\n/**\n * Returned by click handlers (commit placement).\n */\nexport interface CommitResult {\n  nodeUpdate: Partial<ItemNode>\n  stopPropagation: boolean\n  dirtyNodeId: AnyNode['id'] | null\n}\n\n// ============================================================================\n// SPATIAL VALIDATORS\n// ============================================================================\n\n/**\n * Type for the useSpatialQuery() return value.\n */\nexport interface SpatialValidators {\n  canPlaceOnFloor: (\n    levelId: LevelNode['id'],\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n    ignoreIds?: string[],\n  ) => { valid: boolean }\n  canPlaceOnWall: (\n    levelId: LevelNode['id'],\n    wallId: WallNode['id'],\n    localX: number,\n    localY: number,\n    dimensions: [number, number, number],\n    attachType: 'wall' | 'wall-side',\n    side?: 'front' | 'back',\n    ignoreIds?: string[],\n  ) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }\n  canPlaceOnCeiling: (\n    ceilingId: CeilingNode['id'],\n    position: [number, number, number],\n    dimensions: [number, number, number],\n    rotation: [number, number, number],\n    ignoreIds?: string[],\n  ) => { valid: boolean }\n}\n\n/**\n * Resolver function type for finding a node's level.\n */\nexport type LevelResolver = (node: AnyNode, nodes: Record<string, AnyNode>) => string\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/use-draft-node.ts",
    "content": "import {\n  type AnyNodeId,\n  type AssetInput,\n  ItemNode,\n  sceneRegistry,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback, useMemo, useRef } from 'react'\nimport type { Vector3 } from 'three'\nimport { stripTransient } from './placement-math'\n\ninterface OriginalState {\n  position: [number, number, number]\n  rotation: [number, number, number]\n  side: ItemNode['side']\n  parentId: string | null\n  metadata: ItemNode['metadata']\n}\n\nexport interface DraftNodeHandle {\n  /** Current draft item, or null */\n  readonly current: ItemNode | null\n  /** Whether the current draft was adopted (move mode) vs created (create mode) */\n  readonly isAdopted: boolean\n  /** Create a new draft item at the given position. Returns the created node or null. */\n  create: (\n    gridPosition: Vector3,\n    asset: AssetInput,\n    rotation?: [number, number, number],\n    scale?: [number, number, number],\n  ) => ItemNode | null\n  /** Take ownership of an existing scene node as the draft (for move mode). */\n  adopt: (node: ItemNode) => void\n  /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */\n  commit: (finalUpdate: Partial<ItemNode>) => string | null\n  /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */\n  destroy: () => void\n}\n\n/**\n * Hook that manages the lifecycle of a transient (draft) item node.\n * Handles temporal pause/resume for undo/redo isolation.\n *\n * Supports two modes:\n * - Create mode (via `create()`): draft is a new transient node. Commit = delete+recreate (undo removes node).\n * - Move mode (via `adopt()`): draft is an existing node. Commit = update in place (undo reverts position).\n */\nexport function useDraftNode(): DraftNodeHandle {\n  const draftRef = useRef<ItemNode | null>(null)\n  const adoptedRef = useRef(false)\n  const originalStateRef = useRef<OriginalState | null>(null)\n\n  const create = useCallback(\n    (\n      gridPosition: Vector3,\n      asset: AssetInput,\n      rotation?: [number, number, number],\n      scale?: [number, number, number],\n    ): ItemNode | null => {\n      const currentLevelId = useViewer.getState().selection.levelId\n      if (!currentLevelId) return null\n\n      const node = ItemNode.parse({\n        position: [gridPosition.x, gridPosition.y, gridPosition.z],\n        rotation: rotation ?? [0, 0, 0],\n        scale: scale ?? [1, 1, 1],\n        name: asset.name,\n        asset,\n        parentId: currentLevelId,\n        metadata: { isTransient: true },\n      })\n\n      useScene.getState().createNode(node, currentLevelId)\n      draftRef.current = node\n      adoptedRef.current = false\n      originalStateRef.current = null\n      return node\n    },\n    [],\n  )\n\n  const adopt = useCallback((node: ItemNode): void => {\n    // Save original state so destroy() can restore it\n    const meta =\n      typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)\n        ? (node.metadata as Record<string, unknown>)\n        : {}\n\n    originalStateRef.current = {\n      position: [...node.position] as [number, number, number],\n      rotation: [...node.rotation] as [number, number, number],\n      side: node.side,\n      parentId: node.parentId,\n      metadata: node.metadata,\n    }\n\n    draftRef.current = node\n    adoptedRef.current = true\n\n    // Mark as transient so it renders as a draft\n    useScene.getState().updateNode(node.id, {\n      metadata: { ...meta, isTransient: true },\n    })\n  }, [])\n\n  const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {\n    const draft = draftRef.current\n    if (!draft) return null\n\n    if (adoptedRef.current) {\n      // Move mode: update in place (single undoable action)\n      const { parentId: newParentId, ...updateProps } = finalUpdate\n      const parentId =\n        newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId\n      const original = originalStateRef.current!\n\n      // Restore original state while paused — so the undo baseline is clean\n      useScene.getState().updateNode(draft.id, {\n        position: original.position,\n        rotation: original.rotation,\n        side: original.side,\n        parentId: original.parentId,\n        metadata: original.metadata,\n      })\n\n      // Resume → tracked update (undo reverts to original)\n      useScene.temporal.getState().resume()\n\n      useScene.getState().updateNode(draft.id, {\n        position: updateProps.position ?? draft.position,\n        rotation: updateProps.rotation ?? draft.rotation,\n        side: updateProps.side ?? draft.side,\n        metadata: updateProps.metadata ?? stripTransient(draft.metadata),\n        parentId: parentId as string,\n      })\n\n      useScene.temporal.getState().pause()\n\n      const id = draft.id\n      draftRef.current = null\n      adoptedRef.current = false\n      originalStateRef.current = null\n      return id\n    }\n\n    // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause\n    const { parentId: newParentId, ...updateProps } = finalUpdate\n    const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId\n    if (!parentId) return null\n\n    // Delete draft while paused (invisible to undo)\n    useScene.getState().deleteNode(draft.id)\n    draftRef.current = null\n\n    // Briefly resume → create fresh node (the single undoable action)\n    useScene.temporal.getState().resume()\n\n    const finalNode = ItemNode.parse({\n      name: draft.name,\n      asset: draft.asset,\n      position: updateProps.position ?? draft.position,\n      rotation: updateProps.rotation ?? draft.rotation,\n      side: updateProps.side ?? draft.side,\n      metadata: updateProps.metadata ?? stripTransient(draft.metadata),\n    })\n    useScene.getState().createNode(finalNode, parentId)\n\n    // Re-pause for next draft cycle\n    useScene.temporal.getState().pause()\n\n    adoptedRef.current = false\n    originalStateRef.current = null\n    return finalNode.id\n  }, [])\n\n  const destroy = useCallback(() => {\n    if (!draftRef.current) return\n\n    if (adoptedRef.current && originalStateRef.current) {\n      // Move mode: restore original state instead of deleting\n      const original = originalStateRef.current\n      const id = draftRef.current.id\n\n      useScene.getState().updateNode(id, {\n        position: original.position,\n        rotation: original.rotation,\n        side: original.side,\n        parentId: original.parentId,\n        metadata: original.metadata,\n      })\n\n      // Also reset the Three.js mesh directly — the store update triggers a React\n      // re-render but the mesh position was mutated by useFrame and may not reset\n      // until the next render cycle, leaving a visual glitch.\n      const mesh = sceneRegistry.nodes.get(id as AnyNodeId)\n      if (mesh) {\n        mesh.position.set(original.position[0], original.position[1], original.position[2])\n        mesh.rotation.y = original.rotation[1] ?? 0\n        mesh.visible = true\n      }\n    } else {\n      // Create mode: delete the transient node\n      useScene.getState().deleteNode(draftRef.current.id)\n    }\n\n    draftRef.current = null\n    adoptedRef.current = false\n    originalStateRef.current = null\n  }, [])\n\n  return useMemo(\n    () => ({\n      get current() {\n        return draftRef.current\n      },\n      get isAdopted() {\n        return adoptedRef.current\n      },\n      create,\n      adopt,\n      commit,\n      destroy,\n    }),\n    [create, adopt, commit, destroy],\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/item/use-placement-coordinator.tsx",
    "content": "import type { AssetInput } from '@pascal-app/core'\nimport {\n  type AnyNodeId,\n  type CeilingEvent,\n  emitter,\n  type GridEvent,\n  getScaledDimensions,\n  type ItemEvent,\n  resolveLevelId,\n  sceneRegistry,\n  spatialGridManager,\n  useScene,\n  useSpatialQuery,\n  type WallEvent,\n  type WallNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useFrame } from '@react-three/fiber'\nimport { useEffect, useRef } from 'react'\nimport {\n  BoxGeometry,\n  EdgesGeometry,\n  Euler,\n  type Group,\n  type LineSegments,\n  type Mesh,\n  PlaneGeometry,\n  Quaternion,\n  Vector3,\n} from 'three'\nimport { distance, smoothstep, uv, vec2 } from 'three/tsl'\nimport { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport {\n  ceilingStrategy,\n  checkCanPlace,\n  floorStrategy,\n  itemSurfaceStrategy,\n  wallStrategy,\n} from './placement-strategies'\nimport type { PlacementState, TransitionResult } from './placement-types'\nimport type { DraftNodeHandle } from './use-draft-node'\n\nconst DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]\n\n// Shared materials for placement cursor - we just change colors, not swap materials\n// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines\nconst edgeMaterial = new LineBasicNodeMaterial({\n  color: 0xef_44_44, // red-500 (invalid)\n  linewidth: 3,\n  depthTest: false,\n  depthWrite: false,\n})\n\nconst basePlaneMaterial = new MeshBasicNodeMaterial({\n  color: 0xef_44_44, // red-500 (invalid)\n  transparent: true,\n  depthTest: false,\n  depthWrite: false,\n})\n\n// Create radial opacity: transparent in center, opaque at edges\nconst center = vec2(0.5, 0.5)\nconst dist = distance(uv(), center)\nconst radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)\nbasePlaneMaterial.opacityNode = radialOpacity\n\nexport interface PlacementCoordinatorConfig {\n  asset: AssetInput\n  draftNode: DraftNodeHandle\n  initDraft: (gridPosition: Vector3) => void\n  onCommitted: () => boolean\n  onCancel?: () => void\n  initialState?: PlacementState\n  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */\n  defaultScale?: [number, number, number]\n}\n\nexport function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {\n  const cursorGroupRef = useRef<Group>(null!)\n  const edgesRef = useRef<LineSegments>(null!)\n  const basePlaneRef = useRef<Mesh>(null!)\n  const gridPosition = useRef(new Vector3(0, 0, 0))\n  const placementState = useRef<PlacementState>(\n    config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },\n  )\n  const shiftFreeRef = useRef(false)\n\n  // Store config callbacks in refs to avoid re-running effect when they change\n  const configRef = useRef(config)\n  configRef.current = config\n\n  const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()\n  const { asset, draftNode } = config\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }\n\n    // Reset placement state\n    placementState.current = configRef.current.initialState ?? {\n      surface: 'floor',\n      wallId: null,\n      ceilingId: null,\n      surfaceItemId: null,\n    }\n\n    // ---- Helpers ----\n\n    const getContext = () => ({\n      asset,\n      levelId: useViewer.getState().selection.levelId,\n      draftItem: draftNode.current,\n      gridPosition: gridPosition.current,\n      state: { ...placementState.current },\n    })\n\n    const getActiveValidators = () =>\n      shiftFreeRef.current\n        ? {\n            canPlaceOnFloor: () => ({ valid: true }),\n            canPlaceOnWall: () => ({ valid: true }),\n            canPlaceOnCeiling: () => ({ valid: true }),\n          }\n        : validators\n\n    const revalidate = (): boolean => {\n      const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)\n      const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500\n      edgeMaterial.color.setHex(color)\n      basePlaneMaterial.color.setHex(color)\n      return placeable\n    }\n\n    const applyTransition = (result: TransitionResult) => {\n      Object.assign(placementState.current, result.stateUpdate)\n      gridPosition.current.set(...result.gridPosition)\n\n      cursorGroupRef.current.position.set(...result.cursorPosition)\n      cursorGroupRef.current.rotation.y = result.cursorRotationY\n\n      const draft = draftNode.current\n      if (draft) {\n        // Update ref for validation — no store update during drag\n        Object.assign(draft, result.nodeUpdate)\n      }\n      revalidate()\n    }\n\n    const ensureDraft = (result: TransitionResult) => {\n      gridPosition.current.set(...result.gridPosition)\n      cursorGroupRef.current.position.set(...result.cursorPosition)\n      cursorGroupRef.current.rotation.y = result.cursorRotationY\n\n      draftNode.create(\n        gridPosition.current,\n        asset,\n        [0, result.cursorRotationY, 0],\n        configRef.current.defaultScale,\n      )\n\n      const draft = draftNode.current\n      if (draft) {\n        Object.assign(draft, result.nodeUpdate)\n        // One-time setup: put node in the right parent so it renders correctly\n        useScene.getState().updateNode(draft.id, result.nodeUpdate)\n      }\n\n      if (!revalidate()) {\n        draftNode.destroy()\n      }\n    }\n\n    // ---- Init draft ----\n    configRef.current.initDraft(gridPosition.current)\n\n    // Sync cursor to the draft mesh's world position and rotation\n    if (draftNode.current) {\n      const mesh = sceneRegistry.nodes.get(draftNode.current.id)\n      if (mesh) {\n        mesh.getWorldPosition(cursorGroupRef.current.position)\n        // Extract world Y rotation (handles wall-parented items correctly)\n        const q = new Quaternion()\n        mesh.getWorldQuaternion(q)\n        cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y\n      } else {\n        cursorGroupRef.current.position.copy(gridPosition.current)\n        cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0\n      }\n    }\n\n    revalidate()\n\n    // ---- Floor Handlers ----\n\n    let previousGridPos: [number, number, number] | null = null\n\n    const onGridMove = (event: GridEvent) => {\n      const result = floorStrategy.move(getContext(), event)\n      if (!result) return\n\n      // Play snap sound when grid position changes\n      if (\n        previousGridPos &&\n        (result.gridPosition[0] !== previousGridPos[0] ||\n          result.gridPosition[2] !== previousGridPos[2])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousGridPos = [...result.gridPosition]\n      gridPosition.current.set(...result.gridPosition)\n      // Only update X and Z for cursor - useFrame will handle Y (slab elevation)\n      cursorGroupRef.current.position.x = result.cursorPosition[0]\n      cursorGroupRef.current.position.z = result.cursorPosition[2]\n\n      const draft = draftNode.current\n      if (draft) draft.position = result.gridPosition\n\n      revalidate()\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      const result = floorStrategy.click(getContext(), event, getActiveValidators())\n      if (!result) return\n\n      // Preserve cursor rotation for the next draft\n      const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]\n\n      draftNode.commit(result.nodeUpdate)\n      if (configRef.current.onCommitted()) {\n        draftNode.create(gridPosition.current, asset, currentRotation)\n        revalidate()\n      }\n    }\n\n    // ---- Wall Handlers ----\n\n    const onWallEnter = (event: WallEvent) => {\n      const nodes = useScene.getState().nodes\n      const result = wallStrategy.enter(\n        getContext(),\n        event,\n        resolveLevelId,\n        nodes,\n        getActiveValidators(),\n      )\n      if (!result) return\n\n      event.stopPropagation()\n      applyTransition(result)\n\n      if (!draftNode.current) {\n        ensureDraft(result)\n      } else if (result.nodeUpdate.parentId) {\n        // Existing draft (move mode): reparent to new wall\n        useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)\n        if (result.stateUpdate.wallId) {\n          useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)\n        }\n      }\n    }\n\n    const onWallMove = (event: WallEvent) => {\n      const ctx = getContext()\n\n      if (ctx.state.surface !== 'wall') {\n        const nodes = useScene.getState().nodes\n        const enterResult = wallStrategy.enter(\n          ctx,\n          event,\n          resolveLevelId,\n          nodes,\n          getActiveValidators(),\n        )\n        if (!enterResult) return\n\n        event.stopPropagation()\n        applyTransition(enterResult)\n        if (draftNode.current && enterResult.nodeUpdate.parentId) {\n          useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)\n          if (enterResult.stateUpdate.wallId) {\n            useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)\n          }\n        }\n        return\n      }\n\n      if (!draftNode.current) {\n        const nodes = useScene.getState().nodes\n        const setup = wallStrategy.enter(\n          getContext(),\n          event,\n          resolveLevelId,\n          nodes,\n          getActiveValidators(),\n        )\n        if (!setup) return\n\n        event.stopPropagation()\n        ensureDraft(setup)\n        return\n      }\n\n      const result = wallStrategy.move(ctx, event, getActiveValidators())\n      if (!result) return\n\n      event.stopPropagation()\n\n      const posChanged =\n        gridPosition.current.x !== result.gridPosition[0] ||\n        gridPosition.current.y !== result.gridPosition[1] ||\n        gridPosition.current.z !== result.gridPosition[2]\n\n      // Play snap sound when grid position changes\n      if (posChanged) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      gridPosition.current.set(...result.gridPosition)\n      cursorGroupRef.current.position.set(...result.cursorPosition)\n      cursorGroupRef.current.rotation.y = result.cursorRotationY\n\n      const draft = draftNode.current\n      if (draft && result.nodeUpdate) {\n        if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side\n        if ('rotation' in result.nodeUpdate)\n          draft.rotation = result.nodeUpdate.rotation as [number, number, number]\n      }\n\n      const placeable = revalidate()\n\n      if (draft && placeable) {\n        draft.position = result.gridPosition\n        const mesh = sceneRegistry.nodes.get(draft.id)\n        if (mesh) {\n          mesh.position.copy(gridPosition.current)\n          const rot = result.nodeUpdate?.rotation\n          if (rot) mesh.rotation.y = rot[1]\n\n          // Push wall-side items out by half the parent wall's thickness\n          if (asset.attachTo === 'wall-side' && placementState.current.wallId) {\n            const parentWall = useScene.getState().nodes[placementState.current.wallId as AnyNodeId]\n            if (parentWall?.type === 'wall') {\n              const wallThickness = (parentWall as WallNode).thickness ?? 0.1\n              mesh.position.z = (wallThickness / 2) * (draft.side === 'front' ? 1 : -1)\n            }\n          }\n        }\n        // Mark parent wall dirty so it rebuilds geometry — only when position changed\n        if (result.dirtyNodeId && posChanged) {\n          useScene.getState().dirtyNodes.add(result.dirtyNodeId)\n        }\n      }\n    }\n\n    const onWallClick = (event: WallEvent) => {\n      const result = wallStrategy.click(getContext(), event, getActiveValidators())\n      if (!result) return\n\n      event.stopPropagation()\n      draftNode.commit(result.nodeUpdate)\n      if (result.dirtyNodeId) {\n        useScene.getState().dirtyNodes.add(result.dirtyNodeId)\n      }\n\n      if (configRef.current.onCommitted()) {\n        const nodes = useScene.getState().nodes\n        const enterResult = wallStrategy.enter(\n          getContext(),\n          event,\n          resolveLevelId,\n          nodes,\n          validators,\n        )\n        if (enterResult) {\n          applyTransition(enterResult)\n        } else {\n          revalidate()\n        }\n      }\n    }\n\n    const onWallLeave = (event: WallEvent) => {\n      const result = wallStrategy.leave(getContext())\n      if (!result) return\n\n      event.stopPropagation()\n\n      if (asset.attachTo) {\n        if (draftNode.isAdopted) {\n          // Move mode: keep draft alive, reparent to level\n          const oldWallId = placementState.current.wallId\n          applyTransition(result)\n          const draft = draftNode.current\n          if (draft) {\n            useScene\n              .getState()\n              .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })\n          }\n          if (oldWallId) {\n            useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)\n          }\n        } else {\n          // Create mode: destroy transient and reset state\n          draftNode.destroy()\n          Object.assign(placementState.current, result.stateUpdate)\n        }\n      } else {\n        applyTransition(result)\n      }\n    }\n\n    // ---- Item Surface Handlers ----\n\n    const onItemEnter = (event: ItemEvent) => {\n      if (event.node.id === draftNode.current?.id) return\n      const result = itemSurfaceStrategy.enter(getContext(), event)\n      if (!result) return\n\n      event.stopPropagation()\n      applyTransition(result)\n\n      if (!draftNode.current) {\n        ensureDraft(result)\n      } else if (result.nodeUpdate.parentId) {\n        // Existing draft (move mode): reparent to surface item\n        useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)\n      }\n    }\n\n    const onItemMove = (event: ItemEvent) => {\n      if (event.node.id === draftNode.current?.id) return\n      const ctx = getContext()\n\n      if (ctx.state.surface !== 'item-surface') {\n        // Try entering surface mode\n        const enterResult = itemSurfaceStrategy.enter(ctx, event)\n        if (!enterResult) return\n\n        event.stopPropagation()\n        applyTransition(enterResult)\n        if (draftNode.current && enterResult.nodeUpdate.parentId) {\n          useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)\n        }\n        return\n      }\n\n      if (!draftNode.current) {\n        const enterResult = itemSurfaceStrategy.enter(getContext(), event)\n        if (!enterResult) return\n        event.stopPropagation()\n        ensureDraft(enterResult)\n        return\n      }\n\n      const result = itemSurfaceStrategy.move(ctx, event)\n      if (!result) return\n\n      event.stopPropagation()\n\n      gridPosition.current.set(...result.gridPosition)\n      cursorGroupRef.current.position.set(...result.cursorPosition)\n      cursorGroupRef.current.rotation.y = result.cursorRotationY\n\n      const draft = draftNode.current\n      if (draft) {\n        draft.position = result.gridPosition\n        const mesh = sceneRegistry.nodes.get(draft.id)\n        if (mesh) mesh.position.set(...result.gridPosition)\n      }\n\n      revalidate()\n    }\n\n    const onItemLeave = (event: ItemEvent) => {\n      if (event.node.id === draftNode.current?.id) return\n      if (placementState.current.surface !== 'item-surface') return\n\n      event.stopPropagation()\n\n      // Transition back to floor using event world position\n      const wx = Math.round(event.position[0] * 2) / 2\n      const wz = Math.round(event.position[2] * 2) / 2\n      const floorPos: [number, number, number] = [wx, 0, wz]\n\n      Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })\n      gridPosition.current.set(wx, 0, wz)\n      cursorGroupRef.current.position.set(wx, event.position[1], wz)\n\n      const draft = draftNode.current\n      if (draft) {\n        draft.position = floorPos\n        useScene.getState().updateNode(draft.id, {\n          parentId: useViewer.getState().selection.levelId as string,\n          position: floorPos,\n        })\n      }\n\n      revalidate()\n    }\n\n    const onItemClick = (event: ItemEvent) => {\n      if (event.node.id === draftNode.current?.id) return\n      const result = itemSurfaceStrategy.click(getContext(), event)\n      if (!result) return\n\n      event.stopPropagation()\n      draftNode.commit(result.nodeUpdate)\n\n      if (configRef.current.onCommitted()) {\n        // Try to set up next draft on the same surface\n        const enterResult = itemSurfaceStrategy.enter(getContext(), event)\n        if (enterResult) {\n          applyTransition(enterResult)\n        } else {\n          revalidate()\n        }\n      }\n    }\n\n    // ---- Ceiling Handlers ----\n\n    const onCeilingEnter = (event: CeilingEvent) => {\n      const nodes = useScene.getState().nodes\n      const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)\n      if (!result) return\n\n      event.stopPropagation()\n      applyTransition(result)\n\n      if (!draftNode.current) {\n        ensureDraft(result)\n      } else if (result.nodeUpdate.parentId) {\n        // Existing draft (move mode): reparent to new ceiling\n        useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)\n        if (result.stateUpdate.ceilingId) {\n          useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)\n        }\n      }\n    }\n\n    const onCeilingMove = (event: CeilingEvent) => {\n      if (!draftNode.current && placementState.current.surface === 'ceiling') {\n        const nodes = useScene.getState().nodes\n        const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)\n        if (!setup) return\n\n        event.stopPropagation()\n        ensureDraft(setup)\n        return\n      }\n\n      const result = ceilingStrategy.move(getContext(), event)\n      if (!result) return\n\n      event.stopPropagation()\n\n      // Play snap sound when grid position changes\n      const posChanged =\n        gridPosition.current.x !== result.gridPosition[0] ||\n        gridPosition.current.y !== result.gridPosition[1] ||\n        gridPosition.current.z !== result.gridPosition[2]\n\n      if (posChanged) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      gridPosition.current.set(...result.gridPosition)\n      cursorGroupRef.current.position.set(...result.cursorPosition)\n\n      revalidate()\n\n      const draft = draftNode.current\n      if (draft) {\n        draft.position = result.gridPosition\n        const mesh = sceneRegistry.nodes.get(draft.id)\n        if (mesh) mesh.position.copy(gridPosition.current)\n      }\n    }\n\n    const onCeilingClick = (event: CeilingEvent) => {\n      const result = ceilingStrategy.click(getContext(), event, getActiveValidators())\n      if (!result) return\n\n      event.stopPropagation()\n      draftNode.commit(result.nodeUpdate)\n\n      if (configRef.current.onCommitted()) {\n        const nodes = useScene.getState().nodes\n        const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)\n        if (enterResult) {\n          applyTransition(enterResult)\n        } else {\n          revalidate()\n        }\n      }\n    }\n\n    const onCeilingLeave = (event: CeilingEvent) => {\n      const result = ceilingStrategy.leave(getContext())\n      if (!result) return\n\n      event.stopPropagation()\n\n      if (asset.attachTo) {\n        if (draftNode.isAdopted) {\n          // Move mode: keep draft alive, reparent to level\n          const oldCeilingId = placementState.current.ceilingId\n          applyTransition(result)\n          const draft = draftNode.current\n          if (draft) {\n            useScene\n              .getState()\n              .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })\n          }\n          if (oldCeilingId) {\n            useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)\n          }\n        } else {\n          // Create mode: destroy transient and reset state\n          draftNode.destroy()\n          Object.assign(placementState.current, result.stateUpdate)\n        }\n      } else {\n        applyTransition(result)\n      }\n    }\n\n    // ---- Keyboard rotation ----\n\n    const ROTATION_STEP = Math.PI / 2\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Shift') {\n        shiftFreeRef.current = true\n        revalidate()\n        return\n      }\n\n      const draft = draftNode.current\n      if (!draft) return\n\n      let rotationDelta = 0\n      if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP\n      else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP\n\n      if (rotationDelta !== 0) {\n        event.preventDefault()\n        sfxEmitter.emit('sfx:item-rotate')\n        const currentRotation = draft.rotation\n        const newRotationY = (currentRotation[1] ?? 0) + rotationDelta\n        draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]\n\n        // Ref + cursor mesh + item mesh — no store update during drag\n        cursorGroupRef.current.rotation.y = newRotationY\n        const mesh = sceneRegistry.nodes.get(draft.id)\n        if (mesh) mesh.rotation.y = newRotationY\n        revalidate()\n      }\n    }\n\n    const onKeyUp = (event: KeyboardEvent) => {\n      if (event.key === 'Shift') {\n        shiftFreeRef.current = false\n        revalidate()\n      }\n    }\n\n    window.addEventListener('keydown', onKeyDown)\n    window.addEventListener('keyup', onKeyUp)\n\n    // ---- tool:cancel (Escape / programmatic) ----\n    const onCancel = () => {\n      if (configRef.current.onCancel) {\n        configRef.current.onCancel()\n      }\n    }\n    emitter.on('tool:cancel', onCancel)\n\n    // ---- Right-click cancel ----\n    const onContextMenu = (event: MouseEvent) => {\n      if (configRef.current.onCancel) {\n        event.preventDefault()\n        configRef.current.onCancel()\n      }\n    }\n    window.addEventListener('contextmenu', onContextMenu)\n\n    // ---- Bounding box geometry ----\n\n    const draft = draftNode.current\n    const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)\n    const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])\n    boxGeometry.translate(0, dims[1] / 2, 0)\n    const edgesGeometry = new EdgesGeometry(boxGeometry)\n    edgesRef.current.geometry = edgesGeometry\n\n    // ---- Subscribe ----\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('item:enter', onItemEnter)\n    emitter.on('item:move', onItemMove)\n    emitter.on('item:leave', onItemLeave)\n    emitter.on('item:click', onItemClick)\n    emitter.on('wall:enter', onWallEnter)\n    emitter.on('wall:move', onWallMove)\n    emitter.on('wall:click', onWallClick)\n    emitter.on('wall:leave', onWallLeave)\n    emitter.on('ceiling:enter', onCeilingEnter)\n    emitter.on('ceiling:move', onCeilingMove)\n    emitter.on('ceiling:click', onCeilingClick)\n    emitter.on('ceiling:leave', onCeilingLeave)\n\n    return () => {\n      draftNode.destroy()\n      useScene.temporal.getState().resume()\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('item:enter', onItemEnter)\n      emitter.off('item:move', onItemMove)\n      emitter.off('item:leave', onItemLeave)\n      emitter.off('item:click', onItemClick)\n      emitter.off('wall:enter', onWallEnter)\n      emitter.off('wall:move', onWallMove)\n      emitter.off('wall:click', onWallClick)\n      emitter.off('wall:leave', onWallLeave)\n      emitter.off('ceiling:enter', onCeilingEnter)\n      emitter.off('ceiling:move', onCeilingMove)\n      emitter.off('ceiling:click', onCeilingClick)\n      emitter.off('ceiling:leave', onCeilingLeave)\n      emitter.off('tool:cancel', onCancel)\n      window.removeEventListener('keydown', onKeyDown)\n      window.removeEventListener('keyup', onKeyUp)\n      window.removeEventListener('contextmenu', onContextMenu)\n    }\n  }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])\n\n  // Reparent floor draft to the new level when the user switches levels mid-placement.\n  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).\n  const viewerLevelId = useViewer((s) => s.selection.levelId)\n  useEffect(() => {\n    const draft = draftNode.current\n    if (!(draft && viewerLevelId) || asset.attachTo) return\n    if (draft.parentId === viewerLevelId) return\n    draft.parentId = viewerLevelId\n    useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })\n  }, [viewerLevelId, draftNode, asset])\n\n  useFrame((_, delta) => {\n    if (!draftNode.current) return\n    const mesh = sceneRegistry.nodes.get(draftNode.current.id)\n    if (!mesh) return\n\n    // Hide wall/ceiling-attached items when between surfaces (only cursor visible)\n    if (asset.attachTo && placementState.current.surface === 'floor') {\n      mesh.visible = false\n      return\n    }\n    mesh.visible = true\n\n    if (placementState.current.surface === 'floor') {\n      const distance = mesh.position.distanceToSquared(gridPosition.current)\n      if (distance > 1) {\n        mesh.position.copy(gridPosition.current)\n      } else {\n        mesh.position.lerp(gridPosition.current, delta * 20)\n      }\n\n      // Adjust Y for slab elevation (floor items on top of slabs)\n      if (!asset.attachTo) {\n        const nodes = useScene.getState().nodes\n        const levelId = resolveLevelId(draftNode.current, nodes)\n        const slabElevation = spatialGridManager.getSlabElevationForItem(\n          levelId,\n          [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],\n          getScaledDimensions(draftNode.current),\n          draftNode.current.rotation,\n        )\n        mesh.position.y = slabElevation\n        // Cursor group is at the world root (not inside a level group), so add the\n        // level group's current world Y to convert from level-local to world space.\n        const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)\n        cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)\n      }\n    }\n  })\n\n  const initialDraft = draftNode.current\n  const dims = initialDraft\n    ? getScaledDimensions(initialDraft)\n    : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)\n  const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])\n  initialBoxGeometry.translate(0, dims[1] / 2, 0)\n\n  // Base plane geometry (colored rectangle on the ground)\n  const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])\n  basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal\n  basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting\n\n  return (\n    <group ref={cursorGroupRef}>\n      <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}>\n        <edgesGeometry args={[initialBoxGeometry]} />\n      </lineSegments>\n      <mesh\n        geometry={basePlaneGeometry}\n        layers={EDITOR_LAYER}\n        material={basePlaneMaterial}\n        ref={basePlaneRef}\n      />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/roof/move-roof-tool.tsx",
    "content": "import {\n  type AnyNodeId,\n  emitter,\n  type GridEvent,\n  type RoofNode,\n  type RoofSegmentNode,\n  sceneRegistry,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport * as THREE from 'three'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nexport const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({\n  node: movingNode,\n}) => {\n  const exitMoveMode = useCallback(() => {\n    useEditor.getState().setMovingNode(null)\n  }, [])\n\n  const previousGridPosRef = useRef<[number, number] | null>(null)\n\n  const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {\n    const obj = sceneRegistry.nodes.get(movingNode.id)\n    if (obj) {\n      const pos = new THREE.Vector3()\n      obj.getWorldPosition(pos)\n      return [pos.x, pos.y, pos.z]\n    }\n    // Fallback if not registered (e.g. newly created duplicate without mesh yet)\n    if (movingNode.type === 'roof-segment' && movingNode.parentId) {\n      const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]\n      if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {\n        const parentAngle = parentNode.rotation as number\n        const px = parentNode.position[0] as number\n        const py = parentNode.position[1] as number\n        const pz = parentNode.position[2] as number\n        const lx = movingNode.position[0]\n        const ly = movingNode.position[1]\n        const lz = movingNode.position[2]\n\n        const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px\n        const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz\n        return [wx, py + ly, wz]\n      }\n    }\n    return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]\n  })\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const meta =\n      typeof movingNode.metadata === 'object' && movingNode.metadata !== null\n        ? (movingNode.metadata as Record<string, unknown>)\n        : {}\n    const isNew = !!meta.isNew\n    const committedMeta: RoofNode['metadata'] = (() => {\n      if (\n        typeof movingNode.metadata !== 'object' ||\n        movingNode.metadata === null ||\n        Array.isArray(movingNode.metadata)\n      ) {\n        return movingNode.metadata\n      }\n\n      const nextMeta = { ...movingNode.metadata } as Record<string, unknown>\n      delete nextMeta.isNew\n      delete nextMeta.isTransient\n      return nextMeta as RoofNode['metadata']\n    })()\n\n    const original = {\n      position: [...movingNode.position] as [number, number, number],\n      rotation: movingNode.rotation,\n      parentId: movingNode.parentId,\n      metadata: movingNode.metadata,\n    }\n\n    // Track whether the move was committed so cleanup knows whether to revert.\n    // We avoid setting isTransient on the store to prevent RoofSystem from\n    // resetting the mesh position (it resets on dirty) and from triggering\n    // expensive merged-mesh CSG rebuilds on every frame.\n    let wasCommitted = false\n\n    // Track pending rotation — no store updates during drag\n    let pendingRotation: number = movingNode.rotation as number\n\n    // For roof-segment moves: the selection was cleared before entering move mode,\n    // so isSelected=false on the parent roof, hiding individual segment meshes and\n    // showing only the merged mesh. We directly flip Three.js visibility so the\n    // user sees the individual segment tracking the cursor.\n    let segmentWrapperGroup: THREE.Object3D | null = null\n    let mergedRoofMesh: THREE.Object3D | null = null\n    if (movingNode.type === 'roof-segment') {\n      const segmentMesh = sceneRegistry.nodes.get(movingNode.id)\n      if (segmentMesh?.parent) {\n        // segmentMesh.parent = <group visible={isSelected}> wrapper in RoofRenderer\n        // segmentMesh.parent.parent = the registered roof group\n        segmentWrapperGroup = segmentMesh.parent\n        mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null\n        segmentWrapperGroup.visible = true\n        if (mergedRoofMesh) mergedRoofMesh.visible = false\n      }\n    }\n\n    const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {\n      let localX = gridX\n      let localZ = gridZ\n\n      if (movingNode.type === 'roof-segment' && movingNode.parentId) {\n        const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]\n        if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {\n          const parentObj = sceneRegistry.nodes.get(movingNode.parentId)\n          if (parentObj) {\n            const worldVec = new THREE.Vector3(gridX, y, gridZ)\n            parentObj.worldToLocal(worldVec)\n            localX = worldVec.x\n            localZ = worldVec.z\n          } else {\n            const dx = gridX - (parentNode.position[0] as number)\n            const dz = gridZ - (parentNode.position[2] as number)\n            const angle = -(parentNode.rotation as number)\n            localX = dx * Math.cos(angle) - dz * Math.sin(angle)\n            localZ = dx * Math.sin(angle) + dz * Math.cos(angle)\n          }\n        }\n      }\n\n      return [localX, localZ]\n    }\n\n    const onGridMove = (event: GridEvent) => {\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      if (\n        previousGridPosRef.current &&\n        (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousGridPosRef.current = [gridX, gridZ]\n      setCursorWorldPos([gridX, y, gridZ])\n\n      const [localX, localZ] = computeLocal(gridX, gridZ, y)\n\n      // Directly update the Three.js mesh — no store update during drag\n      const mesh = sceneRegistry.nodes.get(movingNode.id)\n      if (mesh) {\n        mesh.position.x = localX\n        mesh.position.z = localZ\n      }\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      const [localX, localZ] = computeLocal(gridX, gridZ, y)\n\n      wasCommitted = true\n\n      // The store still holds the original values (we didn't update during drag).\n      // Resume temporal and apply the final state as a single undoable step.\n      useScene.temporal.getState().resume()\n\n      useScene.getState().updateNode(movingNode.id, {\n        position: [localX, movingNode.position[1], localZ],\n        rotation: pendingRotation,\n        metadata: committedMeta,\n      })\n\n      useScene.temporal.getState().pause()\n\n      sfxEmitter.emit('sfx:item-place')\n      useViewer.getState().setSelection({ selectedIds: [movingNode.id] })\n      exitMoveMode()\n      event.nativeEvent?.stopPropagation?.()\n    }\n\n    const onCancel = () => {\n      if (isNew) {\n        useScene.getState().deleteNode(movingNode.id)\n      } else {\n        useScene.getState().updateNode(movingNode.id, {\n          position: original.position,\n          rotation: original.rotation,\n          metadata: original.metadata,\n        })\n      }\n      useScene.temporal.getState().resume()\n      exitMoveMode()\n    }\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {\n        return\n      }\n\n      const ROTATION_STEP = Math.PI / 4\n      let rotationDelta = 0\n      if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP\n      else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP\n\n      if (rotationDelta !== 0) {\n        event.preventDefault()\n        sfxEmitter.emit('sfx:item-rotate')\n\n        pendingRotation += rotationDelta\n\n        // Directly update the Three.js mesh — no store update during drag\n        const mesh = sceneRegistry.nodes.get(movingNode.id)\n        if (mesh) mesh.rotation.y = pendingRotation\n      }\n    }\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('tool:cancel', onCancel)\n    window.addEventListener('keydown', onKeyDown)\n\n    return () => {\n      // Restore segment wrapper visibility (React will re-sync on next render)\n      if (segmentWrapperGroup) segmentWrapperGroup.visible = false\n      if (mergedRoofMesh) mergedRoofMesh.visible = true\n\n      if (!wasCommitted) {\n        if (isNew) {\n          useScene.getState().deleteNode(movingNode.id)\n        } else {\n          useScene.getState().updateNode(movingNode.id, {\n            position: original.position,\n            rotation: original.rotation,\n            metadata: original.metadata,\n          })\n        }\n      }\n      useScene.temporal.getState().resume()\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('tool:cancel', onCancel)\n      window.removeEventListener('keydown', onKeyDown)\n    }\n  }, [movingNode, exitMoveMode])\n\n  return (\n    <group>\n      <CursorSphere position={cursorWorldPos} showTooltip={false} />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/roof/roof-tool.tsx",
    "content": "import {\n  type AnyNode,\n  type AnyNodeId,\n  emitter,\n  type GridEvent,\n  type LevelNode,\n  RoofNode,\n  RoofSegmentNode,\n  sceneRegistry,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport * as THREE from 'three'\nimport { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'\nimport { markToolCancelConsumed } from '../../../hooks/use-keyboard'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nconst DEFAULT_WALL_HEIGHT = 0.5\nconst DEFAULT_ROOF_HEIGHT = 2.5\nconst GRID_OFFSET = 0.02\n\n/**\n * Creates a roof group with one default gable segment\n */\nconst commitRoofPlacement = (\n  levelId: LevelNode['id'],\n  corner1: [number, number, number],\n  corner2: [number, number, number],\n  selectedIds: string[],\n): AnyNode['id'] => {\n  const { createNode, createNodes, nodes } = useScene.getState()\n\n  const centerX = (corner1[0] + corner2[0]) / 2\n  const centerZ = (corner1[2] + corner2[2]) / 2\n\n  const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1)\n  const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1)\n\n  // Determine if there is an active roof node we should add to\n  let targetRoofId: RoofNode['id'] | null = null\n  const selectedId = selectedIds[0]\n  if (selectedIds.length === 1 && selectedId) {\n    const selectedNode = nodes[selectedId as AnyNodeId]\n    if (selectedNode?.type === 'roof') {\n      targetRoofId = selectedNode.id\n    } else if (selectedNode?.type === 'roof-segment' && selectedNode.parentId) {\n      targetRoofId = selectedNode.parentId as RoofNode['id']\n    }\n  }\n\n  if (targetRoofId) {\n    const targetRoof = nodes[targetRoofId] as RoofNode\n    let localX = centerX\n    let localZ = centerZ\n\n    // Convert world coordinates to the local space of the parent roof\n    const targetObj = sceneRegistry.nodes.get(targetRoofId)\n    if (targetObj) {\n      const worldVec = new THREE.Vector3(centerX, 0, centerZ)\n      targetObj.worldToLocal(worldVec)\n      localX = worldVec.x\n      localZ = worldVec.z\n    } else {\n      // Math fallback if mesh isn't ready\n      const dx = centerX - targetRoof.position[0]\n      const dz = centerZ - targetRoof.position[2]\n      const angle = -targetRoof.rotation\n      localX = dx * Math.cos(angle) - dz * Math.sin(angle)\n      localZ = dx * Math.sin(angle) + dz * Math.cos(angle)\n    }\n\n    const segment = RoofSegmentNode.parse({\n      width,\n      depth,\n      wallHeight: DEFAULT_WALL_HEIGHT,\n      roofHeight: DEFAULT_ROOF_HEIGHT,\n      roofType: 'gable',\n      position: [localX, 0, localZ],\n    })\n\n    createNode(segment, targetRoofId as AnyNode['id'])\n    sfxEmitter.emit('sfx:structure-build')\n    return segment.id // Returns segment ID so it can be selected immediately\n  }\n\n  // Count existing roofs for naming\n  const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length\n  const name = `Roof ${roofCount + 1}`\n\n  // Create the segment first (centered in its new parent)\n  const segment = RoofSegmentNode.parse({\n    width,\n    depth,\n    wallHeight: DEFAULT_WALL_HEIGHT,\n    roofHeight: DEFAULT_ROOF_HEIGHT,\n    roofType: 'gable',\n    position: [0, 0, 0],\n  })\n\n  // Create the roof container\n  const roof = RoofNode.parse({\n    name,\n    position: [centerX, 0, centerZ],\n    children: [segment.id],\n  })\n\n  // Create roof first (so segment can be parented to it), then segment\n  createNodes([\n    { node: roof, parentId: levelId },\n    { node: segment, parentId: roof.id },\n  ])\n\n  sfxEmitter.emit('sfx:structure-build')\n  return roof.id\n}\n\ntype PreviewState = {\n  corner1: [number, number, number] | null\n  cursorPosition: [number, number, number]\n  levelY: number\n}\n\nexport const RoofTool: React.FC = () => {\n  const cursorRef = useRef<Group>(null)\n  const outlineRef = useRef<Line>(null!)\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setTool = useEditor((state) => state.setTool)\n  const setMode = useEditor((state) => state.setMode)\n\n  const selectedIdsRef = useRef(selectedIds)\n  useEffect(() => {\n    selectedIdsRef.current = selectedIds\n  }, [selectedIds])\n\n  const corner1Ref = useRef<[number, number, number] | null>(null)\n  const previousGridPosRef = useRef<[number, number] | null>(null)\n  const [preview, setPreview] = useState<PreviewState>({\n    corner1: null,\n    cursorPosition: [0, 0, 0],\n    levelY: 0,\n  })\n\n  useEffect(() => {\n    if (!currentLevelId) return\n\n    outlineRef.current.geometry = new BufferGeometry()\n\n    const updateOutline = (\n      corner1: [number, number, number],\n      corner2: [number, number, number],\n    ) => {\n      const gridY = corner1[1] + GRID_OFFSET\n\n      const groundPoints = [\n        new Vector3(corner1[0], gridY, corner1[2]),\n        new Vector3(corner2[0], gridY, corner1[2]),\n        new Vector3(corner2[0], gridY, corner2[2]),\n        new Vector3(corner1[0], gridY, corner2[2]),\n        new Vector3(corner1[0], gridY, corner1[2]),\n      ]\n\n      outlineRef.current.geometry.dispose()\n      outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)\n      outlineRef.current.visible = true\n    }\n\n    const onGridMove = (event: GridEvent) => {\n      if (!cursorRef.current) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      const cursorPosition: [number, number, number] = [gridX, y, gridZ]\n      const gridY = y + GRID_OFFSET\n\n      cursorRef.current.position.set(gridX, gridY, gridZ)\n\n      if (\n        corner1Ref.current &&\n        previousGridPosRef.current &&\n        (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousGridPosRef.current = [gridX, gridZ]\n\n      setPreview({\n        corner1: corner1Ref.current,\n        cursorPosition,\n        levelY: y,\n      })\n\n      if (corner1Ref.current) {\n        updateOutline(corner1Ref.current, cursorPosition)\n      }\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      if (!currentLevelId) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      if (corner1Ref.current) {\n        const roofId = commitRoofPlacement(\n          currentLevelId,\n          corner1Ref.current,\n          [gridX, y, gridZ],\n          selectedIdsRef.current,\n        )\n\n        setSelection({ selectedIds: [roofId as AnyNode['id']] })\n\n        corner1Ref.current = null\n        outlineRef.current.visible = false\n      } else {\n        corner1Ref.current = [gridX, y, gridZ]\n        setPreview((prev) => ({\n          ...prev,\n          corner1: corner1Ref.current,\n        }))\n      }\n    }\n\n    const onCancel = () => {\n      if (corner1Ref.current) {\n        markToolCancelConsumed()\n        corner1Ref.current = null\n        outlineRef.current.visible = false\n        setPreview((prev) => ({ ...prev, corner1: null }))\n      }\n    }\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('tool:cancel', onCancel)\n\n      corner1Ref.current = null\n    }\n  }, [currentLevelId, setSelection])\n\n  const { corner1, cursorPosition, levelY } = preview\n\n  const previewDimensions = useMemo(() => {\n    if (!corner1) return null\n    const length = Math.abs(cursorPosition[0] - corner1[0])\n    const width = Math.abs(cursorPosition[2] - corner1[2])\n    const centerX = (corner1[0] + cursorPosition[0]) / 2\n    const centerZ = (corner1[2] + cursorPosition[2]) / 2\n    return { length, width, centerX, centerZ }\n  }, [corner1, cursorPosition])\n\n  return (\n    <group>\n      <CursorSphere ref={cursorRef} />\n\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={outlineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.3}\n          transparent\n        />\n      </line>\n\n      {corner1 && (\n        <CursorSphere\n          color=\"#818cf8\"\n          position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}\n          showTooltip={false}\n        />\n      )}\n\n      {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (\n        <mesh\n          layers={EDITOR_LAYER}\n          position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}\n          rotation={[-Math.PI / 2, 0, 0]}\n        >\n          <planeGeometry args={[previewDimensions.length, previewDimensions.width]} />\n          <meshBasicMaterial\n            color=\"#818cf8\"\n            depthTest={false}\n            depthWrite={false}\n            opacity={0.1}\n            side={DoubleSide}\n            transparent\n          />\n        </mesh>\n      )}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/select/box-select-tool.tsx",
    "content": "import { Icon } from '@iconify/react'\nimport {\n  type AnyNodeId,\n  type CeilingNode,\n  emitter,\n  type GridEvent,\n  type ItemNode,\n  type LevelNode,\n  type SlabNode,\n  sceneRegistry,\n  useScene,\n  type WallNode,\n  type ZoneNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useThree } from '@react-three/fiber'\nimport { useEffect, useRef } from 'react'\nimport {\n  BufferAttribute,\n  BufferGeometry,\n  DoubleSide,\n  type Group,\n  LineBasicMaterial,\n  LineSegments,\n  type Mesh,\n  Plane,\n  Raycaster,\n  Vector2,\n  Vector3,\n} from 'three'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\n/**\n * Module-level flag to prevent the SelectionManager from deselecting\n * on the grid:click that fires right after a box-select drag completes.\n */\nexport let boxSelectHandled = false\n\n// ── Geometry helpers ────────────────────────────────────────────────────────\n\ntype Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }\n\nfunction pointInBounds(x: number, z: number, b: Bounds): boolean {\n  return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ\n}\n\nfunction segmentsIntersect(\n  ax1: number,\n  az1: number,\n  ax2: number,\n  az2: number,\n  bx1: number,\n  bz1: number,\n  bx2: number,\n  bz2: number,\n): boolean {\n  const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)\n  const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)\n  const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)\n  const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)\n\n  if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {\n    return true\n  }\n\n  if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true\n  if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true\n  if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true\n  if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true\n\n  return false\n}\n\nfunction cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {\n  return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)\n}\n\nfunction onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean {\n  return (\n    Math.min(ax, bx) <= cx &&\n    cx <= Math.max(ax, bx) &&\n    Math.min(az, bz) <= cz &&\n    cz <= Math.max(az, bz)\n  )\n}\n\nfunction segmentIntersectsBounds(\n  x1: number,\n  z1: number,\n  x2: number,\n  z2: number,\n  b: Bounds,\n): boolean {\n  if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true\n\n  const edges: [number, number, number, number][] = [\n    [b.minX, b.minZ, b.maxX, b.minZ],\n    [b.maxX, b.minZ, b.maxX, b.maxZ],\n    [b.maxX, b.maxZ, b.minX, b.maxZ],\n    [b.minX, b.maxZ, b.minX, b.minZ],\n  ]\n  for (const [ex1, ez1, ex2, ez2] of edges) {\n    if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true\n  }\n  return false\n}\n\nfunction polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {\n  if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true\n\n  const corners: [number, number][] = [\n    [b.minX, b.minZ],\n    [b.maxX, b.minZ],\n    [b.maxX, b.maxZ],\n    [b.minX, b.maxZ],\n  ]\n  if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true\n\n  const edges: [number, number, number, number][] = [\n    [b.minX, b.minZ, b.maxX, b.minZ],\n    [b.maxX, b.minZ, b.maxX, b.maxZ],\n    [b.maxX, b.maxZ, b.minX, b.maxZ],\n    [b.minX, b.maxZ, b.minX, b.minZ],\n  ]\n  for (let i = 0; i < polygon.length; i++) {\n    const [px1, pz1] = polygon[i]!\n    const [px2, pz2] = polygon[(i + 1) % polygon.length]!\n    for (const [ex1, ez1, ex2, ez2] of edges) {\n      if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true\n    }\n  }\n\n  return false\n}\n\nfunction pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {\n  let inside = false\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const [xi, zi] = polygon[i]!\n    const [xj, zj] = polygon[j]!\n    if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {\n      inside = !inside\n    }\n  }\n  return inside\n}\n\n// ── Node-in-bounds checks ───────────────────────────────────────────────────\n\nconst _tempVec = new Vector3()\n\nfunction getNodeWorldXZ(nodeId: string): [number, number] | null {\n  const obj = sceneRegistry.nodes.get(nodeId)\n  if (!obj) return null\n  obj.getWorldPosition(_tempVec)\n  return [_tempVec.x, _tempVec.z]\n}\n\nfunction collectNodeIdsInBounds(bounds: Bounds): string[] {\n  const { levelId } = useViewer.getState().selection\n  const { nodes } = useScene.getState()\n  const { phase, structureLayer } = useEditor.getState()\n\n  if (!levelId) return []\n  const levelNode = nodes[levelId] as LevelNode | undefined\n  if (!levelNode || levelNode.type !== 'level') return []\n\n  const result: string[] = []\n\n  if (phase === 'structure' && structureLayer === 'elements') {\n    for (const childId of levelNode.children) {\n      const node = nodes[childId as AnyNodeId]\n      if (!node) continue\n\n      if (node.type === 'wall') {\n        const wall = node as WallNode\n        if (\n          segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)\n        ) {\n          result.push(wall.id)\n        }\n        // Check wall children (doors/windows)\n        for (const itemId of wall.children) {\n          const child = nodes[itemId as AnyNodeId]\n          if (!child) continue\n          if (\n            child.type === 'window' ||\n            child.type === 'door' ||\n            (child.type === 'item' &&\n              ((child as ItemNode).asset.category === 'door' ||\n                (child as ItemNode).asset.category === 'window'))\n          ) {\n            const xz = getNodeWorldXZ(child.id)\n            if (xz && pointInBounds(xz[0], xz[1], bounds)) {\n              result.push(child.id)\n            }\n          }\n        }\n      } else if (node.type === 'slab') {\n        const slab = node as SlabNode\n        if (polygonIntersectsBounds(slab.polygon, bounds)) {\n          result.push(slab.id)\n        }\n      } else if (node.type === 'ceiling') {\n        const ceiling = node as CeilingNode\n        if (polygonIntersectsBounds(ceiling.polygon, bounds)) {\n          result.push(ceiling.id)\n        }\n      } else if (node.type === 'roof') {\n        const xz = getNodeWorldXZ(node.id)\n        if (xz && pointInBounds(xz[0], xz[1], bounds)) {\n          result.push(node.id)\n        }\n      }\n    }\n  } else if (phase === 'structure' && structureLayer === 'zones') {\n    for (const childId of levelNode.children) {\n      const node = nodes[childId as AnyNodeId]\n      if (!node || node.type !== 'zone') continue\n      const zone = node as ZoneNode\n      if (polygonIntersectsBounds(zone.polygon, bounds)) {\n        result.push(zone.id)\n      }\n    }\n  } else if (phase === 'furnish') {\n    for (const childId of levelNode.children) {\n      const node = nodes[childId as AnyNodeId]\n      if (!node) continue\n      if (node.type === 'item') {\n        const item = node as ItemNode\n        if (item.asset.category === 'door' || item.asset.category === 'window') continue\n        const xz = getNodeWorldXZ(item.id)\n        if (xz && pointInBounds(xz[0], xz[1], bounds)) {\n          result.push(item.id)\n        }\n      }\n    }\n  }\n\n  return result\n}\n\n// ── Visual helpers ──────────────────────────────────────────────────────────\n\nfunction updateRectVisuals(\n  fillMesh: Mesh,\n  outline: LineSegments,\n  start: Vector3,\n  end: Vector3,\n  y: number,\n) {\n  const cx = (start.x + end.x) / 2\n  const cz = (start.z + end.z) / 2\n  const w = Math.abs(end.x - start.x)\n  const h = Math.abs(end.z - start.z)\n\n  if (w < 0.01 && h < 0.01) {\n    fillMesh.visible = false\n    outline.visible = false\n    return\n  }\n\n  // Fill rect (unit plane scaled)\n  fillMesh.visible = true\n  fillMesh.position.set(cx, y + 0.02, cz)\n  fillMesh.scale.set(w, h, 1)\n\n  // Outline — 4 edges as line segment pairs (8 vertices)\n  outline.visible = true\n  const oy = y + 0.03\n  const x0 = cx - w / 2\n  const x1 = cx + w / 2\n  const z0 = cz - h / 2\n  const z1 = cz + h / 2\n  const pos = outline.geometry.attributes.position as BufferAttribute\n  // bottom: (x0,z0)→(x1,z0)\n  pos.setXYZ(0, x0, oy, z0)\n  pos.setXYZ(1, x1, oy, z0)\n  // right: (x1,z0)→(x1,z1)\n  pos.setXYZ(2, x1, oy, z0)\n  pos.setXYZ(3, x1, oy, z1)\n  // top: (x1,z1)→(x0,z1)\n  pos.setXYZ(4, x1, oy, z1)\n  pos.setXYZ(5, x0, oy, z1)\n  // left: (x0,z1)→(x0,z0)\n  pos.setXYZ(6, x0, oy, z1)\n  pos.setXYZ(7, x0, oy, z0)\n  pos.needsUpdate = true\n}\n\n// ── Outline geometry (allocated once, reused) ───────────────────────────────\n\nfunction createOutlineSegments(): LineSegments {\n  const geo = new BufferGeometry()\n  // 4 edges × 2 vertices each = 8 vertices\n  const positions = new Float32Array(8 * 3)\n  geo.setAttribute('position', new BufferAttribute(positions, 3))\n\n  const mat = new LineBasicMaterial({\n    color: '#818cf8',\n    depthTest: false,\n    depthWrite: false,\n    transparent: true,\n    opacity: 0.6,\n  })\n\n  const segments = new LineSegments(geo, mat)\n  segments.layers.set(EDITOR_LAYER)\n  segments.renderOrder = 2\n  segments.visible = false\n  segments.frustumCulled = false\n\n  return segments\n}\n\n// ── Drag threshold (pixels) ─────────────────────────────────────────────────\n\nconst DRAG_THRESHOLD_PX = 4\n\n// ── Component ───────────────────────────────────────────────────────────────\n\nexport const BoxSelectTool: React.FC = () => {\n  const mode = useEditor((s) => s.mode)\n  const selectionTool = useEditor((s) => s.floorplanSelectionTool)\n  const isActive = mode === 'select' && selectionTool === 'marquee'\n\n  if (!isActive) return null\n\n  return <BoxSelectToolInner />\n}\n\nconst BOX_SELECT_TOOLTIP = (\n  <Icon\n    color=\"currentColor\"\n    height={24}\n    icon=\"mdi:select-drag\"\n    style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}\n    width={24}\n  />\n)\n\nconst BoxSelectToolInner: React.FC = () => {\n  const { camera, gl } = useThree()\n  const cursorRef = useRef<Group>(null)\n  const rectFillRef = useRef<Mesh>(null!)\n  const outlineRef = useRef(createOutlineSegments())\n  const startPoint = useRef(new Vector3())\n  const currentPoint = useRef(new Vector3())\n  const pointerDown = useRef(false)\n  const isDragging = useRef(false)\n  const startClientX = useRef(0)\n  const startClientY = useRef(0)\n  const gridY = useRef(0)\n  const prevHitCount = useRef(0)\n\n  // Raycasting helpers (same technique as useGridEvents)\n  const raycasterRef = useRef(new Raycaster())\n  const pointerNDC = useRef(new Vector2())\n  const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))\n  const hitPoint = useRef(new Vector3())\n\n  // Cleanup outline geometry on unmount\n  useEffect(() => {\n    const outline = outlineRef.current\n    return () => {\n      outline.geometry.dispose()\n      ;(outline.material as LineBasicMaterial).dispose()\n    }\n  }, [])\n\n  // Sync ground plane Y with the current level\n  useEffect(() => {\n    const unsubscribe = useViewer.subscribe((state) => {\n      const levelId = state.selection.levelId\n      if (!levelId) return\n      const obj = sceneRegistry.nodes.get(levelId)\n      if (obj) groundPlane.current.constant = -obj.position.y\n    })\n    // Set initial value\n    const levelId = useViewer.getState().selection.levelId\n    if (levelId) {\n      const obj = sceneRegistry.nodes.get(levelId)\n      if (obj) groundPlane.current.constant = -obj.position.y\n    }\n    return unsubscribe\n  }, [])\n\n  const raycastToGround = (e: PointerEvent): Vector3 | null => {\n    const rect = gl.domElement.getBoundingClientRect()\n    pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1\n    pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1\n    raycasterRef.current.setFromCamera(pointerNDC.current, camera)\n    if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {\n      return hitPoint.current\n    }\n    return null\n  }\n\n  useEffect(() => {\n    const canvas = gl.domElement\n\n    const onCanvasPointerDown = (e: PointerEvent) => {\n      if (e.button !== 0) return\n      if (useViewer.getState().cameraDragging) return\n\n      const point = raycastToGround(e)\n      if (!point) return\n\n      startPoint.current.copy(point)\n      currentPoint.current.copy(point)\n      gridY.current = point.y\n      pointerDown.current = true\n      isDragging.current = false\n      prevHitCount.current = 0\n      startClientX.current = e.clientX\n      startClientY.current = e.clientY\n    }\n\n    const onCanvasPointerUp = (e: PointerEvent) => {\n      if (e.button !== 0) return\n      if (!pointerDown.current) return\n\n      if (isDragging.current) {\n        const point = raycastToGround(e)\n        if (point) currentPoint.current.copy(point)\n\n        const bounds: Bounds = {\n          minX: Math.min(startPoint.current.x, currentPoint.current.x),\n          maxX: Math.max(startPoint.current.x, currentPoint.current.x),\n          minZ: Math.min(startPoint.current.z, currentPoint.current.z),\n          maxZ: Math.max(startPoint.current.z, currentPoint.current.z),\n        }\n\n        const ids = collectNodeIdsInBounds(bounds)\n\n        const shouldAppend = e.metaKey || e.ctrlKey\n        const { phase, structureLayer } = useEditor.getState()\n\n        if (phase === 'structure' && structureLayer === 'zones') {\n          if (ids.length > 0) {\n            useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })\n          } else if (!shouldAppend) {\n            useViewer.getState().setSelection({ zoneId: null })\n          }\n        } else if (shouldAppend) {\n          const currentIds = useViewer.getState().selection.selectedIds\n          const merged = Array.from(new Set([...currentIds, ...ids]))\n          useViewer.getState().setSelection({ selectedIds: merged })\n        } else {\n          useViewer.getState().setSelection({ selectedIds: ids })\n        }\n\n        // Prevent the subsequent grid:click from deselecting\n        boxSelectHandled = true\n        setTimeout(() => {\n          boxSelectHandled = false\n        }, 50)\n      }\n      // NOTE: Short clicks (no drag) fall through to the SelectionManager's\n      // existing grid:click / node:click handlers — no extra logic needed here.\n\n      // Hide visuals\n      if (rectFillRef.current) rectFillRef.current.visible = false\n      if (outlineRef.current) outlineRef.current.visible = false\n\n      // Reset\n      pointerDown.current = false\n      isDragging.current = false\n    }\n\n    canvas.addEventListener('pointerdown', onCanvasPointerDown)\n    canvas.addEventListener('pointerup', onCanvasPointerUp)\n\n    return () => {\n      canvas.removeEventListener('pointerdown', onCanvasPointerDown)\n      canvas.removeEventListener('pointerup', onCanvasPointerUp)\n    }\n  }, [camera, gl])\n\n  // grid:move for cursor tracking + rectangle update during drag\n  useEffect(() => {\n    const onMove = (event: GridEvent) => {\n      // Always update cursor position\n      if (cursorRef.current) {\n        cursorRef.current.position.set(event.position[0], event.position[1], event.position[2])\n      }\n\n      if (!pointerDown.current) return\n\n      currentPoint.current.set(event.position[0], event.position[1], event.position[2])\n\n      // Check drag threshold (screen pixels)\n      const nativeEvent = event.nativeEvent as unknown as PointerEvent\n      const dx = nativeEvent.clientX - startClientX.current\n      const dy = nativeEvent.clientY - startClientY.current\n      if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {\n        isDragging.current = true\n      }\n\n      if (isDragging.current && rectFillRef.current && outlineRef.current) {\n        updateRectVisuals(\n          rectFillRef.current,\n          outlineRef.current,\n          startPoint.current,\n          currentPoint.current,\n          gridY.current,\n        )\n\n        // Play snap sound when the set of captured nodes changes\n        const bounds: Bounds = {\n          minX: Math.min(startPoint.current.x, currentPoint.current.x),\n          maxX: Math.max(startPoint.current.x, currentPoint.current.x),\n          minZ: Math.min(startPoint.current.z, currentPoint.current.z),\n          maxZ: Math.max(startPoint.current.z, currentPoint.current.z),\n        }\n        const hitCount = collectNodeIdsInBounds(bounds).length\n        if (hitCount !== prevHitCount.current) {\n          sfxEmitter.emit('sfx:grid-snap')\n          prevHitCount.current = hitCount\n        }\n      }\n    }\n\n    emitter.on('grid:move', onMove)\n    return () => {\n      emitter.off('grid:move', onMove)\n    }\n  }, [])\n\n  return (\n    <group>\n      {/* Cursor indicator */}\n      <CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />\n\n      {/* Selection rectangle fill */}\n      <mesh\n        layers={EDITOR_LAYER}\n        ref={rectFillRef}\n        renderOrder={1}\n        rotation={[-Math.PI / 2, 0, 0]}\n        visible={false}\n      >\n        <planeGeometry args={[1, 1]} />\n        <meshBasicMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          opacity={0.12}\n          side={DoubleSide}\n          transparent\n        />\n      </mesh>\n\n      {/* Outline (LineLoop added as primitive — allocated once in ref) */}\n      <primitive object={outlineRef.current} />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/shared/cursor-sphere.tsx",
    "content": "import { Html } from '@react-three/drei'\nimport type { ThreeElements } from '@react-three/fiber'\nimport { forwardRef } from 'react'\nimport type { Group } from 'three'\nimport { furnishTools } from '../../../components/ui/action-menu/furnish-tools'\nimport { tools } from '../../../components/ui/action-menu/structure-tools'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport useEditor from '../../../store/use-editor'\n\ninterface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {\n  color?: string\n  depthWrite?: boolean\n  showTooltip?: boolean\n  height?: number\n  /** Custom tooltip content — overrides the auto-detected build tool icon */\n  tooltipContent?: React.ReactNode\n}\n\nexport const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(\n  { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },\n  ref,\n) {\n  const tool = useEditor((s) => s.tool)\n  const mode = useEditor((s) => s.mode)\n  const catalogCategory = useEditor((s) => s.catalogCategory)\n  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)\n\n  // Find the icon for the current tool\n  let activeToolConfig = null\n  if (mode === 'build' && tool) {\n    if (tool === 'item' && catalogCategory) {\n      activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory)\n    } else {\n      activeToolConfig = tools.find((t) => t.id === tool)\n    }\n  }\n\n  const isVisible = visible && !isFloorplanHovered\n\n  return (\n    <group ref={ref} {...props} visible={isVisible}>\n      {/* Flat marker on the ground */}\n      <group rotation={[-Math.PI / 2, 0, 0]}>\n        {/* Center dot */}\n        <mesh layers={EDITOR_LAYER} renderOrder={2}>\n          <circleGeometry args={[0.06, 32]} />\n          <meshBasicMaterial\n            color={color}\n            depthTest={false}\n            depthWrite={false}\n            opacity={0.9}\n            transparent\n          />\n        </mesh>\n\n        {/* Outer ring / glow */}\n        <mesh layers={EDITOR_LAYER} renderOrder={2}>\n          <circleGeometry args={[0.2, 32]} />\n          <meshBasicMaterial\n            color={color}\n            depthTest={false}\n            depthWrite={false}\n            opacity={0.25}\n            transparent\n          />\n        </mesh>\n      </group>\n\n      {/* Vertical line */}\n      {height > 0 && (\n        <mesh layers={EDITOR_LAYER} position={[0, height / 2, 0]} renderOrder={2}>\n          <cylinderGeometry args={[0.01, 0.01, height, 8]} />\n          <meshBasicMaterial\n            color={color}\n            depthTest={false}\n            depthWrite={false}\n            opacity={0.7}\n            transparent\n          />\n        </mesh>\n      )}\n\n      {/* Tool Icon Tooltip at the top of the line */}\n      {isVisible && showTooltip && (activeToolConfig || tooltipContent) && (\n        <Html\n          center\n          position={[0, height > 0 ? height + 0.2 : 0.6, 0]}\n          style={{\n            pointerEvents: 'none',\n            background: '#18181b', // zinc-900\n            padding: '6px',\n            borderRadius: '12px',\n            border: '1px solid rgba(255,255,255,0.05)',\n            boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)',\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n            width: '36px',\n            height: '36px',\n          }}\n        >\n          {tooltipContent || (\n            // eslint-disable-next-line @next/next/no-img-element\n            <img\n              alt={activeToolConfig!.label}\n              src={activeToolConfig!.iconSrc}\n              style={{\n                width: '100%',\n                height: '100%',\n                objectFit: 'contain',\n                filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',\n              }}\n            />\n          )}\n        </Html>\n      )}\n    </group>\n  )\n})\n"
  },
  {
    "path": "packages/editor/src/components/tools/shared/polygon-editor.tsx",
    "content": "import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'\nimport { createPortal } from '@react-three/fiber'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { BufferGeometry, Float32BufferAttribute, type Line } from 'three'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\n\nconst Y_OFFSET = 0.02\n\ntype DragState = {\n  isDragging: boolean\n  vertexIndex: number\n  initialPosition: [number, number]\n  pointerId: number\n}\n\nexport interface PolygonEditorProps {\n  polygon: Array<[number, number]>\n  color?: string\n  onPolygonChange: (polygon: Array<[number, number]>) => void\n  minVertices?: number\n  /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */\n  levelId?: string\n  /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */\n  surfaceHeight?: number\n}\n\n/**\n * Generic polygon editor component for editing polygon vertices\n * Used by zone and site boundary editors\n */\nconst MIN_HANDLE_HEIGHT = 0.15\n\nexport const PolygonEditor: React.FC<PolygonEditorProps> = ({\n  polygon,\n  color = '#3b82f6',\n  onPolygonChange,\n  minVertices = 3,\n  levelId,\n  surfaceHeight = 0,\n}) => {\n  // Get level node from registry if levelId is provided\n  const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null\n\n  // When using portal, edit at Y_OFFSET (local to level)\n  // When not using portal, edit at world origin\n  const editY = levelNode ? Y_OFFSET : 0\n\n  // Local state for dragging\n  const [dragState, setDragState] = useState<DragState | null>(null)\n  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)\n  const previewPolygonRef = useRef<Array<[number, number]> | null>(null)\n\n  // Keep ref in sync\n  useEffect(() => {\n    previewPolygonRef.current = previewPolygon\n  }, [previewPolygon])\n\n  const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)\n  const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)\n  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])\n\n  const lineRef = useRef<Line>(null!)\n  const previousPositionRef = useRef<[number, number] | null>(null)\n\n  // Track the last polygon prop to detect external changes (undo/redo)\n  const lastPolygonRef = useRef(polygon)\n  if (polygon !== lastPolygonRef.current) {\n    lastPolygonRef.current = polygon\n    // External change (e.g. undo/redo) — clear any stale preview/drag state\n    if (previewPolygon) setPreviewPolygon(null)\n    if (dragState) setDragState(null)\n  }\n\n  // The polygon to display (preview during drag, or actual polygon)\n  const displayPolygon = previewPolygon ?? polygon\n\n  // Calculate midpoints for adding new vertices\n  const midpoints = useMemo(() => {\n    if (displayPolygon.length < 2) return []\n    return displayPolygon.map(([x1, z1], index) => {\n      const nextIndex = (index + 1) % displayPolygon.length\n      const [x2, z2] = displayPolygon[nextIndex]!\n      return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]\n    })\n  }, [displayPolygon])\n\n  // Update vertex position using grid cursor position\n  const handleVertexDrag = useCallback(\n    (vertexIndex: number, position: [number, number]) => {\n      setPreviewPolygon((prev) => {\n        const basePolygon = prev ?? polygon\n        const newPolygon = [...basePolygon]\n        newPolygon[vertexIndex] = position\n        return newPolygon\n      })\n    },\n    [polygon],\n  )\n\n  // Commit polygon changes\n  const commitPolygonChange = useCallback(() => {\n    if (previewPolygonRef.current) {\n      onPolygonChange(previewPolygonRef.current)\n    }\n    setPreviewPolygon(null)\n    setDragState(null)\n  }, [onPolygonChange])\n\n  // Handle adding a new vertex at midpoint\n  const handleAddVertex = useCallback(\n    (afterIndex: number, position: [number, number]) => {\n      const basePolygon = previewPolygon ?? polygon\n      const newPolygon = [\n        ...basePolygon.slice(0, afterIndex + 1),\n        position,\n        ...basePolygon.slice(afterIndex + 1),\n      ]\n\n      setPreviewPolygon(newPolygon)\n      return afterIndex + 1 // Return new vertex index\n    },\n    [polygon, previewPolygon],\n  )\n\n  // Handle deleting a vertex\n  const handleDeleteVertex = useCallback(\n    (index: number) => {\n      const basePolygon = previewPolygon ?? polygon\n      if (basePolygon.length <= minVertices) return // Need at least minVertices points\n\n      const newPolygon = basePolygon.filter((_, i) => i !== index)\n      onPolygonChange(newPolygon)\n      setPreviewPolygon(null)\n    },\n    [polygon, previewPolygon, onPolygonChange, minVertices],\n  )\n\n  // Listen to grid:move events to track cursor position\n  useEffect(() => {\n    const onGridMove = (event: GridEvent) => {\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const newPosition: [number, number] = [gridX, gridZ]\n\n      // Play snap sound when cursor moves to a new grid cell during drag\n      if (\n        dragState?.isDragging &&\n        previousPositionRef.current &&\n        (newPosition[0] !== previousPositionRef.current[0] ||\n          newPosition[1] !== previousPositionRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousPositionRef.current = newPosition\n      setCursorPosition(newPosition)\n\n      // Update vertex position during drag\n      if (dragState?.isDragging) {\n        handleVertexDrag(dragState.vertexIndex, newPosition)\n      }\n    }\n\n    emitter.on('grid:move', onGridMove)\n    return () => {\n      emitter.off('grid:move', onGridMove)\n    }\n  }, [dragState, handleVertexDrag])\n\n  // Set up pointer up listener for ending drag\n  useEffect(() => {\n    if (!dragState?.isDragging) return\n\n    const handlePointerUp = (e: PointerEvent | MouseEvent) => {\n      // Only handle the specific pointer that started the drag, if it's a PointerEvent\n      if (\n        'pointerId' in e &&\n        dragState.pointerId !== undefined &&\n        e.pointerId !== dragState.pointerId\n      )\n        return\n\n      // Stop the event from propagating to prevent grid click\n      e.stopImmediatePropagation()\n      e.preventDefault()\n\n      // Suppress the follow-up click event that browsers fire after pointerup\n      const suppressClick = (ce: MouseEvent) => {\n        ce.stopImmediatePropagation()\n        ce.preventDefault()\n        window.removeEventListener('click', suppressClick, true)\n      }\n      window.addEventListener('click', suppressClick, true)\n\n      // Safety cleanup in case no click fires\n      requestAnimationFrame(() => {\n        window.removeEventListener('click', suppressClick, true)\n      })\n\n      commitPolygonChange()\n    }\n\n    window.addEventListener('pointerup', handlePointerUp as EventListener, true)\n    window.addEventListener('pointercancel', handlePointerUp as EventListener, true)\n    return () => {\n      window.removeEventListener('pointerup', handlePointerUp as EventListener, true)\n      window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)\n    }\n  }, [dragState, commitPolygonChange])\n\n  // Update line geometry when polygon changes\n  useEffect(() => {\n    if (!lineRef.current || displayPolygon.length < 2) return\n\n    const positions: number[] = []\n    for (const [x, z] of displayPolygon) {\n      positions.push(x!, editY + 0.01, z!)\n    }\n    // Close the loop\n    const first = displayPolygon[0]!\n    positions.push(first[0]!, editY + 0.01, first[1]!)\n\n    const geometry = new BufferGeometry()\n    geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))\n\n    lineRef.current.geometry.dispose()\n    lineRef.current.geometry = geometry\n  }, [displayPolygon, editY])\n\n  if (displayPolygon.length < minVertices) return null\n\n  const canDelete = displayPolygon.length > minVertices\n\n  const editorContent = (\n    <group>\n      {/* Border line */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        raycast={() => {}}\n        // @ts-expect-error R3F <line> element conflicts with SVG <line> type\n        ref={lineRef}\n        renderOrder={10}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color={color}\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.8}\n          transparent\n        />\n      </line>\n\n      {/* Vertex handles - blue cylinders that match surface height */}\n      {displayPolygon.map(([x, z], index) => {\n        const isHovered = hoveredVertex === index\n        const isDragging = dragState?.vertexIndex === index\n        const radius = 0.1\n        const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)\n\n        return (\n          <mesh\n            castShadow\n            key={`vertex-${index}`}\n            layers={EDITOR_LAYER}\n            onClick={(e) => {\n              if (e.button !== 0) return\n              e.stopPropagation()\n            }}\n            onDoubleClick={(e) => {\n              if (e.button !== 0) return\n              e.stopPropagation()\n              if (canDelete) {\n                handleDeleteVertex(index)\n              }\n            }}\n            onPointerDown={(e) => {\n              if (e.button !== 0) return\n              e.stopPropagation()\n              setDragState({\n                isDragging: true,\n                vertexIndex: index,\n                initialPosition: [x!, z!],\n                pointerId: e.pointerId,\n              })\n            }}\n            onPointerEnter={(e) => {\n              e.stopPropagation()\n              setHoveredVertex(index)\n            }}\n            onPointerLeave={(e) => {\n              e.stopPropagation()\n              setHoveredVertex(null)\n            }}\n            position={[x!, editY + height / 2, z!]}\n          >\n            <cylinderGeometry args={[radius, radius, height, 16]} />\n            <meshStandardMaterial\n              color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}\n            />\n          </mesh>\n        )\n      })}\n\n      {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}\n      {!dragState &&\n        midpoints.map(([x, z], index) => {\n          const isHovered = hoveredMidpoint === index\n          const radius = 0.06\n          const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)\n\n          return (\n            <mesh\n              key={`midpoint-${index}`}\n              layers={EDITOR_LAYER}\n              onClick={(e) => {\n                if (e.button !== 0) return\n                e.stopPropagation()\n              }}\n              onPointerDown={(e) => {\n                if (e.button !== 0) return\n                e.stopPropagation()\n                const newVertexIndex = handleAddVertex(index, [x!, z!])\n                if (newVertexIndex >= 0) {\n                  setDragState({\n                    isDragging: true,\n                    vertexIndex: newVertexIndex,\n                    initialPosition: [x!, z!],\n                    pointerId: e.pointerId,\n                  })\n                  setHoveredMidpoint(null)\n                }\n              }}\n              onPointerEnter={(e) => {\n                e.stopPropagation()\n                setHoveredMidpoint(index)\n              }}\n              onPointerLeave={(e) => {\n                e.stopPropagation()\n                setHoveredMidpoint(null)\n              }}\n              position={[x!, editY + height / 2, z!]}\n            >\n              <cylinderGeometry args={[radius, radius, height, 16]} />\n              <meshStandardMaterial\n                color={isHovered ? '#4ade80' : '#22c55e'}\n                opacity={isHovered ? 1 : 0.7}\n                transparent\n              />\n            </mesh>\n          )\n        })}\n    </group>\n  )\n\n  // Mount to level node if available, otherwise render at world origin\n  return levelNode ? createPortal(editorContent, levelNode) : editorContent\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/site/site-boundary-editor.tsx",
    "content": "import { type SiteNode, useScene } from '@pascal-app/core'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\n/**\n * Site boundary editor - allows editing site polygon when in site phase\n * Uses the generic PolygonEditor component\n */\nexport const SiteBoundaryEditor: React.FC = () => {\n  const nodes = useScene((state) => state.nodes)\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n  const updateNode = useScene((state) => state.updateNode)\n\n  // Get the site node (first root node)\n  const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null\n  const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      if (site) {\n        updateNode(site.id, {\n          polygon: {\n            type: 'polygon',\n            points: newPolygon,\n          },\n        })\n      }\n    },\n    [site, updateNode],\n  )\n\n  if (!site?.polygon?.points || site.polygon.points.length < 3) return null\n\n  return (\n    <PolygonEditor\n      color=\"#10b981\"\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={site.polygon.points}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/slab/slab-boundary-editor.tsx",
    "content": "import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\ninterface SlabBoundaryEditorProps {\n  slabId: SlabNode['id']\n}\n\n/**\n * Slab boundary editor - allows editing slab polygon vertices for a specific slab\n * Uses the generic PolygonEditor component\n */\nexport const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {\n  const slabNode = useScene((state) => state.nodes[slabId])\n  const updateNode = useScene((state) => state.updateNode)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      updateNode(slabId, { polygon: newPolygon })\n      // Re-assert selection so the slab stays selected after the edit\n      setSelection({ selectedIds: [slabId] })\n    },\n    [slabId, updateNode, setSelection],\n  )\n\n  if (!slab?.polygon || slab.polygon.length < 3) return null\n\n  return (\n    <PolygonEditor\n      color=\"#a3a3a3\"\n      levelId={resolveLevelId(slab, useScene.getState().nodes)}\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={slab.polygon}\n      surfaceHeight={slab.elevation ?? 0.05}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/slab/slab-hole-editor.tsx",
    "content": "import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\ninterface SlabHoleEditorProps {\n  slabId: SlabNode['id']\n  holeIndex: number\n}\n\n/**\n * Slab hole editor - allows editing a specific hole polygon within a slab\n * Uses the generic PolygonEditor component\n */\nexport const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {\n  const slabNode = useScene((state) => state.nodes[slabId])\n  const updateNode = useScene((state) => state.updateNode)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null\n  const holes = slab?.holes || []\n  const hole = holes[holeIndex]\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      const updatedHoles = [...holes]\n      updatedHoles[holeIndex] = newPolygon\n      updateNode(slabId, { holes: updatedHoles })\n      // Re-assert selection so the slab stays selected after the edit\n      setSelection({ selectedIds: [slabId] })\n    },\n    [slabId, holeIndex, holes, updateNode, setSelection],\n  )\n\n  if (!(slab && hole) || hole.length < 3) return null\n\n  return (\n    <PolygonEditor\n      color=\"#ef4444\"\n      levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={hole}\n      surfaceHeight={slab.elevation ?? 0.05}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/slab/slab-tool.tsx",
    "content": "import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'\nimport { markToolCancelConsumed } from '../../../hooks/use-keyboard'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nconst Y_OFFSET = 0.02\n\n/**\n * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point\n */\nconst calculateSnapPoint = (\n  lastPoint: [number, number],\n  currentPoint: [number, number],\n): [number, number] => {\n  const [x1, y1] = lastPoint\n  const [x, y] = currentPoint\n\n  const dx = x - x1\n  const dy = y - y1\n  const absDx = Math.abs(dx)\n  const absDy = Math.abs(dy)\n\n  // Calculate distances to horizontal, vertical, and diagonal lines\n  const horizontalDist = absDy\n  const verticalDist = absDx\n  const diagonalDist = Math.abs(absDx - absDy)\n\n  // Find the minimum distance to determine which axis to snap to\n  const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)\n\n  if (minDist === diagonalDist) {\n    // Snap to 45° diagonal\n    const diagonalLength = Math.min(absDx, absDy)\n    return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]\n  }\n  if (minDist === horizontalDist) {\n    // Snap to horizontal\n    return [x, y1]\n  }\n  // Snap to vertical\n  return [x1, y]\n}\n\n/**\n * Creates a slab with the given polygon points and returns its ID\n */\nconst commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {\n  const { createNode, nodes } = useScene.getState()\n\n  // Count existing slabs for naming\n  const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length\n  const name = `Slab ${slabCount + 1}`\n\n  const slab = SlabNode.parse({\n    name,\n    polygon: points,\n  })\n\n  createNode(slab, levelId)\n  sfxEmitter.emit('sfx:structure-build')\n  return slab.id\n}\n\nexport const SlabTool: React.FC = () => {\n  const cursorRef = useRef<Group>(null)\n  const mainLineRef = useRef<Line>(null!)\n  const closingLineRef = useRef<Line>(null!)\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const [points, setPoints] = useState<Array<[number, number]>>([])\n  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])\n  const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])\n  const [levelY, setLevelY] = useState(0)\n  const previousSnappedPointRef = useRef<[number, number] | null>(null)\n  const shiftPressed = useRef(false)\n\n  // Update cursor position and lines on grid move\n  useEffect(() => {\n    if (!currentLevelId) return\n\n    const onGridMove = (event: GridEvent) => {\n      if (!cursorRef.current) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const gridPosition: [number, number] = [gridX, gridZ]\n\n      setCursorPosition(gridPosition)\n      setLevelY(event.position[1])\n\n      // Calculate snapped display position (bypass snap when Shift is held)\n      const lastPoint = points[points.length - 1]\n      const displayPoint =\n        shiftPressed.current || !lastPoint\n          ? gridPosition\n          : calculateSnapPoint(lastPoint, gridPosition)\n      setSnappedCursorPosition(displayPoint)\n\n      // Play snap sound when the snapped position actually changes (only when drawing)\n      if (\n        points.length > 0 &&\n        previousSnappedPointRef.current &&\n        (displayPoint[0] !== previousSnappedPointRef.current[0] ||\n          displayPoint[1] !== previousSnappedPointRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousSnappedPointRef.current = displayPoint\n      cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])\n    }\n\n    const onGridClick = (_event: GridEvent) => {\n      if (!currentLevelId) return\n\n      // Use the last displayed snapped position (respects Shift state from onGridMove)\n      const clickPoint = previousSnappedPointRef.current ?? cursorPosition\n\n      // Check if clicking on the first point to close the shape\n      const firstPoint = points[0]\n      if (\n        points.length >= 3 &&\n        firstPoint &&\n        Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&\n        Math.abs(clickPoint[1] - firstPoint[1]) < 0.25\n      ) {\n        // Create the slab and select it\n        const slabId = commitSlabDrawing(currentLevelId, points)\n        setSelection({ selectedIds: [slabId] })\n        setPoints([])\n      } else {\n        // Add point to polygon\n        setPoints([...points, clickPoint])\n      }\n    }\n\n    const onGridDoubleClick = (_event: GridEvent) => {\n      if (!currentLevelId) return\n\n      // Need at least 3 points to form a polygon\n      if (points.length >= 3) {\n        const slabId = commitSlabDrawing(currentLevelId, points)\n        setSelection({ selectedIds: [slabId] })\n        setPoints([])\n      }\n    }\n\n    const onCancel = () => {\n      if (points.length > 0) markToolCancelConsumed()\n      setPoints([])\n    }\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') shiftPressed.current = true\n    }\n    const onKeyUp = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') shiftPressed.current = false\n    }\n    document.addEventListener('keydown', onKeyDown)\n    document.addEventListener('keyup', onKeyUp)\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('grid:double-click', onGridDoubleClick)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      document.removeEventListener('keydown', onKeyDown)\n      document.removeEventListener('keyup', onKeyUp)\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('grid:double-click', onGridDoubleClick)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [currentLevelId, points, cursorPosition, setSelection])\n\n  // Update line geometries when points change\n  useEffect(() => {\n    if (!(mainLineRef.current && closingLineRef.current)) return\n\n    if (points.length === 0) {\n      mainLineRef.current.visible = false\n      closingLineRef.current.visible = false\n      return\n    }\n\n    const y = levelY + Y_OFFSET\n    const snappedCursor = snappedCursorPosition\n\n    // Build main line points\n    const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))\n    linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))\n\n    // Update main line\n    if (linePoints.length >= 2) {\n      mainLineRef.current.geometry.dispose()\n      mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)\n      mainLineRef.current.visible = true\n    } else {\n      mainLineRef.current.visible = false\n    }\n\n    // Update closing line (from cursor back to first point)\n    const firstPoint = points[0]\n    if (points.length >= 2 && firstPoint) {\n      const closingPoints = [\n        new Vector3(snappedCursor[0], y, snappedCursor[1]),\n        new Vector3(firstPoint[0], y, firstPoint[1]),\n      ]\n      closingLineRef.current.geometry.dispose()\n      closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)\n      closingLineRef.current.visible = true\n    } else {\n      closingLineRef.current.visible = false\n    }\n  }, [points, snappedCursorPosition, levelY])\n\n  // Create preview shape when we have 3+ points\n  const previewShape = useMemo(() => {\n    if (points.length < 3) return null\n\n    const snappedCursor = snappedCursorPosition\n\n    const allPoints = [...points, snappedCursor]\n\n    // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:\n    // - Shape X -> World X\n    // - Shape Y -> World -Z (so we negate Z to get correct orientation)\n    const firstPt = allPoints[0]\n    if (!firstPt) return null\n\n    const shape = new Shape()\n    shape.moveTo(firstPt[0], -firstPt[1])\n\n    for (let i = 1; i < allPoints.length; i++) {\n      const pt = allPoints[i]\n      if (pt) {\n        shape.lineTo(pt[0], -pt[1])\n      }\n    }\n    shape.closePath()\n\n    return shape\n  }, [points, snappedCursorPosition])\n\n  return (\n    <group>\n      {/* Cursor */}\n      <CursorSphere ref={cursorRef} />\n\n      {/* Preview fill */}\n      {previewShape && (\n        <mesh\n          frustumCulled={false}\n          layers={EDITOR_LAYER}\n          position={[0, levelY + Y_OFFSET, 0]}\n          rotation={[-Math.PI / 2, 0, 0]}\n        >\n          <shapeGeometry args={[previewShape]} />\n          <meshBasicMaterial\n            color=\"#818cf8\"\n            depthTest={false}\n            opacity={0.15}\n            side={DoubleSide}\n            transparent\n          />\n        </mesh>\n      )}\n\n      {/* Main line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={mainLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial color=\"#818cf8\" depthTest={false} depthWrite={false} linewidth={3} />\n      </line>\n\n      {/* Closing line */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={closingLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.5}\n          transparent\n        />\n      </line>\n\n      {/* Point markers */}\n      {points.map(([x, z], index) => (\n        <CursorSphere\n          color=\"#818cf8\"\n          height={0}\n          key={index}\n          position={[x, levelY + Y_OFFSET + 0.01, z]}\n          showTooltip={false}\n        />\n      ))}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/stair/stair-tool.tsx",
    "content": "import {\n  type AnyNode,\n  emitter,\n  type GridEvent,\n  type LevelNode,\n  StairNode,\n  StairSegmentNode,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useMemo, useRef } from 'react'\nimport * as THREE from 'three'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nconst GRID_OFFSET = 0.02\n\n// Default stair segment dimensions\nconst DEFAULT_WIDTH = 1.0\nconst DEFAULT_LENGTH = 3.0\nconst DEFAULT_HEIGHT = 2.5\nconst DEFAULT_STEP_COUNT = 10\n\n/**\n * Generates the step-profile geometry for the ghost preview.\n * Same algorithm as StairSystem's generateStairSegmentGeometry.\n */\nfunction createStairPreviewGeometry(): THREE.BufferGeometry {\n  const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT\n  const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT\n\n  const shape = new THREE.Shape()\n  shape.moveTo(0, 0)\n\n  for (let i = 0; i < DEFAULT_STEP_COUNT; i++) {\n    shape.lineTo(i * treadDepth, (i + 1) * riserHeight)\n    shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)\n  }\n\n  // Fill to floor (absoluteHeight = 0)\n  shape.lineTo(DEFAULT_LENGTH, 0)\n  shape.lineTo(0, 0)\n\n  const geometry = new THREE.ExtrudeGeometry(shape, {\n    steps: 1,\n    depth: DEFAULT_WIDTH,\n    bevelEnabled: false,\n  })\n\n  // Rotate so extrusion is along X (width), shape profile in XZ plane\n  const matrix = new THREE.Matrix4()\n  matrix.makeRotationY(-Math.PI / 2)\n  matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0)\n  geometry.applyMatrix4(matrix)\n\n  return geometry\n}\n\n/**\n * Creates a stair group with one default stair segment at the given position/rotation.\n */\nfunction commitStairPlacement(\n  levelId: LevelNode['id'],\n  position: [number, number, number],\n  rotation: number,\n): void {\n  const { createNodes, nodes } = useScene.getState()\n\n  const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length\n  const name = `Staircase ${stairCount + 1}`\n\n  const segment = StairSegmentNode.parse({\n    segmentType: 'stair',\n    width: DEFAULT_WIDTH,\n    length: DEFAULT_LENGTH,\n    height: DEFAULT_HEIGHT,\n    stepCount: DEFAULT_STEP_COUNT,\n    attachmentSide: 'front',\n    fillToFloor: true,\n    position: [0, 0, 0],\n  })\n\n  const stair = StairNode.parse({\n    name,\n    position,\n    rotation,\n    children: [segment.id],\n  })\n\n  createNodes([\n    { node: stair, parentId: levelId },\n    { node: segment, parentId: stair.id },\n  ])\n\n  sfxEmitter.emit('sfx:structure-build')\n}\n\nexport const StairTool: React.FC = () => {\n  const cursorRef = useRef<THREE.Group>(null)\n  const previewRef = useRef<THREE.Group>(null)\n  const rotationRef = useRef(0)\n  const previousGridPosRef = useRef<[number, number] | null>(null)\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n\n  const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])\n\n  useEffect(() => {\n    if (!currentLevelId) return\n\n    // Reset rotation when tool activates\n    rotationRef.current = 0\n    if (previewRef.current) previewRef.current.rotation.y = 0\n\n    const onGridMove = (event: GridEvent) => {\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      if (cursorRef.current) {\n        cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)\n      }\n\n      if (previewRef.current) {\n        previewRef.current.position.set(gridX, y, gridZ)\n      }\n\n      if (\n        previousGridPosRef.current &&\n        (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])\n      ) {\n        sfxEmitter.emit('sfx:grid-snap')\n      }\n\n      previousGridPosRef.current = [gridX, gridZ]\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      if (!currentLevelId) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      const y = event.position[1]\n\n      commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)\n    }\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {\n        return\n      }\n\n      const ROTATION_STEP = Math.PI / 4\n      let rotationDelta = 0\n      if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP\n      else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP\n\n      if (rotationDelta !== 0) {\n        event.preventDefault()\n        sfxEmitter.emit('sfx:item-rotate')\n        rotationRef.current += rotationDelta\n        if (previewRef.current) {\n          previewRef.current.rotation.y = rotationRef.current\n        }\n      }\n    }\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    window.addEventListener('keydown', onKeyDown)\n\n    return () => {\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      window.removeEventListener('keydown', onKeyDown)\n    }\n  }, [currentLevelId])\n\n  return (\n    <group>\n      <CursorSphere ref={cursorRef} />\n\n      {/* 3D ghost preview — position/rotation updated imperatively */}\n      <group ref={previewRef}>\n        <mesh castShadow geometry={previewGeometry}>\n          <meshStandardMaterial color=\"#818cf8\" depthWrite={false} opacity={0.35} transparent />\n        </mesh>\n      </group>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/tool-manager.tsx",
    "content": "import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport useEditor, { type Phase, type Tool } from '../../store/use-editor'\nimport { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'\nimport { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'\nimport { CeilingTool } from './ceiling/ceiling-tool'\nimport { DoorTool } from './door/door-tool'\nimport { ItemTool } from './item/item-tool'\nimport { MoveTool } from './item/move-tool'\nimport { RoofTool } from './roof/roof-tool'\nimport { SiteBoundaryEditor } from './site/site-boundary-editor'\nimport { SlabBoundaryEditor } from './slab/slab-boundary-editor'\nimport { SlabHoleEditor } from './slab/slab-hole-editor'\nimport { SlabTool } from './slab/slab-tool'\nimport { StairTool } from './stair/stair-tool'\nimport { WallTool } from './wall/wall-tool'\nimport { WindowTool } from './window/window-tool'\nimport { ZoneBoundaryEditor } from './zone/zone-boundary-editor'\nimport { ZoneTool } from './zone/zone-tool'\n\nconst tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {\n  site: {\n    'property-line': SiteBoundaryEditor,\n  },\n  structure: {\n    wall: WallTool,\n    slab: SlabTool,\n    ceiling: CeilingTool,\n    roof: RoofTool,\n    stair: StairTool,\n    door: DoorTool,\n    item: ItemTool,\n    zone: ZoneTool,\n    window: WindowTool,\n  },\n  furnish: {\n    item: ItemTool,\n  },\n}\n\nexport const ToolManager: React.FC = () => {\n  const phase = useEditor((state) => state.phase)\n  const mode = useEditor((state) => state.mode)\n  const tool = useEditor((state) => state.tool)\n  const movingNode = useEditor((state) => state.movingNode)\n  const editingHole = useEditor((state) => state.editingHole)\n  const selectedZoneId = useViewer((state) => state.selection.zoneId)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const nodes = useScene((state) => state.nodes)\n\n  // Check if a slab is selected\n  const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as\n    | SlabNode['id']\n    | undefined\n\n  // Check if a ceiling is selected\n  const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as\n    | CeilingNode['id']\n    | undefined\n\n  // Show site boundary editor when in site phase (toggle controls entry/exit)\n  const showSiteBoundaryEditor = phase === 'site'\n\n  // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)\n  const showSlabBoundaryEditor =\n    phase === 'structure' &&\n    mode === 'select' &&\n    selectedSlabId !== undefined &&\n    (!editingHole || editingHole.nodeId !== selectedSlabId)\n\n  // Show slab hole editor when editing a hole on the selected slab\n  const showSlabHoleEditor =\n    selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId\n\n  // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)\n  const showCeilingBoundaryEditor =\n    phase === 'structure' &&\n    mode === 'select' &&\n    selectedCeilingId !== undefined &&\n    (!editingHole || editingHole.nodeId !== selectedCeilingId)\n\n  // Show ceiling hole editor when editing a hole on the selected ceiling\n  const showCeilingHoleEditor =\n    selectedCeilingId !== undefined &&\n    editingHole !== null &&\n    editingHole.nodeId === selectedCeilingId\n\n  // Show zone boundary editor when in structure/select mode with a zone selected\n  // Hide when editing a slab or ceiling to avoid overlapping handles\n  const showZoneBoundaryEditor =\n    phase === 'structure' &&\n    mode === 'select' &&\n    selectedZoneId !== null &&\n    !showSlabBoundaryEditor &&\n    !showCeilingBoundaryEditor\n\n  // Show build tools when in build mode\n  const showBuildTool = mode === 'build' && tool !== null\n\n  const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null\n\n  return (\n    <>\n      {showSiteBoundaryEditor && <SiteBoundaryEditor />}\n      {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}\n      {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}\n      {showSlabHoleEditor && selectedSlabId && editingHole && (\n        <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />\n      )}\n      {showCeilingBoundaryEditor && selectedCeilingId && (\n        <CeilingBoundaryEditor ceilingId={selectedCeilingId} />\n      )}\n      {showCeilingHoleEditor && selectedCeilingId && editingHole && (\n        <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />\n      )}\n      {movingNode && <MoveTool />}\n      {!movingNode && BuildToolComponent && <BuildToolComponent />}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/wall/wall-drafting.ts",
    "content": "import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nexport type WallPlanPoint = [number, number]\nexport const WALL_GRID_STEP = 0.5\nexport const WALL_JOIN_SNAP_RADIUS = 0.35\nexport const WALL_MIN_LENGTH = 0.5\nfunction distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {\n  const dx = a[0] - b[0]\n  const dz = a[1] - b[1]\n  return dx * dx + dz * dz\n}\nfunction snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {\n  return Math.round(value / step) * step\n}\nexport function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {\n  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]\n}\nexport function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {\n  const dx = cursor[0] - start[0]\n  const dz = cursor[1] - start[1]\n  const angle = Math.atan2(dz, dx)\n  const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)\n  const distance = Math.sqrt(dx * dx + dz * dz)\n  return snapPointToGrid([\n    start[0] + Math.cos(snappedAngle) * distance,\n    start[1] + Math.sin(snappedAngle) * distance,\n  ])\n}\nfunction projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {\n  const [x1, z1] = wall.start\n  const [x2, z2] = wall.end\n  const dx = x2 - x1\n  const dz = z2 - z1\n  const lengthSquared = dx * dx + dz * dz\n  if (lengthSquared < 1e-9) {\n    return null\n  }\n  const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared\n  if (t <= 0 || t >= 1) {\n    return null\n  }\n  return [x1 + dx * t, z1 + dz * t]\n}\nexport function findWallSnapTarget(\n  point: WallPlanPoint,\n  walls: WallNode[],\n  options?: { ignoreWallIds?: string[]; radius?: number },\n): WallPlanPoint | null {\n  const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])\n  const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2\n  let bestTarget: WallPlanPoint | null = null\n  let bestDistanceSquared = Number.POSITIVE_INFINITY\n  for (const wall of walls) {\n    if (ignoreWallIds.has(wall.id)) {\n      continue\n    }\n    const candidates: Array<WallPlanPoint | null> = [\n      wall.start,\n      wall.end,\n      projectPointOntoWall(point, wall),\n    ]\n    for (const candidate of candidates) {\n      if (!candidate) {\n        continue\n      }\n      const candidateDistanceSquared = distanceSquared(point, candidate)\n      if (\n        candidateDistanceSquared > radiusSquared ||\n        candidateDistanceSquared >= bestDistanceSquared\n      ) {\n        continue\n      }\n      bestTarget = candidate\n      bestDistanceSquared = candidateDistanceSquared\n    }\n  }\n  return bestTarget\n}\nexport function snapWallDraftPoint(args: {\n  point: WallPlanPoint\n  walls: WallNode[]\n  start?: WallPlanPoint\n  angleSnap?: boolean\n  ignoreWallIds?: string[]\n}): WallPlanPoint {\n  const { point, walls, start, angleSnap = false, ignoreWallIds } = args\n  const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)\n  return (\n    findWallSnapTarget(basePoint, walls, {\n      ignoreWallIds,\n    }) ?? basePoint\n  )\n}\nexport function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {\n  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH\n}\nexport function createWallOnCurrentLevel(\n  start: WallPlanPoint,\n  end: WallPlanPoint,\n): WallNode | null {\n  const currentLevelId = useViewer.getState().selection.levelId\n  const { createNode, nodes } = useScene.getState()\n  if (!(currentLevelId && isWallLongEnough(start, end))) {\n    return null\n  }\n  const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length\n  const wall = WallSchema.parse({\n    name: `Wall ${wallCount + 1}`,\n    start,\n    end,\n  })\n  createNode(wall, currentLevelId)\n  sfxEmitter.emit('sfx:structure-build')\n  return wall\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/wall/wall-tool.tsx",
    "content": "import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\nimport { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'\nimport { markToolCancelConsumed } from '../../../hooks/use-keyboard'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport { CursorSphere } from '../shared/cursor-sphere'\nimport {\n  createWallOnCurrentLevel,\n  snapWallDraftPoint,\n  WALL_MIN_LENGTH,\n  type WallPlanPoint,\n} from './wall-drafting'\n\nconst WALL_HEIGHT = 2.5\n\n/**\n * Update wall preview mesh geometry to create a vertical plane between two points\n */\nconst updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {\n  // Calculate direction and perpendicular for wall thickness\n  const direction = new Vector3(end.x - start.x, 0, end.z - start.z)\n  const length = direction.length()\n\n  if (length < WALL_MIN_LENGTH) {\n    mesh.visible = false\n    return\n  }\n\n  mesh.visible = true\n  direction.normalize()\n\n  // Create wall shape (vertical rectangle in XY plane)\n  const shape = new Shape()\n  shape.moveTo(0, 0)\n  shape.lineTo(length, 0)\n  shape.lineTo(length, WALL_HEIGHT)\n  shape.lineTo(0, WALL_HEIGHT)\n  shape.closePath()\n\n  // Create geometry\n  const geometry = new ShapeGeometry(shape)\n\n  // Calculate rotation angle\n  // Negate the angle to fix the opposite direction issue\n  const angle = -Math.atan2(direction.z, direction.x)\n\n  // Position at start point and rotate\n  mesh.position.set(start.x, start.y, start.z)\n  mesh.rotation.y = angle\n\n  // Dispose old geometry and assign new one\n  if (mesh.geometry) {\n    mesh.geometry.dispose()\n  }\n  mesh.geometry = geometry\n}\n\nconst getCurrentLevelWalls = (): WallNode[] => {\n  const currentLevelId = useViewer.getState().selection.levelId\n  const { nodes } = useScene.getState()\n\n  if (!currentLevelId) return []\n\n  const levelNode = nodes[currentLevelId]\n  if (!levelNode || levelNode.type !== 'level') return []\n\n  return (levelNode as LevelNode).children\n    .map((childId) => nodes[childId])\n    .filter((node): node is WallNode => node?.type === 'wall')\n}\n\nexport const WallTool: React.FC = () => {\n  const cursorRef = useRef<Group>(null)\n  const wallPreviewRef = useRef<Mesh>(null!)\n  const startingPoint = useRef(new Vector3(0, 0, 0))\n  const endingPoint = useRef(new Vector3(0, 0, 0))\n  const buildingState = useRef(0)\n  const shiftPressed = useRef(false)\n\n  useEffect(() => {\n    let gridPosition: WallPlanPoint = [0, 0]\n    let previousWallEnd: [number, number] | null = null\n\n    const onGridMove = (event: GridEvent) => {\n      if (!(cursorRef.current && wallPreviewRef.current)) return\n\n      const walls = getCurrentLevelWalls()\n      const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]\n      gridPosition = snapWallDraftPoint({\n        point: cursorPoint,\n        walls,\n      })\n\n      if (buildingState.current === 1) {\n        const snappedPoint = snapWallDraftPoint({\n          point: cursorPoint,\n          walls,\n          start: [startingPoint.current.x, startingPoint.current.z],\n          angleSnap: !shiftPressed.current,\n        })\n        const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])\n        endingPoint.current.copy(snapped)\n\n        // Position the cursor at the end of the wall being drawn\n        cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)\n\n        // Play snap sound only when the actual wall end position changes\n        const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]\n        if (\n          previousWallEnd &&\n          (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])\n        ) {\n          sfxEmitter.emit('sfx:grid-snap')\n        }\n        previousWallEnd = currentWallEnd\n\n        // Update wall preview geometry\n        updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)\n      } else {\n        // Not drawing a wall yet, show the snapped anchor point.\n        cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])\n      }\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      const walls = getCurrentLevelWalls()\n      const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]\n\n      if (buildingState.current === 0) {\n        const snappedStart = snapWallDraftPoint({\n          point: clickPoint,\n          walls,\n        })\n        gridPosition = snappedStart\n        startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])\n        endingPoint.current.copy(startingPoint.current)\n        buildingState.current = 1\n        wallPreviewRef.current.visible = true\n      } else if (buildingState.current === 1) {\n        const snappedEnd = snapWallDraftPoint({\n          point: clickPoint,\n          walls,\n          start: [startingPoint.current.x, startingPoint.current.z],\n          angleSnap: !shiftPressed.current,\n        })\n        endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])\n        const dx = endingPoint.current.x - startingPoint.current.x\n        const dz = endingPoint.current.z - startingPoint.current.z\n        if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return\n        createWallOnCurrentLevel(\n          [startingPoint.current.x, startingPoint.current.z],\n          [endingPoint.current.x, endingPoint.current.z],\n        )\n        wallPreviewRef.current.visible = false\n        buildingState.current = 0\n      }\n    }\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') {\n        shiftPressed.current = true\n      }\n    }\n\n    const onKeyUp = (e: KeyboardEvent) => {\n      if (e.key === 'Shift') {\n        shiftPressed.current = false\n      }\n    }\n\n    const onCancel = () => {\n      if (buildingState.current === 1) {\n        markToolCancelConsumed()\n        buildingState.current = 0\n        wallPreviewRef.current.visible = false\n      }\n    }\n\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('tool:cancel', onCancel)\n    window.addEventListener('keydown', onKeyDown)\n    window.addEventListener('keyup', onKeyUp)\n\n    return () => {\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('tool:cancel', onCancel)\n      window.removeEventListener('keydown', onKeyDown)\n      window.removeEventListener('keyup', onKeyUp)\n    }\n  }, [])\n\n  return (\n    <group>\n      {/* Cursor indicator */}\n      <CursorSphere ref={cursorRef} />\n\n      {/* Wall preview */}\n      <mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>\n        <shapeGeometry />\n        <meshBasicMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          opacity={0.5}\n          side={DoubleSide}\n          transparent\n        />\n      </mesh>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/window/move-window-tool.tsx",
    "content": "import {\n  type AnyNodeId,\n  emitter,\n  sceneRegistry,\n  spatialGridManager,\n  useScene,\n  type WallEvent,\n  WindowNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback, useEffect, useMemo, useRef } from 'react'\nimport { BoxGeometry, EdgesGeometry, type Group } from 'three'\nimport { LineBasicNodeMaterial } from 'three/webgpu'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport {\n  calculateCursorRotation,\n  calculateItemRotation,\n  getSideFromNormal,\n  isValidWallSideFace,\n  snapToHalf,\n} from '../item/placement-math'\nimport { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'\n\nconst edgeMaterial = new LineBasicNodeMaterial({\n  color: 0xef_44_44,\n  linewidth: 3,\n  depthTest: false,\n  depthWrite: false,\n})\n\n/**\n * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.\n *\n * Move mode (metadata.isNew falsy):\n *   Adopts the existing window, pauses temporal. On commit: restores original state\n *   (clean undo baseline) then resumes + updateNode (undo reverts to original position).\n *   On cancel: restores original state.\n *\n * Duplicate mode (metadata.isNew = true):\n *   The node is a freshly created transient copy. On commit: deletes transient + resumes\n *   + createNode (undo removes the new window entirely). On cancel: deletes the node.\n */\nexport const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {\n  const cursorGroupRef = useRef<Group>(null!)\n\n  const exitMoveMode = useCallback(() => {\n    useEditor.getState().setMovingNode(null)\n  }, [])\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const meta =\n      typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null\n        ? (movingWindowNode.metadata as Record<string, unknown>)\n        : {}\n    const isNew = !!meta.isNew\n\n    // Save original state (only used in move mode)\n    const original = {\n      position: [...movingWindowNode.position] as [number, number, number],\n      rotation: [...movingWindowNode.rotation] as [number, number, number],\n      side: movingWindowNode.side,\n      parentId: movingWindowNode.parentId,\n      wallId: movingWindowNode.wallId,\n      metadata: movingWindowNode.metadata,\n    }\n\n    if (!isNew) {\n      // Move mode: mark the existing window as transient so it hides while being repositioned\n      useScene.getState().updateNode(movingWindowNode.id, {\n        metadata: { ...meta, isTransient: true },\n      })\n    }\n\n    let currentWallId: string | null = movingWindowNode.parentId\n\n    const markWallDirty = (wallId: string | null) => {\n      if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)\n    }\n\n    const getLevelId = () => useViewer.getState().selection.levelId\n    const getLevelYOffset = () => {\n      const id = getLevelId()\n      return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0\n    }\n    const getSlabElevation = (wallEvent: WallEvent) =>\n      spatialGridManager.getSlabElevationForWall(\n        wallEvent.node.parentId ?? '',\n        wallEvent.node.start,\n        wallEvent.node.end,\n      )\n\n    const hideCursor = () => {\n      if (cursorGroupRef.current) cursorGroupRef.current.visible = false\n    }\n\n    const updateCursor = (\n      worldPosition: [number, number, number],\n      cursorRotationY: number,\n      valid: boolean,\n    ) => {\n      const group = cursorGroupRef.current\n      if (!group) return\n      group.visible = true\n      group.position.set(...worldPosition)\n      group.rotation.y = cursorRotationY\n      edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)\n    }\n\n    const onWallEnter = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        localY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n      )\n\n      const prevWallId = currentWallId\n      currentWallId = event.node.id\n\n      useScene.getState().updateNode(movingWindowNode.id, {\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        parentId: event.node.id,\n        wallId: event.node.id,\n      })\n\n      if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)\n      markWallDirty(event.node.id)\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n        movingWindowNode.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallMove = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        localY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n      )\n\n      useScene.getState().updateNode(movingWindowNode.id, {\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        parentId: event.node.id,\n        wallId: event.node.id,\n      })\n\n      if (currentWallId !== event.node.id) {\n        markWallDirty(currentWallId)\n        currentWallId = event.node.id\n      }\n      markWallDirty(event.node.id)\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n        movingWindowNode.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallClick = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        localY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n      )\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        movingWindowNode.width,\n        movingWindowNode.height,\n        movingWindowNode.id,\n      )\n      if (!valid) return\n\n      let placedId: string\n\n      if (isNew) {\n        // Duplicate mode: delete transient + resume + createNode\n        // Undo will remove the newly created node entirely\n        useScene.getState().deleteNode(movingWindowNode.id)\n        useScene.temporal.getState().resume()\n\n        const node = WindowNode.parse({\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          wallId: event.node.id,\n          parentId: event.node.id,\n          width: movingWindowNode.width,\n          height: movingWindowNode.height,\n          frameThickness: movingWindowNode.frameThickness,\n          frameDepth: movingWindowNode.frameDepth,\n          columnRatios: movingWindowNode.columnRatios,\n          rowRatios: movingWindowNode.rowRatios,\n          columnDividerThickness: movingWindowNode.columnDividerThickness,\n          rowDividerThickness: movingWindowNode.rowDividerThickness,\n          sill: movingWindowNode.sill,\n          sillDepth: movingWindowNode.sillDepth,\n          sillThickness: movingWindowNode.sillThickness,\n        })\n        useScene.getState().createNode(node, event.node.id as AnyNodeId)\n        placedId = node.id\n      } else {\n        // Move mode: restore original (clean baseline) + resume + updateNode\n        // Undo will revert to the original position\n        useScene.getState().updateNode(movingWindowNode.id, {\n          position: original.position,\n          rotation: original.rotation,\n          side: original.side,\n          parentId: original.parentId,\n          wallId: original.wallId,\n          metadata: original.metadata,\n        })\n        useScene.temporal.getState().resume()\n\n        useScene.getState().updateNode(movingWindowNode.id, {\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          parentId: event.node.id,\n          wallId: event.node.id,\n          metadata: {},\n        })\n\n        if (original.parentId && original.parentId !== event.node.id) {\n          markWallDirty(original.parentId)\n        }\n        placedId = movingWindowNode.id\n      }\n\n      markWallDirty(event.node.id)\n      useScene.temporal.getState().pause()\n\n      sfxEmitter.emit('sfx:item-place')\n      hideCursor()\n      useViewer.getState().setSelection({ selectedIds: [placedId] })\n      exitMoveMode()\n      event.stopPropagation()\n    }\n\n    const onWallLeave = () => {\n      hideCursor()\n      if (isNew) return // No original to restore for duplicates\n      // Move mode: restore to original position while off-wall\n      if (currentWallId && currentWallId !== original.parentId) {\n        markWallDirty(currentWallId)\n      }\n      currentWallId = original.parentId\n      useScene.getState().updateNode(movingWindowNode.id, {\n        position: original.position,\n        rotation: original.rotation,\n        side: original.side,\n        parentId: original.parentId,\n        wallId: original.wallId,\n      })\n      if (original.parentId) markWallDirty(original.parentId)\n    }\n\n    const onCancel = () => {\n      if (isNew) {\n        useScene.getState().deleteNode(movingWindowNode.id)\n        if (currentWallId) markWallDirty(currentWallId)\n      } else {\n        useScene.getState().updateNode(movingWindowNode.id, {\n          position: original.position,\n          rotation: original.rotation,\n          side: original.side,\n          parentId: original.parentId,\n          wallId: original.wallId,\n          metadata: original.metadata,\n        })\n        if (original.parentId) markWallDirty(original.parentId)\n      }\n      useScene.temporal.getState().resume()\n      hideCursor()\n      exitMoveMode()\n    }\n\n    emitter.on('wall:enter', onWallEnter)\n    emitter.on('wall:move', onWallMove)\n    emitter.on('wall:click', onWallClick)\n    emitter.on('wall:leave', onWallLeave)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)\n      const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as\n        | WindowNode\n        | undefined\n      const currentMeta = current?.metadata as Record<string, unknown> | undefined\n      if (currentMeta?.isTransient) {\n        if (isNew) {\n          useScene.getState().deleteNode(movingWindowNode.id)\n          if (currentWallId) markWallDirty(currentWallId)\n        } else {\n          useScene.getState().updateNode(movingWindowNode.id, {\n            position: original.position,\n            rotation: original.rotation,\n            side: original.side,\n            parentId: original.parentId,\n            wallId: original.wallId,\n            metadata: original.metadata,\n          })\n          if (original.parentId) markWallDirty(original.parentId)\n        }\n      }\n      useScene.temporal.getState().resume()\n      emitter.off('wall:enter', onWallEnter)\n      emitter.off('wall:move', onWallMove)\n      emitter.off('wall:click', onWallClick)\n      emitter.off('wall:leave', onWallLeave)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [movingWindowNode, exitMoveMode])\n\n  const edgesGeo = useMemo(() => {\n    const boxGeo = new BoxGeometry(\n      movingWindowNode.width,\n      movingWindowNode.height,\n      movingWindowNode.frameDepth ?? 0.07,\n    )\n    const geo = new EdgesGeometry(boxGeo)\n    boxGeo.dispose()\n    return geo\n  }, [movingWindowNode])\n\n  return (\n    <group ref={cursorGroupRef} visible={false}>\n      <lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/window/window-math.ts",
    "content": "import {\n  type AnyNodeId,\n  type DoorNode,\n  getScaledDimensions,\n  type ItemNode,\n  useScene,\n  type WallNode,\n  type WindowNode,\n} from '@pascal-app/core'\n\n/**\n * Converts wall-local (X along wall, Y = height above wall base) to world XYZ.\n * Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).\n * Pass levelYOffset (the level group's current world Y) and slabElevation (the\n * wall mesh's Y within the level group) so the cursor lands at the correct world\n * height — matching how WallSystem positions the wall mesh at slabElevation.\n */\nexport function wallLocalToWorld(\n  wallNode: WallNode,\n  localX: number,\n  localY: number,\n  levelYOffset = 0,\n  slabElevation = 0,\n): [number, number, number] {\n  const wallAngle = Math.atan2(\n    wallNode.end[1] - wallNode.start[1],\n    wallNode.end[0] - wallNode.start[0],\n  )\n  return [\n    wallNode.start[0] + localX * Math.cos(wallAngle),\n    slabElevation + localY + levelYOffset,\n    wallNode.start[1] + localX * Math.sin(wallAngle),\n  ]\n}\n\n/**\n * Clamps window center position so it stays fully within wall bounds.\n */\nexport function clampToWall(\n  wallNode: WallNode,\n  localX: number,\n  localY: number,\n  width: number,\n  height: number,\n): { clampedX: number; clampedY: number } {\n  const dx = wallNode.end[0] - wallNode.start[0]\n  const dz = wallNode.end[1] - wallNode.start[1]\n  const wallLength = Math.sqrt(dx * dx + dz * dz)\n  const wallHeight = wallNode.height ?? 2.5\n\n  const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))\n  const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))\n  return { clampedX, clampedY }\n}\n\n/**\n * Directly checks the wall's children for bounding-box overlap with a proposed window.\n * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).\n * The spatial grid only tracks `item` nodes, so windows must be checked this way.\n * Reads the wall's latest children from the store (not the event node) to avoid stale data.\n */\nexport function hasWallChildOverlap(\n  wallId: string,\n  clampedX: number,\n  clampedY: number,\n  width: number,\n  height: number,\n  ignoreId?: string,\n): boolean {\n  const nodes = useScene.getState().nodes\n  const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined\n  if (!wallNode) return true // Block if wall not found\n  const halfW = width / 2\n  const halfH = height / 2\n  const newBottom = clampedY - halfH\n  const newTop = clampedY + halfH\n  const newLeft = clampedX - halfW\n  const newRight = clampedX + halfW\n\n  for (const childId of wallNode.children) {\n    if (childId === ignoreId) continue\n    const child = nodes[childId as AnyNodeId]\n    if (!child) continue\n\n    let childLeft: number, childRight: number, childBottom: number, childTop: number\n\n    if (child.type === 'item') {\n      const item = child as ItemNode\n      if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue\n      const [w, h] = getScaledDimensions(item)\n      childLeft = item.position[0] - w / 2\n      childRight = item.position[0] + w / 2\n      childBottom = item.position[1] // items store bottom Y\n      childTop = item.position[1] + h\n    } else if (child.type === 'window') {\n      const win = child as WindowNode\n      childLeft = win.position[0] - win.width / 2\n      childRight = win.position[0] + win.width / 2\n      childBottom = win.position[1] - win.height / 2 // windows store center Y\n      childTop = win.position[1] + win.height / 2\n    } else if (child.type === 'door') {\n      const door = child as DoorNode\n      childLeft = door.position[0] - door.width / 2\n      childRight = door.position[0] + door.width / 2\n      childBottom = door.position[1] - door.height / 2 // doors store center Y\n      childTop = door.position[1] + door.height / 2\n    } else {\n      continue\n    }\n\n    const xOverlap = newLeft < childRight && newRight > childLeft\n    const yOverlap = newBottom < childTop && newTop > childBottom\n    if (xOverlap && yOverlap) return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/window/window-tool.tsx",
    "content": "import {\n  type AnyNodeId,\n  emitter,\n  sceneRegistry,\n  spatialGridManager,\n  useScene,\n  type WallEvent,\n  WindowNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useRef } from 'react'\nimport { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'\nimport { LineBasicNodeMaterial } from 'three/webgpu'\nimport { EDITOR_LAYER } from '../../../lib/constants'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport {\n  calculateCursorRotation,\n  calculateItemRotation,\n  getSideFromNormal,\n  isValidWallSideFace,\n  snapToHalf,\n} from '../item/placement-math'\nimport { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'\n\n// Shared edge material — reuse across renders, just toggle color\nconst edgeMaterial = new LineBasicNodeMaterial({\n  color: 0xef_44_44, // red-500 default (invalid)\n  linewidth: 3,\n  depthTest: false,\n  depthWrite: false,\n})\n\n/**\n * Window tool — places WindowNodes on walls only.\n * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.\n */\nexport const WindowTool: React.FC = () => {\n  const draftRef = useRef<WindowNode | null>(null)\n  const cursorGroupRef = useRef<Group>(null!)\n  const edgesRef = useRef<LineSegments>(null!)\n\n  useEffect(() => {\n    useScene.temporal.getState().pause()\n\n    const getLevelId = () => useViewer.getState().selection.levelId\n    const getLevelYOffset = () => {\n      const id = getLevelId()\n      return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0\n    }\n    const getSlabElevation = (wallEvent: WallEvent) =>\n      spatialGridManager.getSlabElevationForWall(\n        wallEvent.node.parentId ?? '',\n        wallEvent.node.start,\n        wallEvent.node.end,\n      )\n\n    const markWallDirty = (wallId: string) => {\n      useScene.getState().dirtyNodes.add(wallId as AnyNodeId)\n    }\n\n    const destroyDraft = () => {\n      if (!draftRef.current) return\n      const wallId = draftRef.current.parentId\n      useScene.getState().deleteNode(draftRef.current.id)\n      draftRef.current = null\n      // Rebuild wall so it removes the cutout from the deleted draft\n      if (wallId) markWallDirty(wallId)\n    }\n\n    const hideCursor = () => {\n      if (cursorGroupRef.current) cursorGroupRef.current.visible = false\n    }\n\n    const updateCursor = (\n      worldPosition: [number, number, number],\n      cursorRotationY: number,\n      valid: boolean,\n    ) => {\n      const group = cursorGroupRef.current\n      if (!group) return\n      group.visible = true\n      group.position.set(...worldPosition)\n      group.rotation.y = cursorRotationY\n      edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)\n    }\n\n    const onWallEnter = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      const levelId = getLevelId()\n      if (!levelId) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== levelId) return\n\n      destroyDraft()\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n\n      const width = 1.5\n      const height = 1.5\n\n      const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)\n\n      const node = WindowNode.parse({\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        wallId: event.node.id,\n        parentId: event.node.id,\n        metadata: { isTransient: true },\n      })\n\n      useScene.getState().createNode(node, event.node.id as AnyNodeId)\n      draftRef.current = node\n\n      const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallMove = (event: WallEvent) => {\n      if (!isValidWallSideFace(event.normal)) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n      const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n\n      const width = draftRef.current?.width ?? 1.5\n      const height = draftRef.current?.height ?? 1.5\n\n      const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)\n\n      if (draftRef.current) {\n        useScene.getState().updateNode(draftRef.current.id, {\n          position: [clampedX, clampedY, 0],\n          rotation: [0, itemRotation, 0],\n          side,\n          parentId: event.node.id,\n          wallId: event.node.id,\n        })\n      }\n\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        width,\n        height,\n        draftRef.current?.id,\n      )\n\n      updateCursor(\n        wallLocalToWorld(\n          event.node,\n          clampedX,\n          clampedY,\n          getLevelYOffset(),\n          getSlabElevation(event),\n        ),\n        cursorRotation,\n        valid,\n      )\n      event.stopPropagation()\n    }\n\n    const onWallClick = (event: WallEvent) => {\n      if (!draftRef.current) return\n      if (!isValidWallSideFace(event.normal)) return\n      // Only interact with walls on the current level\n      if (event.node.parentId !== getLevelId()) return\n\n      const side = getSideFromNormal(event.normal)\n      const itemRotation = calculateItemRotation(event.normal)\n\n      const localX = snapToHalf(event.localPosition[0])\n      const localY = snapToHalf(event.localPosition[1])\n      const { clampedX, clampedY } = clampToWall(\n        event.node,\n        localX,\n        localY,\n        draftRef.current.width,\n        draftRef.current.height,\n      )\n      const valid = !hasWallChildOverlap(\n        event.node.id,\n        clampedX,\n        clampedY,\n        draftRef.current.width,\n        draftRef.current.height,\n        draftRef.current.id,\n      )\n      if (!valid) return\n\n      const draft = draftRef.current\n      draftRef.current = null\n\n      // Delete transient draft (paused, invisible to undo)\n      useScene.getState().deleteNode(draft.id)\n\n      // Resume → create permanent node (single undoable action)\n      useScene.temporal.getState().resume()\n\n      const levelId = getLevelId()\n      const state = useScene.getState()\n      const windowCount = Object.values(state.nodes).filter((n) => {\n        if (n.type !== 'window') return false\n        const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined\n        return wall?.parentId === levelId\n      }).length\n      const name = `Window ${windowCount + 1}`\n\n      const node = WindowNode.parse({\n        name,\n        position: [clampedX, clampedY, 0],\n        rotation: [0, itemRotation, 0],\n        side,\n        wallId: event.node.id,\n        parentId: event.node.id,\n        width: draft.width,\n        height: draft.height,\n        frameThickness: draft.frameThickness,\n        frameDepth: draft.frameDepth,\n        columnRatios: draft.columnRatios,\n        rowRatios: draft.rowRatios,\n        columnDividerThickness: draft.columnDividerThickness,\n        rowDividerThickness: draft.rowDividerThickness,\n        sill: draft.sill,\n        sillDepth: draft.sillDepth,\n        sillThickness: draft.sillThickness,\n      })\n\n      useScene.getState().createNode(node, event.node.id as AnyNodeId)\n      useViewer.getState().setSelection({ selectedIds: [node.id] })\n      useScene.temporal.getState().pause()\n      sfxEmitter.emit('sfx:item-place')\n\n      event.stopPropagation()\n    }\n\n    const onWallLeave = () => {\n      destroyDraft()\n      hideCursor()\n    }\n\n    const onCancel = () => {\n      destroyDraft()\n      hideCursor()\n    }\n\n    emitter.on('wall:enter', onWallEnter)\n    emitter.on('wall:move', onWallMove)\n    emitter.on('wall:click', onWallClick)\n    emitter.on('wall:leave', onWallLeave)\n    emitter.on('tool:cancel', onCancel)\n\n    return () => {\n      destroyDraft()\n      hideCursor()\n      useScene.temporal.getState().resume()\n      emitter.off('wall:enter', onWallEnter)\n      emitter.off('wall:move', onWallMove)\n      emitter.off('wall:click', onWallClick)\n      emitter.off('wall:leave', onWallLeave)\n      emitter.off('tool:cancel', onCancel)\n    }\n  }, [])\n\n  // Cursor geometry: window outline rectangle (width × height × frameDepth)\n  const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)\n  const edgesGeo = new EdgesGeometry(boxGeo)\n  boxGeo.dispose()\n\n  return (\n    <group ref={cursorGroupRef} visible={false}>\n      <lineSegments\n        geometry={edgesGeo}\n        layers={EDITOR_LAYER}\n        material={edgeMaterial}\n        ref={edgesRef}\n      />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/zone/zone-boundary-editor.tsx",
    "content": "import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'\nimport { useCallback } from 'react'\nimport { PolygonEditor } from '../shared/polygon-editor'\n\ninterface ZoneBoundaryEditorProps {\n  zoneId: ZoneNode['id']\n}\n\n/**\n * Zone boundary editor - allows editing zone polygon vertices for a specific zone\n * Uses the generic PolygonEditor component\n */\nexport const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {\n  const zoneNode = useScene((state) => state.nodes[zoneId])\n  const updateNode = useScene((state) => state.updateNode)\n\n  const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null\n\n  const handlePolygonChange = useCallback(\n    (newPolygon: Array<[number, number]>) => {\n      updateNode(zoneId, { polygon: newPolygon })\n    },\n    [zoneId, updateNode],\n  )\n\n  if (!zone?.polygon || zone.polygon.length < 3) return null\n\n  const zoneColor = zone.color || '#3b82f6'\n\n  return (\n    <PolygonEditor\n      color={zoneColor}\n      levelId={resolveLevelId(zone, useScene.getState().nodes)}\n      minVertices={3}\n      onPolygonChange={handlePolygonChange}\n      polygon={zone.polygon}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/tools/zone/zone-tool.tsx",
    "content": "import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'\nimport { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'\nimport { EDITOR_LAYER } from './../../../lib/constants'\nimport useEditor from './../../../store/use-editor'\nimport { CursorSphere } from '../shared/cursor-sphere'\n\nconst Y_OFFSET = 0.02\n\n/**\n * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point\n */\nconst calculateSnapPoint = (\n  lastPoint: [number, number],\n  currentPoint: [number, number],\n): [number, number] => {\n  const [x1, y1] = lastPoint\n  const [x, y] = currentPoint\n\n  const dx = x - x1\n  const dy = y - y1\n  const absDx = Math.abs(dx)\n  const absDy = Math.abs(dy)\n\n  // Calculate distances to horizontal, vertical, and diagonal lines\n  const horizontalDist = absDy\n  const verticalDist = absDx\n  const diagonalDist = Math.abs(absDx - absDy)\n\n  // Find the minimum distance to determine which axis to snap to\n  const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)\n\n  if (minDist === diagonalDist) {\n    // Snap to 45° diagonal\n    const diagonalLength = Math.min(absDx, absDy)\n    return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]\n  }\n  if (minDist === horizontalDist) {\n    // Snap to horizontal\n    return [x, y1]\n  }\n  // Snap to vertical\n  return [x1, y]\n}\n\n/**\n * Creates a zone with the given polygon points\n */\nconst commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {\n  const { createNode, nodes } = useScene.getState()\n\n  // Count existing zones for naming and color cycling\n  const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length\n  const name = `Zone ${zoneCount + 1}`\n\n  // Cycle through colors\n  const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length]\n\n  const zone = ZoneNode.parse({\n    name,\n    polygon: points,\n    color,\n  })\n\n  createNode(zone, levelId)\n\n  // Select the newly created zone\n  useViewer.getState().setSelection({ zoneId: zone.id })\n}\n\ntype PreviewState = {\n  points: Array<[number, number]>\n  cursorPoint: [number, number] | null\n  levelY: number\n}\n\n// Helper to validate point values (no NaN or Infinity)\nconst isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => {\n  if (!pt) return false\n  return Number.isFinite(pt[0]) && Number.isFinite(pt[1])\n}\n\nexport const ZoneTool: React.FC = () => {\n  const cursorRef = useRef<Group>(null)\n  const mainLineRef = useRef<Line>(null!)\n  const closingLineRef = useRef<Line>(null!)\n  const pointsRef = useRef<Array<[number, number]>>([])\n  const levelYRef = useRef(0) // Track current level Y position\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n  const setTool = useEditor((state) => state.setTool)\n\n  // Preview state for reactive rendering (for shape and point markers)\n  const [preview, setPreview] = useState<PreviewState>({\n    points: [],\n    cursorPoint: null,\n    levelY: 0,\n  })\n\n  useEffect(() => {\n    if (!currentLevelId) return\n\n    let cursorPosition: [number, number] = [0, 0]\n\n    // Initialize line geometries\n    mainLineRef.current.geometry = new BufferGeometry()\n    closingLineRef.current.geometry = new BufferGeometry()\n\n    const updateLines = () => {\n      const points = pointsRef.current\n      const y = levelYRef.current + Y_OFFSET\n\n      if (points.length === 0) {\n        mainLineRef.current.visible = false\n        closingLineRef.current.visible = false\n        return\n      }\n\n      // Build main line points\n      const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))\n\n      // Add cursor point\n      const lastPoint = points[points.length - 1]\n      if (lastPoint) {\n        const snapped = calculateSnapPoint(lastPoint, cursorPosition)\n        if (isValidPoint(snapped)) {\n          linePoints.push(new Vector3(snapped[0], y, snapped[1]))\n        }\n      }\n\n      // Update main line geometry\n      if (linePoints.length >= 2) {\n        mainLineRef.current.geometry.dispose()\n        mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)\n        mainLineRef.current.visible = true\n      } else {\n        mainLineRef.current.visible = false\n      }\n\n      // Update closing line (from cursor back to first point)\n      const firstPoint = points[0]\n      if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {\n        const snapped = calculateSnapPoint(lastPoint, cursorPosition)\n        if (isValidPoint(snapped)) {\n          const closingPoints = [\n            new Vector3(snapped[0], y, snapped[1]),\n            new Vector3(firstPoint[0], y, firstPoint[1]),\n          ]\n          closingLineRef.current.geometry.dispose()\n          closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)\n          closingLineRef.current.visible = true\n        }\n      } else {\n        closingLineRef.current.visible = false\n      }\n    }\n\n    const updatePreview = () => {\n      const points = pointsRef.current\n      const lastPoint = points[points.length - 1]\n\n      let cursorPt: [number, number] | null = null\n      if (lastPoint) {\n        cursorPt = calculateSnapPoint(lastPoint, cursorPosition)\n      } else if (points.length === 0) {\n        cursorPt = cursorPosition\n      }\n\n      setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })\n      updateLines()\n    }\n\n    const onGridMove = (event: GridEvent) => {\n      if (!cursorRef.current) return\n\n      // Snap to 0.5 grid\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      cursorPosition = [gridX, gridZ]\n      levelYRef.current = event.position[1]\n\n      // If we have points, snap to axis from last point\n      const lastPoint = pointsRef.current[pointsRef.current.length - 1]\n      if (lastPoint) {\n        const snapped = calculateSnapPoint(lastPoint, cursorPosition)\n        cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])\n      } else {\n        cursorRef.current.position.set(gridX, event.position[1], gridZ)\n      }\n\n      updatePreview()\n    }\n\n    const onGridClick = (event: GridEvent) => {\n      if (!currentLevelId) return\n\n      const gridX = Math.round(event.position[0] * 2) / 2\n      const gridZ = Math.round(event.position[2] * 2) / 2\n      let clickPoint: [number, number] = [gridX, gridZ]\n\n      // Snap to axis from last point\n      const lastPoint = pointsRef.current[pointsRef.current.length - 1]\n      if (lastPoint) {\n        clickPoint = calculateSnapPoint(lastPoint, clickPoint)\n      }\n\n      // Check if clicking on the first point to close the shape\n      const firstPoint = pointsRef.current[0]\n      if (\n        pointsRef.current.length >= 3 &&\n        firstPoint &&\n        Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&\n        Math.abs(clickPoint[1] - firstPoint[1]) < 0.25\n      ) {\n        // Create the zone\n        commitZoneDrawing(currentLevelId, pointsRef.current)\n\n        // Reset state\n        pointsRef.current = []\n        setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })\n        mainLineRef.current.visible = false\n        closingLineRef.current.visible = false\n      } else {\n        // Add point to polygon\n        pointsRef.current = [...pointsRef.current, clickPoint]\n        updatePreview()\n      }\n    }\n\n    const onGridDoubleClick = (_event: GridEvent) => {\n      if (!currentLevelId) return\n\n      // Need at least 3 points to form a polygon\n      if (pointsRef.current.length >= 3) {\n        commitZoneDrawing(currentLevelId, pointsRef.current)\n\n        // Reset state\n        pointsRef.current = []\n        setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })\n        mainLineRef.current.visible = false\n        closingLineRef.current.visible = false\n      }\n    }\n\n    // Subscribe to events\n    emitter.on('grid:move', onGridMove)\n    emitter.on('grid:click', onGridClick)\n    emitter.on('grid:double-click', onGridDoubleClick)\n\n    return () => {\n      emitter.off('grid:move', onGridMove)\n      emitter.off('grid:click', onGridClick)\n      emitter.off('grid:double-click', onGridDoubleClick)\n\n      // Reset state on unmount\n      pointsRef.current = []\n    }\n  }, [currentLevelId])\n\n  const { points, cursorPoint, levelY } = preview\n\n  // Create preview shape when we have 3+ points\n  const previewShape = useMemo(() => {\n    if (points.length < 3) return null\n\n    const allPoints = [...points]\n    if (isValidPoint(cursorPoint)) {\n      allPoints.push(cursorPoint)\n    }\n\n    // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:\n    // - Shape X -> World X\n    // - Shape Y -> World -Z (so we negate Z to get correct orientation)\n    const firstPt = allPoints[0]\n    if (!isValidPoint(firstPt)) return null\n\n    const shape = new Shape()\n    shape.moveTo(firstPt[0], -firstPt[1])\n\n    for (let i = 1; i < allPoints.length; i++) {\n      const pt = allPoints[i]\n      if (isValidPoint(pt)) {\n        shape.lineTo(pt[0], -pt[1])\n      }\n    }\n    shape.closePath()\n\n    return shape\n  }, [points, cursorPoint])\n\n  return (\n    <group>\n      {/* Cursor */}\n      <CursorSphere ref={cursorRef} />\n\n      {/* Preview fill */}\n      {previewShape && (\n        <mesh\n          frustumCulled={false}\n          layers={EDITOR_LAYER}\n          position={[0, levelY + Y_OFFSET, 0]}\n          rotation={[-Math.PI / 2, 0, 0]}\n        >\n          <shapeGeometry args={[previewShape]} />\n          <meshBasicMaterial\n            color=\"#818cf8\"\n            depthTest={false}\n            opacity={0.15}\n            side={DoubleSide}\n            transparent\n          />\n        </mesh>\n      )}\n\n      {/* Main line - uses native line element with TSL-compatible material */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={mainLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial color=\"#818cf8\" depthTest={false} depthWrite={false} linewidth={3} />\n      </line>\n\n      {/* Closing line - uses native line element with TSL-compatible material */}\n      {/* @ts-ignore */}\n      <line\n        frustumCulled={false}\n        layers={EDITOR_LAYER}\n        // @ts-expect-error\n        ref={closingLineRef}\n        renderOrder={1}\n        visible={false}\n      >\n        <bufferGeometry />\n        <lineBasicNodeMaterial\n          color=\"#818cf8\"\n          depthTest={false}\n          depthWrite={false}\n          linewidth={2}\n          opacity={0.5}\n          transparent\n        />\n      </line>\n\n      {/* Point markers */}\n      {points.map(([x, z], index) =>\n        isValidPoint([x, z]) ? (\n          <CursorSphere\n            color=\"#818cf8\"\n            height={0}\n            key={index}\n            position={[x, levelY + Y_OFFSET + 0.01, z]}\n            showTooltip={false}\n          />\n        ) : null,\n      )}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/action-button.tsx",
    "content": "import * as React from 'react'\nimport { Button } from './../../../components/ui/primitives/button'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from './../../../components/ui/primitives/tooltip'\nimport { cn } from './../../../lib/utils'\n\ninterface ActionButtonProps extends React.ComponentProps<typeof Button> {\n  label: string\n  shortcut?: string\n  isActive?: boolean\n  tooltipContent?: React.ReactNode\n  tooltipSide?: 'top' | 'right' | 'bottom' | 'left'\n}\n\nexport const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(\n  (\n    { className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },\n    ref,\n  ) => {\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            className={cn('relative h-11 w-11 transition-all', className)}\n            ref={ref}\n            {...props}\n          >\n            <div\n              className={cn(\n                'flex h-full w-full items-center justify-center transition-transform',\n                shortcut && '-translate-x-0.5 -translate-y-0.5',\n              )}\n            >\n              {children}\n            </div>\n            {shortcut && (\n              <div className=\"absolute right-1 bottom-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md\">\n                <span className=\"block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none\">\n                  {shortcut}\n                </span>\n              </div>\n            )}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent side={tooltipSide}>\n          {tooltipContent || (\n            <p>\n              {label} {shortcut && `(${shortcut})`}\n            </p>\n          )}\n        </TooltipContent>\n      </Tooltip>\n    )\n  },\n)\nActionButton.displayName = 'ActionButton'\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/camera-actions.tsx",
    "content": "'use client'\r\n\r\nimport { Icon } from '@iconify/react'\r\nimport { emitter } from '@pascal-app/core'\r\nimport Image from 'next/image'\r\nimport useEditor from '../../../store/use-editor'\r\nimport { ActionButton } from './action-button'\r\n\r\nexport function CameraActions() {\r\n  const goToTopView = () => {\r\n    emitter.emit('camera-controls:top-view')\r\n  }\r\n\r\n  const orbitCW = () => {\r\n    emitter.emit('camera-controls:orbit-cw')\r\n  }\r\n\r\n  const orbitCCW = () => {\r\n    emitter.emit('camera-controls:orbit-ccw')\r\n  }\r\n\r\n  const enterStreetView = () => {\r\n    useEditor.getState().setFirstPersonMode(true)\r\n  }\r\n\r\n  return (\r\n    <div className=\"flex items-center gap-1\">\r\n      {/* Orbit CCW */}\r\n      <ActionButton\r\n        className=\"group hover:bg-white/5\"\r\n        label=\"Orbit Left\"\r\n        onClick={orbitCCW}\r\n        size=\"icon\"\r\n        variant=\"ghost\"\r\n      >\r\n        <Image\r\n          alt=\"Orbit Left\"\r\n          className=\"h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n          height={28}\r\n          src=\"/icons/rotate.png\"\r\n          width={28}\r\n        />\r\n      </ActionButton>\r\n\r\n      {/* Orbit CW */}\r\n      <ActionButton\r\n        className=\"group hover:bg-white/5\"\r\n        label=\"Orbit Right\"\r\n        onClick={orbitCW}\r\n        size=\"icon\"\r\n        variant=\"ghost\"\r\n      >\r\n        <Image\r\n          alt=\"Orbit Right\"\r\n          className=\"h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n          height={28}\r\n          src=\"/icons/rotate.png\"\r\n          width={28}\r\n        />\r\n      </ActionButton>\r\n\r\n      {/* Top View */}\r\n      <ActionButton\r\n        className=\"group hover:bg-white/5\"\r\n        label=\"Top View\"\r\n        onClick={goToTopView}\r\n        size=\"icon\"\r\n        variant=\"ghost\"\r\n      >\r\n        <Image\r\n          alt=\"Top View\"\r\n          className=\"h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n          height={28}\r\n          src=\"/icons/topview.png\"\r\n          width={28}\r\n        />\r\n      </ActionButton>\r\n\r\n      {/* Street View */}\r\n      <ActionButton\r\n        className=\"group hover:bg-white/5\"\r\n        label=\"Street View\"\r\n        onClick={enterStreetView}\r\n        size=\"icon\"\r\n        variant=\"ghost\"\r\n      >\r\n        <Icon\r\n          className=\"opacity-70 transition-opacity group-hover:opacity-100\"\r\n          color=\"currentColor\"\r\n          height={22}\r\n          icon=\"mdi:walk\"\r\n          width={22}\r\n        />\r\n      </ActionButton>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/control-modes.tsx",
    "content": "'use client'\n\nimport { Icon } from '@iconify/react'\nimport { type LevelNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { type LucideIcon, Trash2 } from 'lucide-react'\nimport Image from 'next/image'\nimport { cn } from './../../../lib/utils'\nimport useEditor from './../../../store/use-editor'\nimport { ActionButton } from './action-button'\n\ntype ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete'\n\ntype ControlConfig = {\n  id: ControlId\n  icon?: LucideIcon\n  iconifyIcon?: string\n  imageSrc?: string\n  label: string\n  shortcut?: string\n  color: string\n  activeColor: string\n}\n\n// Fixed set of controls — always visible, never morphs\nconst controls: ControlConfig[] = [\n  {\n    id: 'select',\n    imageSrc: '/icons/select.png',\n    label: 'Select',\n    shortcut: 'V',\n    color: 'hover:bg-blue-500/20 hover:text-blue-400',\n    activeColor: 'bg-blue-500/20 text-blue-400',\n  },\n  {\n    id: 'box-select',\n    iconifyIcon: 'mdi:select-drag',\n    label: 'Box select',\n    color: 'hover:bg-white/5',\n    activeColor: 'bg-white/10 hover:bg-white/10',\n  },\n  {\n    id: 'site-edit',\n    imageSrc: '/icons/site.png',\n    label: 'Edit site',\n    color: 'hover:bg-white/5',\n    activeColor: 'bg-white/10 hover:bg-white/10',\n  },\n  {\n    id: 'build',\n    imageSrc: '/icons/build.png',\n    label: 'Build',\n    shortcut: 'B',\n    color: 'hover:bg-green-500/20 hover:text-green-400',\n    activeColor: 'bg-green-500/20 text-green-400',\n  },\n  {\n    id: 'delete',\n    icon: Trash2,\n    label: 'Delete',\n    shortcut: 'D',\n    color: 'hover:bg-red-500/20 hover:text-red-400',\n    activeColor: 'bg-red-500/20 text-red-400',\n  },\n]\n\nexport function ControlModes() {\n  const mode = useEditor((state) => state.mode)\n  const phase = useEditor((state) => state.phase)\n  const selectionTool = useEditor((state) => state.floorplanSelectionTool)\n  const setMode = useEditor((state) => state.setMode)\n  const setPhase = useEditor((state) => state.setPhase)\n  const setStructureLayer = useEditor((state) => state.setStructureLayer)\n  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)\n  const levelId = useViewer((s) => s.selection.levelId)\n\n  const levelNode = useScene((state) =>\n    levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,\n  )\n\n  const isSiteEditing = phase === 'site'\n  const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0\n  const canEnterSiteEdit = isGroundFloor || isSiteEditing\n\n  const getIsActive = (id: ControlId): boolean => {\n    if (isSiteEditing) return id === 'site-edit'\n    if (id === 'select') return mode === 'select' && selectionTool === 'click'\n    if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'\n    if (id === 'site-edit') return false\n    return mode === id\n  }\n\n  const handleClick = (id: ControlId) => {\n    if (id === 'site-edit') {\n      if (isSiteEditing) {\n        // Toggle off → back to structure/select\n        setPhase('structure')\n        setMode('select')\n        setStructureLayer('elements')\n      } else if (isGroundFloor) {\n        // Enter site editing — set state directly to preserve level selection.\n        // setPhase('site') calls viewer.resetSelection() which clears levelId,\n        // breaking the 2D floorplan (it needs a level to render the SVG).\n        useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })\n      }\n      return\n    }\n\n    // Exit site editing first if needed\n    if (isSiteEditing) {\n      setPhase('structure')\n      setStructureLayer('elements')\n    }\n\n    if (id === 'select') {\n      setMode('select')\n      setSelectionTool('click')\n    } else if (id === 'box-select') {\n      setMode('select')\n      setSelectionTool('marquee')\n    } else {\n      setMode(id)\n    }\n  }\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {controls.map((c) => {\n        const ModeIcon = c.icon\n        const isImageMode = Boolean(c.imageSrc)\n        const isSiteButton = c.id === 'site-edit'\n        const isActive = getIsActive(c.id)\n        const isDisabled = isSiteButton && !canEnterSiteEdit\n\n        return (\n          <ActionButton\n            className={cn(\n              'group text-muted-foreground',\n              isSiteButton\n                ? isActive\n                  ? c.activeColor\n                  : canEnterSiteEdit\n                    ? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'\n                    : 'cursor-not-allowed opacity-35 grayscale'\n                : !(isImageMode || isActive) && c.color,\n              !(isSiteButton || isImageMode) && isActive && c.activeColor,\n              !isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',\n              !isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',\n            )}\n            disabled={isDisabled}\n            key={c.id}\n            label={\n              isSiteButton\n                ? isActive\n                  ? 'Exit site editing'\n                  : canEnterSiteEdit\n                    ? 'Edit site'\n                    : 'Site editing (ground level only)'\n                : c.label\n            }\n            onClick={() => handleClick(c.id)}\n            shortcut={c.shortcut}\n            size=\"icon\"\n            variant=\"ghost\"\n          >\n            {c.imageSrc ? (\n              <Image\n                alt={c.label}\n                className={cn(\n                  'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',\n                  isSiteButton\n                    ? isActive\n                      ? 'opacity-100 grayscale-0'\n                      : ''\n                    : isActive\n                      ? 'opacity-100 grayscale-0'\n                      : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',\n                )}\n                height={28}\n                src={c.imageSrc}\n                width={28}\n              />\n            ) : c.iconifyIcon ? (\n              <Icon color=\"currentColor\" height={18} icon={c.iconifyIcon} width={18} />\n            ) : (\n              ModeIcon && <ModeIcon className=\"h-5 w-5\" />\n            )}\n          </ActionButton>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/furnish-tools.tsx",
    "content": "'use client'\n\nimport NextImage from 'next/image'\nimport { cn } from './../../../lib/utils'\nimport useEditor, { type CatalogCategory } from './../../../store/use-editor'\nimport { ActionButton } from './action-button'\n\nexport type FurnishToolConfig = {\n  id: 'item'\n  iconSrc: string\n  label: string\n  catalogCategory: CatalogCategory\n}\n\n// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)\nexport const furnishTools: FurnishToolConfig[] = [\n  {\n    id: 'item',\n    iconSrc: '/icons/couch.png',\n    label: 'Furniture',\n    catalogCategory: 'furniture',\n  },\n  {\n    id: 'item',\n    iconSrc: '/icons/appliance.png',\n    label: 'Appliance',\n    catalogCategory: 'appliance',\n  },\n  {\n    id: 'item',\n    iconSrc: '/icons/kitchen.png',\n    label: 'Kitchen',\n    catalogCategory: 'kitchen',\n  },\n  {\n    id: 'item',\n    iconSrc: '/icons/bathroom.png',\n    label: 'Bathroom',\n    catalogCategory: 'bathroom',\n  },\n  {\n    id: 'item',\n    iconSrc: '/icons/tree.png',\n    label: 'Outdoor',\n    catalogCategory: 'outdoor',\n  },\n]\n\nexport function FurnishTools() {\n  const mode = useEditor((state) => state.mode)\n  const activeTool = useEditor((state) => state.tool)\n  const setActiveTool = useEditor((state) => state.setTool)\n  const setMode = useEditor((state) => state.setMode)\n  const catalogCategory = useEditor((state) => state.catalogCategory)\n  const setCatalogCategory = useEditor((state) => state.setCatalogCategory)\n\n  const hasActiveTool = furnishTools.some(\n    (tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,\n  )\n\n  return (\n    <div className=\"flex items-center gap-1.5 px-1\">\n      {furnishTools.map((tool, index) => {\n        // For item tools with catalog category, check both tool and category match\n        const isActive =\n          mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory\n\n        return (\n          <ActionButton\n            className={cn(\n              'rounded-lg duration-300',\n              isActive\n                ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'\n                : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',\n            )}\n            key={`${tool.id}-${tool.catalogCategory ?? index}`}\n            label={tool.label}\n            onClick={() => {\n              if (!isActive) {\n                setCatalogCategory(tool.catalogCategory)\n                setActiveTool('item')\n                if (mode !== 'build') {\n                  setMode('build')\n                }\n              }\n            }}\n            size=\"icon\"\n            variant=\"ghost\"\n          >\n            <NextImage\n              alt={tool.label}\n              className=\"size-full object-contain\"\n              height={28}\n              src={tool.iconSrc}\n              width={28}\n            />\n          </ActionButton>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/index.tsx",
    "content": "'use client'\n\nimport { AnimatePresence, motion } from 'motion/react'\nimport { TooltipProvider } from './../../../components/ui/primitives/tooltip'\nimport { useReducedMotion } from './../../../hooks/use-reduced-motion'\nimport { cn } from './../../../lib/utils'\nimport useEditor from './../../../store/use-editor'\nimport { ItemCatalog } from '../item-catalog/item-catalog'\nimport { CameraActions } from './camera-actions'\nimport { ControlModes } from './control-modes'\nimport { FurnishTools } from './furnish-tools'\nimport { StructureTools } from './structure-tools'\nimport { ViewToggles } from './view-toggles'\n\nexport function ActionMenu({ className }: { className?: string }) {\n  const phase = useEditor((state) => state.phase)\n  const mode = useEditor((state) => state.mode)\n  const tool = useEditor((state) => state.tool)\n  const catalogCategory = useEditor((state) => state.catalogCategory)\n  const reducedMotion = useReducedMotion()\n  const transition = reducedMotion\n    ? { duration: 0 }\n    : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }\n\n  return (\n    <TooltipProvider>\n      <motion.div\n        className={cn(\n          'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',\n          'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',\n          'transition-colors duration-200 ease-out',\n          className,\n        )}\n        layout\n        transition={transition}\n      >\n        {/* Item Catalog Row - Only show when in build mode with item tool */}\n        <AnimatePresence>\n          {mode === 'build' && tool === 'item' && catalogCategory && (\n            <motion.div\n              animate={{\n                opacity: 1,\n                maxHeight: 160,\n                paddingTop: 8,\n                paddingBottom: 8,\n                borderBottomWidth: 1,\n              }}\n              className={cn('overflow-hidden border-border border-b px-2 py-2')}\n              exit={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              initial={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              transition={transition}\n            >\n              <ItemCatalog category={catalogCategory} key={catalogCategory} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <AnimatePresence>\n          {phase === 'furnish' && mode === 'build' && (\n            <motion.div\n              animate={{\n                opacity: 1,\n                maxHeight: 80,\n                paddingTop: 8,\n                paddingBottom: 8,\n                borderBottomWidth: 1,\n              }}\n              className={cn(\n                'overflow-hidden border-border',\n                'max-h-20 border-b px-2 py-2 opacity-100',\n              )}\n              exit={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              initial={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              transition={transition}\n            >\n              <div className=\"mx-auto w-max\">\n                <FurnishTools />\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* Structure Tools Row - Animated */}\n        <AnimatePresence>\n          {phase === 'structure' && mode === 'build' && (\n            <motion.div\n              animate={{\n                opacity: 1,\n                maxHeight: 80,\n                paddingTop: 8,\n                paddingBottom: 8,\n                borderBottomWidth: 1,\n              }}\n              className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}\n              exit={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              initial={{\n                opacity: 0,\n                maxHeight: 0,\n                paddingTop: 0,\n                paddingBottom: 0,\n                borderBottomWidth: 0,\n              }}\n              transition={transition}\n            >\n              <div className=\"w-max\">\n                <StructureTools />\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        {/* Control Mode Row - Always visible, centered */}\n        <div className=\"flex items-center justify-center gap-1 px-2 py-1.5\">\n          <ControlModes />\n          <div className=\"mx-1 h-5 w-px bg-border\" />\n          <ViewToggles />\n          <div className=\"mx-1 h-5 w-px bg-border\" />\n          <CameraActions />\n        </div>\n      </motion.div>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/structure-tools.tsx",
    "content": "'use client'\n\nimport NextImage from 'next/image'\nimport { useContextualTools } from '../../../hooks/use-contextual-tools'\n\nimport { cn } from '../../../lib/utils'\nimport useEditor, {\n  type CatalogCategory,\n  type StructureTool,\n  type Tool,\n} from '../../../store/use-editor'\nimport { ActionButton } from './action-button'\n\nexport type ToolConfig = {\n  id: StructureTool\n  iconSrc: string\n  label: string\n  catalogCategory?: CatalogCategory\n}\n\nexport const tools: ToolConfig[] = [\n  { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },\n  // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },\n  // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },\n  { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },\n  { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },\n  { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },\n  { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },\n  { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },\n  { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },\n  { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },\n]\n\nexport function StructureTools() {\n  const activeTool = useEditor((state) => state.tool)\n  const catalogCategory = useEditor((state) => state.catalogCategory)\n  const structureLayer = useEditor((state) => state.structureLayer)\n  const setTool = useEditor((state) => state.setTool)\n  const setCatalogCategory = useEditor((state) => state.setCatalogCategory)\n\n  const contextualTools = useContextualTools()\n\n  // Filter tools based on structureLayer\n  const visibleTools =\n    structureLayer === 'zones'\n      ? tools.filter((t) => t.id === 'zone')\n      : tools.filter((t) => t.id !== 'zone')\n\n  const hasActiveTool = visibleTools.some(\n    (t) =>\n      activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),\n  )\n\n  return (\n    <div className=\"flex items-center gap-1.5 px-1\">\n      {visibleTools.map((tool, index) => {\n        // For item tools with catalog category, check both tool and category match\n        const isActive =\n          activeTool === tool.id &&\n          (tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)\n\n        const isContextual = contextualTools.includes(tool.id)\n\n        return (\n          <ActionButton\n            className={cn(\n              'rounded-lg duration-300',\n              isActive\n                ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'\n                : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',\n            )}\n            key={`${tool.id}-${tool.catalogCategory ?? index}`}\n            label={tool.label}\n            onClick={() => {\n              if (!isActive) {\n                setTool(tool.id)\n                setCatalogCategory(tool.catalogCategory ?? null)\n\n                // Automatically switch to build mode if we select a tool\n                if (useEditor.getState().mode !== 'build') {\n                  useEditor.getState().setMode('build')\n                }\n              }\n            }}\n            size=\"icon\"\n            variant=\"ghost\"\n          >\n            <NextImage\n              alt={tool.label}\n              className=\"size-full object-contain\"\n              height={28}\n              src={tool.iconSrc}\n              width={28}\n            />\n          </ActionButton>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/action-menu/view-toggles.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNodeId,\n  type GuideNode,\n  type LevelNode,\n  type ScanNode,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { ChevronDown } from 'lucide-react'\nimport { useCallback, useState } from 'react'\nimport { useShallow } from 'zustand/react/shallow'\nimport { cn } from '../../../lib/utils'\nimport { SliderControl } from '../controls/slider-control'\nimport { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'\nimport { ActionButton } from './action-button'\n\n// ── Helper: get guide images for the current level ──────────────────────────\n\nfunction useLevelGuides(): GuideNode[] {\n  const levelId = useViewer((s) => s.selection.levelId)\n  return useScene(\n    useShallow((state) => {\n      if (!levelId) return [] as GuideNode[]\n      const level = state.nodes[levelId]\n      if (!level || level.type !== 'level') return [] as GuideNode[]\n      return (level as LevelNode).children\n        .map((id) => state.nodes[id])\n        .filter((node): node is GuideNode => node?.type === 'guide')\n    }),\n  )\n}\n\n// ── Helper: get scans for the current level ─────────────────────────────────\n\nfunction useLevelScans(): ScanNode[] {\n  const levelId = useViewer((s) => s.selection.levelId)\n  return useScene(\n    useShallow((state) => {\n      if (!levelId) return [] as ScanNode[]\n      const level = state.nodes[levelId]\n      if (!level || level.type !== 'level') return [] as ScanNode[]\n      return (level as LevelNode).children\n        .map((id) => state.nodes[id])\n        .filter((node): node is ScanNode => node?.type === 'scan')\n    }),\n  )\n}\n\n// ── Guides toggle + dropdown ────────────────────────────────────────────────\n\nfunction GuidesControl() {\n  const showGuides = useViewer((state) => state.showGuides)\n  const setShowGuides = useViewer((state) => state.setShowGuides)\n  const updateNode = useScene((state) => state.updateNode)\n  const [isOpen, setIsOpen] = useState(false)\n\n  const guides = useLevelGuides()\n  const hasGuides = guides.length > 0\n\n  const handleOpacityChange = useCallback(\n    (guideId: GuideNode['id'], opacity: number) => {\n      updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })\n    },\n    [updateNode],\n  )\n\n  return (\n    <Popover onOpenChange={setIsOpen} open={isOpen}>\n      <div className=\"flex items-center\">\n        {/* Toggle button */}\n        <ActionButton\n          className={cn(\n            'rounded-r-none p-0',\n            showGuides\n              ? 'bg-white/10'\n              : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',\n          )}\n          label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}\n          onClick={() => setShowGuides(!showGuides)}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <img\n            alt=\"Guides\"\n            className=\"h-[28px] w-[28px] object-contain\"\n            src=\"/icons/floorplan.png\"\n          />\n        </ActionButton>\n\n        {/* Dropdown chevron */}\n        <PopoverTrigger asChild>\n          <button\n            aria-expanded={isOpen}\n            aria-label=\"Guide image settings\"\n            className={cn(\n              'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',\n              isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',\n            )}\n            type=\"button\"\n          >\n            <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />\n          </button>\n        </PopoverTrigger>\n      </div>\n\n      <PopoverContent\n        align=\"center\"\n        className=\"w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl\"\n        side=\"top\"\n        sideOffset={14}\n      >\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80\">\n              <img alt=\"\" className=\"h-4 w-4 object-contain\" src=\"/icons/floorplan.png\" />\n            </span>\n            <div className=\"min-w-0\">\n              <p className=\"font-medium text-foreground text-sm\">Guide images</p>\n              {hasGuides && (\n                <p className=\"text-muted-foreground text-xs\">\n                  {guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level\n                </p>\n              )}\n            </div>\n          </div>\n\n          {hasGuides ? (\n            <div className=\"max-h-56 space-y-2 overflow-y-auto pr-1\">\n              {guides.map((guide, index) => (\n                <div\n                  className=\"space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5\"\n                  key={guide.id}\n                >\n                  <div className=\"flex min-w-0 items-center gap-2\">\n                    <img\n                      alt=\"\"\n                      className=\"h-3.5 w-3.5 shrink-0 object-contain opacity-70\"\n                      src=\"/icons/floorplan.png\"\n                    />\n                    <p className=\"truncate font-medium text-foreground text-sm\">\n                      {guide.name || `Guide image ${index + 1}`}\n                    </p>\n                  </div>\n                  <SliderControl\n                    label=\"Opacity\"\n                    max={100}\n                    min={0}\n                    onChange={(value) => handleOpacityChange(guide.id, value)}\n                    precision={0}\n                    step={1}\n                    unit=\"%\"\n                    value={guide.opacity}\n                  />\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div className=\"rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm\">\n              No guide images on this level yet.\n            </div>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ── Scans toggle + dropdown ─────────────────────────────────────────────────\n\nfunction ScansControl() {\n  const showScans = useViewer((state) => state.showScans)\n  const setShowScans = useViewer((state) => state.setShowScans)\n  const updateNode = useScene((state) => state.updateNode)\n  const [isOpen, setIsOpen] = useState(false)\n\n  const scans = useLevelScans()\n  const hasScans = scans.length > 0\n\n  const handleOpacityChange = useCallback(\n    (scanId: ScanNode['id'], opacity: number) => {\n      updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })\n    },\n    [updateNode],\n  )\n\n  return (\n    <Popover onOpenChange={setIsOpen} open={isOpen}>\n      <div className=\"flex items-center\">\n        {/* Toggle button */}\n        <ActionButton\n          className={cn(\n            'rounded-r-none p-0',\n            showScans\n              ? 'bg-white/10'\n              : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',\n          )}\n          label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}\n          onClick={() => setShowScans(!showScans)}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <img alt=\"Scans\" className=\"h-[28px] w-[28px] object-contain\" src=\"/icons/mesh.png\" />\n        </ActionButton>\n\n        {/* Dropdown chevron */}\n        <PopoverTrigger asChild>\n          <button\n            aria-expanded={isOpen}\n            aria-label=\"Scan settings\"\n            className={cn(\n              'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',\n              isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',\n            )}\n            type=\"button\"\n          >\n            <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />\n          </button>\n        </PopoverTrigger>\n      </div>\n\n      <PopoverContent\n        align=\"center\"\n        className=\"w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl\"\n        side=\"top\"\n        sideOffset={14}\n      >\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80\">\n              <img alt=\"\" className=\"h-4 w-4 object-contain\" src=\"/icons/mesh.png\" />\n            </span>\n            <div className=\"min-w-0\">\n              <p className=\"font-medium text-foreground text-sm\">Scans</p>\n              {hasScans && (\n                <p className=\"text-muted-foreground text-xs\">\n                  {scans.length} scan{scans.length !== 1 ? 's' : ''} on this level\n                </p>\n              )}\n            </div>\n          </div>\n\n          {hasScans ? (\n            <div className=\"max-h-56 space-y-2 overflow-y-auto pr-1\">\n              {scans.map((scan, index) => (\n                <div\n                  className=\"space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5\"\n                  key={scan.id}\n                >\n                  <div className=\"flex min-w-0 items-center gap-2\">\n                    <img\n                      alt=\"\"\n                      className=\"h-3.5 w-3.5 shrink-0 object-contain opacity-70\"\n                      src=\"/icons/mesh.png\"\n                    />\n                    <p className=\"truncate font-medium text-foreground text-sm\">\n                      {scan.name || `Scan ${index + 1}`}\n                    </p>\n                  </div>\n                  <SliderControl\n                    label=\"Opacity\"\n                    max={100}\n                    min={0}\n                    onChange={(value) => handleOpacityChange(scan.id, value)}\n                    precision={0}\n                    step={1}\n                    unit=\"%\"\n                    value={scan.opacity}\n                  />\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div className=\"rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm\">\n              No scans on this level yet.\n            </div>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ── Main ViewToggles ────────────────────────────────────────────────────────\n\nexport function ViewToggles() {\n  return (\n    <div className=\"flex items-center gap-1\">\n      {/* Scans (toggle + dropdown) */}\n      <ScansControl />\n\n      {/* Guides (toggle + dropdown) */}\n      <GuidesControl />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/command-palette/editor-commands.tsx",
    "content": "'use client'\n\nimport type { AnyNodeId } from '@pascal-app/core'\nimport { LevelNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport {\n  AppWindow,\n  ArrowRight,\n  Box,\n  Building2,\n  Camera,\n  Copy,\n  DoorOpen,\n  Eye,\n  EyeOff,\n  FileJson,\n  Grid3X3,\n  Hexagon,\n  Layers,\n  Map,\n  Maximize2,\n  Minimize2,\n  Moon,\n  MousePointer2,\n  Package,\n  PencilLine,\n  Plus,\n  Redo2,\n  Square,\n  SquareStack,\n  Sun,\n  Trash2,\n  Undo2,\n  Video,\n} from 'lucide-react'\nimport { useEffect } from 'react'\nimport { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'\nimport { useCommandRegistry } from '../../../store/use-command-registry'\nimport type { StructureTool } from '../../../store/use-editor'\nimport useEditor from '../../../store/use-editor'\nimport { useCommandPalette } from './index'\n\nexport function EditorCommands() {\n  const register = useCommandRegistry((s) => s.register)\n  const { navigateTo, setInputValue, setOpen } = useCommandPalette()\n\n  const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =\n    useEditor()\n\n  const exportScene = useViewer((s) => s.exportScene)\n\n  // Re-register when exportScene availability changes (it's a conditional action)\n  useEffect(() => {\n    const run = (fn: () => void) => {\n      fn()\n      setOpen(false)\n    }\n\n    const activateTool = (tool: StructureTool) => {\n      run(() => {\n        setPhase('structure')\n        setMode('build')\n        if (tool === 'zone') setStructureLayer('zones')\n        setTool(tool)\n      })\n    }\n\n    return register([\n      // ── Scene ────────────────────────────────────────────────────────────\n      {\n        id: 'editor.tool.wall',\n        label: 'Wall Tool',\n        group: 'Scene',\n        icon: <Square className=\"h-4 w-4\" />,\n        keywords: ['draw', 'build', 'structure'],\n        execute: () => activateTool('wall'),\n      },\n      {\n        id: 'editor.tool.slab',\n        label: 'Slab Tool',\n        group: 'Scene',\n        icon: <Layers className=\"h-4 w-4\" />,\n        keywords: ['floor', 'build'],\n        execute: () => activateTool('slab'),\n      },\n      {\n        id: 'editor.tool.ceiling',\n        label: 'Ceiling Tool',\n        group: 'Scene',\n        icon: <Grid3X3 className=\"h-4 w-4\" />,\n        keywords: ['top', 'build'],\n        execute: () => activateTool('ceiling'),\n      },\n      {\n        id: 'editor.tool.door',\n        label: 'Door Tool',\n        group: 'Scene',\n        icon: <DoorOpen className=\"h-4 w-4\" />,\n        keywords: ['opening', 'entrance'],\n        execute: () => activateTool('door'),\n      },\n      {\n        id: 'editor.tool.window',\n        label: 'Window Tool',\n        group: 'Scene',\n        icon: <AppWindow className=\"h-4 w-4\" />,\n        keywords: ['opening', 'glass'],\n        execute: () => activateTool('window'),\n      },\n      {\n        id: 'editor.tool.item',\n        label: 'Item Tool',\n        group: 'Scene',\n        icon: <Package className=\"h-4 w-4\" />,\n        keywords: ['furniture', 'object', 'asset', 'furnish'],\n        execute: () => activateTool('item'),\n      },\n      {\n        id: 'editor.tool.stair',\n        label: 'Stair Tool',\n        group: 'Scene',\n        icon: <ArrowRight className=\"h-4 w-4\" />,\n        keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'],\n        execute: () => activateTool('stair'),\n      },\n      {\n        id: 'editor.tool.zone',\n        label: 'Zone Tool',\n        group: 'Scene',\n        icon: <Hexagon className=\"h-4 w-4\" />,\n        keywords: ['area', 'room', 'space'],\n        execute: () => activateTool('zone'),\n      },\n      {\n        id: 'editor.delete-selection',\n        label: 'Delete Selection',\n        group: 'Scene',\n        icon: <Trash2 className=\"h-4 w-4\" />,\n        keywords: ['remove', 'erase'],\n        shortcut: ['⌫'],\n        when: () => useViewer.getState().selection.selectedIds.length > 0,\n        execute: () =>\n          run(() => {\n            const { selectedIds } = useViewer.getState().selection\n            useScene.getState().deleteNodes(selectedIds as any[])\n          }),\n      },\n\n      // ── Levels ───────────────────────────────────────────────────────────\n      {\n        id: 'editor.level.goto',\n        label: 'Go to Level',\n        group: 'Levels',\n        icon: <ArrowRight className=\"h-4 w-4\" />,\n        keywords: ['level', 'floor', 'go', 'navigate', 'switch', 'select'],\n        navigate: true,\n        when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'level'),\n        execute: () => navigateTo('goto-level'),\n      },\n      {\n        id: 'editor.level.add',\n        label: 'Add Level',\n        group: 'Levels',\n        icon: <Plus className=\"h-4 w-4\" />,\n        keywords: ['level', 'floor', 'add', 'create', 'new'],\n        execute: () =>\n          run(() => {\n            const { nodes } = useScene.getState()\n            const building = Object.values(nodes).find((n) => n.type === 'building')\n            if (!building) return\n            const newLevel = LevelNode.parse({\n              level: building.children.length,\n              children: [],\n              parentId: building.id,\n            })\n            useScene.getState().createNode(newLevel, building.id)\n            useViewer.getState().setSelection({ levelId: newLevel.id })\n          }),\n      },\n      {\n        id: 'editor.level.rename',\n        label: 'Rename Level',\n        group: 'Levels',\n        icon: <PencilLine className=\"h-4 w-4\" />,\n        keywords: ['level', 'floor', 'rename', 'name'],\n        navigate: true,\n        when: () => !!useViewer.getState().selection.levelId,\n        execute: () => {\n          const activeLevelId = useViewer.getState().selection.levelId\n          if (!activeLevelId) return\n          const level = useScene.getState().nodes[activeLevelId as AnyNodeId] as LevelNode\n          setInputValue(level?.name ?? '')\n          navigateTo('rename-level')\n        },\n      },\n      {\n        id: 'editor.level.delete',\n        label: 'Delete Level',\n        group: 'Levels',\n        icon: <Trash2 className=\"h-4 w-4\" />,\n        keywords: ['level', 'floor', 'delete', 'remove'],\n        when: () => {\n          const levelId = useViewer.getState().selection.levelId\n          if (!levelId) return false\n          const node = useScene.getState().nodes[levelId as AnyNodeId] as LevelNode\n          return node?.type === 'level' && node.level !== 0\n        },\n        execute: () =>\n          run(() => {\n            const activeLevelId = useViewer.getState().selection.levelId\n            if (!activeLevelId) return\n            deleteLevelWithFallbackSelection(activeLevelId as AnyNodeId)\n          }),\n      },\n\n      // ── Viewer Controls ──────────────────────────────────────────────────\n      {\n        id: 'editor.viewer.wall-mode',\n        label: 'Wall Mode',\n        group: 'Viewer Controls',\n        icon: <Layers className=\"h-4 w-4\" />,\n        keywords: ['wall', 'cutaway', 'up', 'down', 'view'],\n        badge: () => {\n          const mode = useViewer.getState().wallMode\n          return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]\n        },\n        navigate: true,\n        execute: () => navigateTo('wall-mode'),\n      },\n      {\n        id: 'editor.viewer.level-mode',\n        label: 'Level Mode',\n        group: 'Viewer Controls',\n        icon: <SquareStack className=\"h-4 w-4\" />,\n        keywords: ['level', 'floor', 'exploded', 'stacked', 'solo'],\n        badge: () => {\n          const mode = useViewer.getState().levelMode\n          return { manual: 'Manual', stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo' }[mode]\n        },\n        navigate: true,\n        execute: () => navigateTo('level-mode'),\n      },\n      {\n        id: 'editor.viewer.camera-mode',\n        label: () => {\n          const mode = useViewer.getState().cameraMode\n          return `Camera: Switch to ${mode === 'perspective' ? 'Orthographic' : 'Perspective'}`\n        },\n        group: 'Viewer Controls',\n        icon: <Video className=\"h-4 w-4\" />,\n        keywords: ['camera', 'ortho', 'perspective', '2d', '3d', 'view'],\n        execute: () =>\n          run(() => {\n            const { cameraMode, setCameraMode } = useViewer.getState()\n            setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')\n          }),\n      },\n      {\n        id: 'editor.viewer.theme',\n        label: () => {\n          const theme = useViewer.getState().theme\n          return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'\n        },\n        group: 'Viewer Controls',\n        icon: <Sun className=\"h-4 w-4\" />, // icon is static; label conveys the action\n        keywords: ['theme', 'dark', 'light', 'appearance', 'color'],\n        execute: () =>\n          run(() => {\n            const { theme, setTheme } = useViewer.getState()\n            setTheme(theme === 'dark' ? 'light' : 'dark')\n          }),\n      },\n      {\n        id: 'editor.viewer.camera-snapshot',\n        label: 'Camera Snapshot',\n        group: 'Viewer Controls',\n        icon: <Camera className=\"h-4 w-4\" />,\n        keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],\n        navigate: true,\n        execute: () => navigateTo('camera-view'),\n      },\n\n      // ── View ─────────────────────────────────────────────────────────────\n      {\n        id: 'editor.view.preview',\n        label: () => (isPreviewMode ? 'Exit Preview' : 'Enter Preview'),\n        group: 'View',\n        icon: isPreviewMode ? <EyeOff className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />,\n        keywords: ['preview', 'view', 'read-only', 'present'],\n        execute: () => run(() => setPreviewMode(!isPreviewMode)),\n      },\n      {\n        id: 'editor.view.fullscreen',\n        label: 'Toggle Fullscreen',\n        group: 'View',\n        icon: <Maximize2 className=\"h-4 w-4\" />,\n        keywords: ['fullscreen', 'maximize', 'expand', 'window'],\n        execute: () =>\n          run(() => {\n            if (document.fullscreenElement) document.exitFullscreen()\n            else document.documentElement.requestFullscreen()\n          }),\n      },\n\n      // ── History ──────────────────────────────────────────────────────────\n      {\n        id: 'editor.history.undo',\n        label: 'Undo',\n        group: 'History',\n        icon: <Undo2 className=\"h-4 w-4\" />,\n        keywords: ['undo', 'revert', 'back'],\n        execute: () => run(() => useScene.temporal.getState().undo()),\n      },\n      {\n        id: 'editor.history.redo',\n        label: 'Redo',\n        group: 'History',\n        icon: <Redo2 className=\"h-4 w-4\" />,\n        keywords: ['redo', 'forward', 'repeat'],\n        execute: () => run(() => useScene.temporal.getState().redo()),\n      },\n\n      // ── Export & Share ───────────────────────────────────────────────────\n      {\n        id: 'editor.export.json',\n        label: 'Export Scene (JSON)',\n        group: 'Export & Share',\n        icon: <FileJson className=\"h-4 w-4\" />,\n        keywords: ['export', 'download', 'json', 'save', 'data'],\n        execute: () =>\n          run(() => {\n            const { nodes, rootNodeIds } = useScene.getState()\n            const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {\n              type: 'application/json',\n            })\n            const url = URL.createObjectURL(blob)\n            Object.assign(document.createElement('a'), {\n              href: url,\n              download: `scene_${new Date().toISOString().split('T')[0]}.json`,\n            }).click()\n            URL.revokeObjectURL(url)\n          }),\n      },\n      ...(exportScene\n        ? [\n            {\n              id: 'editor.export.glb',\n              label: 'Export 3D Model (GLB)',\n              group: 'Export & Share',\n              icon: <Box className=\"h-4 w-4\" />,\n              keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],\n              execute: () => run(() => exportScene()),\n            } as const,\n          ]\n        : []),\n      {\n        id: 'editor.export.share-link',\n        label: 'Copy Share Link',\n        group: 'Export & Share',\n        icon: <Copy className=\"h-4 w-4\" />,\n        keywords: ['share', 'copy', 'url', 'link'],\n        execute: () => run(() => navigator.clipboard.writeText(window.location.href)),\n      },\n      {\n        id: 'editor.export.screenshot',\n        label: 'Take Screenshot',\n        group: 'Export & Share',\n        icon: <Camera className=\"h-4 w-4\" />,\n        keywords: ['screenshot', 'capture', 'image', 'photo', 'png'],\n        execute: () =>\n          run(() => {\n            const canvas = document.querySelector('canvas')\n            if (!canvas) return\n            Object.assign(document.createElement('a'), {\n              href: canvas.toDataURL('image/png'),\n              download: `screenshot_${new Date().toISOString().split('T')[0]}.png`,\n            }).click()\n          }),\n      },\n    ])\n  }, [\n    register,\n    navigateTo,\n    setInputValue,\n    setOpen,\n    setPhase,\n    setMode,\n    setTool,\n    setStructureLayer,\n    isPreviewMode,\n    setPreviewMode,\n    exportScene,\n  ])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/command-palette/index.tsx",
    "content": "'use client'\n\nimport type { AnyNodeId, LevelNode } from '@pascal-app/core'\nimport { useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Command } from 'cmdk'\nimport { ChevronRight, Search } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport { create } from 'zustand'\nimport { useShallow } from 'zustand/shallow'\nimport { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'\nimport { useCommandRegistry } from '../../../store/use-command-registry'\nimport { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'\n\n// ---------------------------------------------------------------------------\n// Open + navigation state store\n// ---------------------------------------------------------------------------\ninterface CommandPaletteStore {\n  open: boolean\n  setOpen: (open: boolean) => void\n  /** Current rendering mode. 'command' = normal palette; anything else = registered mode view. */\n  mode: string\n  setMode: (mode: string) => void\n  pages: string[]\n  inputValue: string\n  setInputValue: (value: string) => void\n  navigateTo: (page: string) => void\n  goBack: () => void\n  cameraScope: { nodeId: string; label: string } | null\n  setCameraScope: (scope: { nodeId: string; label: string } | null) => void\n}\n\nexport const useCommandPalette = create<CommandPaletteStore>((set, get) => ({\n  open: false,\n  setOpen: (open) => {\n    set({ open })\n    if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })\n  },\n  mode: 'command',\n  setMode: (mode) => set({ mode }),\n  pages: [],\n  inputValue: '',\n  setInputValue: (value) => set({ inputValue: value }),\n  navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),\n  goBack: () => {\n    const { pages } = get()\n    if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })\n    set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))\n  },\n  cameraScope: null,\n  setCameraScope: (scope) => set({ cameraScope: scope }),\n}))\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\nfunction resolve(value: string | (() => string)): string {\n  return typeof value === 'function' ? value() : value\n}\n\nfunction Shortcut({ keys }: { keys: string[] }) {\n  return (\n    <span className=\"ml-auto flex shrink-0 items-center gap-0.5\">\n      {keys.map((k) => (\n        <kbd\n          className=\"flex min-w-4.5 items-center justify-center rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground leading-none\"\n          key={k}\n        >\n          {k}\n        </kbd>\n      ))}\n    </span>\n  )\n}\n\nfunction Item({\n  icon,\n  label,\n  onSelect,\n  shortcut,\n  disabled = false,\n  keywords = [],\n  badge,\n  navigate = false,\n}: {\n  icon: React.ReactNode\n  label: string | (() => string)\n  onSelect: () => void\n  shortcut?: string[]\n  disabled?: boolean\n  keywords?: string[]\n  badge?: string | (() => string)\n  navigate?: boolean\n}) {\n  const resolvedLabel = resolve(label)\n  const resolvedBadge = badge ? resolve(badge) : undefined\n\n  return (\n    <Command.Item\n      className=\"flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40\"\n      disabled={disabled}\n      keywords={keywords}\n      onSelect={onSelect}\n      value={resolvedLabel}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n        {icon}\n      </span>\n      <span className=\"flex-1 truncate\">{resolvedLabel}</span>\n      {resolvedBadge && (\n        <span className=\"rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground\">\n          {resolvedBadge}\n        </span>\n      )}\n      {shortcut && <Shortcut keys={shortcut} />}\n      {(resolvedBadge || navigate) && (\n        <ChevronRight className=\"h-3 w-3 shrink-0 text-muted-foreground\" />\n      )}\n    </Command.Item>\n  )\n}\n\nfunction OptionItem({\n  label,\n  isActive = false,\n  onSelect,\n  icon,\n  disabled = false,\n}: {\n  label: string\n  isActive?: boolean\n  onSelect: () => void\n  icon?: React.ReactNode\n  disabled?: boolean\n}) {\n  return (\n    <Command.Item\n      className=\"flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40\"\n      disabled={disabled}\n      onSelect={onSelect}\n      value={label}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n        {isActive ? <div className=\"h-1.5 w-1.5 rounded-full bg-primary\" /> : icon}\n      </span>\n      <span className=\"flex-1 truncate\">{label}</span>\n    </Command.Item>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Sub-page label map\n// ---------------------------------------------------------------------------\nconst PAGE_LABEL: Record<string, string> = {\n  'wall-mode': 'Wall Mode',\n  'level-mode': 'Level Mode',\n  'rename-level': 'Rename Level',\n  'goto-level': 'Go to Level',\n  'camera-view': 'Camera Snapshot',\n  'camera-scope': '',\n}\n\n// ---------------------------------------------------------------------------\n// Main component\n// ---------------------------------------------------------------------------\nexport function CommandPalette() {\n  const {\n    open,\n    setOpen,\n    mode,\n    setMode,\n    pages,\n    inputValue,\n    setInputValue,\n    navigateTo,\n    goBack,\n    cameraScope,\n    setCameraScope,\n  } = useCommandPalette()\n\n  const [meta, setMeta] = useState('⌘')\n  const [isFullscreen, setIsFullscreen] = useState(false)\n\n  const page = pages[pages.length - 1]\n\n  const actions = useCommandRegistry((s) => s.actions)\n  const views = usePaletteViewRegistry((s) => s.views)\n\n  const activeLevelId = useViewer((s) => s.selection.levelId)\n  const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))\n\n  const wallMode = useViewer((s) => s.wallMode)\n  const setWallMode = useViewer((s) => s.setWallMode)\n  const levelMode = useViewer((s) => s.levelMode)\n  const setLevelMode = useViewer((s) => s.setLevelMode)\n\n  const allLevels = useScene(\n    useShallow((s) =>\n      (Object.values(s.nodes).filter((n) => n.type === 'level') as LevelNode[]).sort(\n        (a, b) => a.level - b.level,\n      ),\n    ),\n  )\n\n  const cameraScopeNode = useScene((s) =>\n    cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,\n  )\n  const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera\n\n  // Platform detection\n  useEffect(() => {\n    setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')\n  }, [])\n\n  // Fullscreen tracking\n  useEffect(() => {\n    const handler = () => setIsFullscreen(!!document.fullscreenElement)\n    document.addEventListener('fullscreenchange', handler)\n    return () => document.removeEventListener('fullscreenchange', handler)\n  }, [])\n\n  // Cmd/Ctrl+K global shortcut\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n        e.preventDefault()\n        setOpen(true)\n      }\n    }\n    window.addEventListener('keydown', handler)\n    return () => window.removeEventListener('keydown', handler)\n  }, [setOpen])\n\n  const run = (fn: () => void) => {\n    fn()\n    setOpen(false)\n  }\n\n  const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {\n    cutaway: 'Cutaway',\n    up: 'Up',\n    down: 'Down',\n  }\n  const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {\n    manual: 'Manual',\n    stacked: 'Stacked',\n    exploded: 'Exploded',\n    solo: 'Solo',\n  }\n\n  // Camera snapshot helpers (used by sub-pages registered via EditorCommands)\n  const confirmRename = () => {\n    if (!(activeLevelId && inputValue.trim())) return\n    run(() => {\n      useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any)\n    })\n  }\n\n  const takeSnapshot = () => {\n    if (!cameraScope) return\n    import('@pascal-app/core').then(({ emitter }) => {\n      run(() =>\n        emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),\n      )\n    })\n  }\n\n  const viewSnapshot = () => {\n    if (!(cameraScope && hasScopeSnapshot)) return\n    import('@pascal-app/core').then(({ emitter }) => {\n      run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))\n    })\n  }\n\n  const clearSnapshot = () => {\n    if (!(cameraScope && hasScopeSnapshot)) return\n    run(() => {\n      useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)\n    })\n  }\n\n  // ---------------------------------------------------------------------------\n  // Group registered actions by group (preserving insertion order)\n  // ---------------------------------------------------------------------------\n  const grouped = actions.reduce<Map<string, typeof actions>>((acc, action) => {\n    const list = acc.get(action.group) ?? []\n    list.push(action)\n    acc.set(action.group, list)\n    return acc\n  }, new Map())\n\n  const onClose = () => setOpen(false)\n  const onBack = () => {\n    if (mode !== 'command') {\n      setMode('command')\n    } else {\n      goBack()\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Render\n  // ---------------------------------------------------------------------------\n\n  // Mode view: replaces the entire cmdk shell\n  const modeView = mode !== 'command' ? views.get(mode) : undefined\n\n  return (\n    <Dialog onOpenChange={setOpen} open={open}>\n      <DialogContent className=\"max-w-lg gap-0 overflow-hidden p-0\" showCloseButton={false}>\n        <DialogTitle className=\"sr-only\">Command Palette</DialogTitle>\n\n        {modeView && <modeView.Component onBack={onBack} onClose={onClose} />}\n\n        {!modeView && (\n          <Command\n            className=\"**:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pt-3 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:text-[10px] **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider\"\n            onKeyDown={(e) => {\n              if (e.key === 'Backspace' && !inputValue && pages.length > 0) {\n                e.preventDefault()\n                goBack()\n              }\n            }}\n            shouldFilter={page !== 'rename-level'}\n          >\n            {/* Search bar */}\n            <div className=\"flex items-center border-border/50 border-b px-3\">\n              <Search className=\"mr-2 h-4 w-4 shrink-0 text-muted-foreground\" />\n              {page && (\n                <button\n                  className=\"mr-2 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted/70\"\n                  onClick={goBack}\n                  type=\"button\"\n                >\n                  {page === 'camera-scope'\n                    ? (cameraScope?.label ?? 'Snapshot')\n                    : (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}\n                </button>\n              )}\n              <Command.Input\n                autoFocus\n                className=\"flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n                onValueChange={setInputValue}\n                placeholder={\n                  page === 'rename-level'\n                    ? 'Type a new name…'\n                    : page\n                      ? 'Filter options…'\n                      : 'Search actions…'\n                }\n                value={inputValue}\n              />\n            </div>\n\n            <Command.List className=\"max-h-100 overflow-y-auto p-1.5\">\n              <Command.Empty className=\"py-8 text-center text-muted-foreground text-sm\">\n                No commands found.\n              </Command.Empty>\n\n              {/* ── Registered page view (e.g. 'ai') ─────────────────────── */}\n              {page &&\n                views.get(page)?.type === 'page' &&\n                (() => {\n                  const pageView = views.get(page)\n                  return pageView ? <pageView.Component onBack={onBack} onClose={onClose} /> : null\n                })()}\n\n              {/* ── Root view: render from registry ───────────────────────── */}\n              {!page &&\n                Array.from(grouped.entries()).map(([group, groupActions]) => (\n                  <Command.Group heading={group} key={group}>\n                    {groupActions.map((action) => (\n                      <Item\n                        badge={action.badge}\n                        disabled={action.when ? !action.when() : false}\n                        icon={action.icon}\n                        key={action.id}\n                        keywords={action.keywords}\n                        label={action.label}\n                        navigate={action.navigate}\n                        onSelect={() => action.execute()}\n                        shortcut={action.shortcut}\n                      />\n                    ))}\n                  </Command.Group>\n                ))}\n\n              {/* ── Wall Mode sub-page ────────────────────────────────────── */}\n              {page === 'wall-mode' && (\n                <Command.Group heading=\"Wall Mode\">\n                  {(['cutaway', 'up', 'down'] as const).map((mode) => (\n                    <OptionItem\n                      isActive={wallMode === mode}\n                      key={mode}\n                      label={wallModeLabel[mode]}\n                      onSelect={() => run(() => setWallMode(mode))}\n                    />\n                  ))}\n                </Command.Group>\n              )}\n\n              {/* ── Level Mode sub-page ───────────────────────────────────── */}\n              {page === 'level-mode' && (\n                <Command.Group heading=\"Level Mode\">\n                  {(['stacked', 'exploded', 'solo'] as const).map((mode) => (\n                    <OptionItem\n                      isActive={levelMode === mode}\n                      key={mode}\n                      label={levelModeLabel[mode]}\n                      onSelect={() => run(() => setLevelMode(mode))}\n                    />\n                  ))}\n                </Command.Group>\n              )}\n\n              {/* ── Go to Level sub-page ──────────────────────────────────── */}\n              {page === 'goto-level' && (\n                <Command.Group heading=\"Go to Level\">\n                  {allLevels.map((level) => (\n                    <OptionItem\n                      isActive={level.id === activeLevelId}\n                      key={level.id}\n                      label={level.name ?? `Level ${level.level}`}\n                      onSelect={() =>\n                        run(() => useViewer.getState().setSelection({ levelId: level.id }))\n                      }\n                    />\n                  ))}\n                </Command.Group>\n              )}\n\n              {/* ── Rename Level sub-page ─────────────────────────────────── */}\n              {page === 'rename-level' && (\n                <Command.Group heading=\"Rename Level\">\n                  <Command.Item\n                    className=\"flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40\"\n                    disabled={!inputValue.trim()}\n                    onSelect={confirmRename}\n                    value=\"confirm-rename\"\n                  >\n                    <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path d=\"M12 20h9\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                        <path\n                          d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </svg>\n                    </span>\n                    <span className=\"flex-1 truncate\">\n                      {inputValue.trim() ? (\n                        <>\n                          Rename to <span className=\"font-medium\">\"{inputValue.trim()}\"</span>\n                        </>\n                      ) : (\n                        <span className=\"text-muted-foreground\">Type a new name above…</span>\n                      )}\n                    </span>\n                  </Command.Item>\n                </Command.Group>\n              )}\n\n              {/* ── Camera Snapshot: scope picker ─────────────────────────── */}\n              {page === 'camera-view' && (\n                <Command.Group heading=\"Camera Snapshot — Select Scope\">\n                  <OptionItem\n                    icon={\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path d=\"M3 3h18v18H3z\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                        <path d=\"M3 9h18M9 21V9\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                      </svg>\n                    }\n                    label=\"Site\"\n                    onSelect={() => {\n                      const { rootNodeIds } = useScene.getState()\n                      const siteId = rootNodeIds[0]\n                      if (siteId) {\n                        setCameraScope({ nodeId: siteId, label: 'Site' })\n                        navigateTo('camera-scope')\n                      }\n                    }}\n                  />\n                  <OptionItem\n                    icon={\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path\n                          d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                        <polyline\n                          points=\"9 22 9 12 15 12 15 22\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </svg>\n                    }\n                    label=\"Building\"\n                    onSelect={() => {\n                      const building = Object.values(useScene.getState().nodes).find(\n                        (n) => n.type === 'building',\n                      )\n                      if (building) {\n                        setCameraScope({ nodeId: building.id, label: 'Building' })\n                        navigateTo('camera-scope')\n                      }\n                    }}\n                  />\n                  <OptionItem\n                    disabled={!activeLevelId}\n                    icon={\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path\n                          d=\"M12 2L2 7l10 5 10-5-10-5z\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                        <path\n                          d=\"M2 17l10 5 10-5M2 12l10 5 10-5\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </svg>\n                    }\n                    label=\"Level\"\n                    onSelect={() => {\n                      if (activeLevelId) {\n                        setCameraScope({ nodeId: activeLevelId, label: 'Level' })\n                        navigateTo('camera-scope')\n                      }\n                    }}\n                  />\n                  <OptionItem\n                    disabled={!useViewer.getState().selection.selectedIds.length}\n                    icon={\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path d=\"M5 3l14 9-14 9V3z\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                      </svg>\n                    }\n                    label=\"Selection\"\n                    onSelect={() => {\n                      const firstId = useViewer.getState().selection.selectedIds[0]\n                      if (firstId) {\n                        setCameraScope({ nodeId: firstId, label: 'Selection' })\n                        navigateTo('camera-scope')\n                      }\n                    }}\n                  />\n                </Command.Group>\n              )}\n\n              {/* ── Camera Snapshot: actions for selected scope ───────────── */}\n              {page === 'camera-scope' && cameraScope && (\n                <Command.Group heading={`${cameraScope.label} Snapshot`}>\n                  <OptionItem\n                    icon={\n                      <svg\n                        className=\"h-4 w-4\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={2}\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path\n                          d=\"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                        <circle\n                          cx=\"12\"\n                          cy=\"13\"\n                          r=\"4\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </svg>\n                    }\n                    label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}\n                    onSelect={takeSnapshot}\n                  />\n                  {hasScopeSnapshot && (\n                    <OptionItem\n                      icon={\n                        <svg\n                          className=\"h-4 w-4\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          strokeWidth={2}\n                          viewBox=\"0 0 24 24\"\n                        >\n                          <path\n                            d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                          <circle\n                            cx=\"12\"\n                            cy=\"12\"\n                            r=\"3\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                        </svg>\n                      }\n                      label=\"View Snapshot\"\n                      onSelect={viewSnapshot}\n                    />\n                  )}\n                  {hasScopeSnapshot && (\n                    <OptionItem\n                      icon={\n                        <svg\n                          className=\"h-4 w-4\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          strokeWidth={2}\n                          viewBox=\"0 0 24 24\"\n                        >\n                          <polyline\n                            points=\"3 6 5 6 21 6\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                          <path\n                            d=\"M19 6l-1 14H6L5 6\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                          <path d=\"M10 11v6M14 11v6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                          <path d=\"M9 6V4h6v2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                        </svg>\n                      }\n                      label=\"Clear Snapshot\"\n                      onSelect={clearSnapshot}\n                    />\n                  )}\n                </Command.Group>\n              )}\n            </Command.List>\n\n            {/* Footer hint */}\n            <div className=\"flex items-center justify-between border-border/50 border-t px-3 py-2\">\n              <span className=\"text-[11px] text-muted-foreground\">\n                <Shortcut keys={['↑', '↓']} /> navigate\n              </span>\n              <span className=\"text-[11px] text-muted-foreground\">\n                <Shortcut keys={['↵']} /> select\n              </span>\n              {page ? (\n                <span className=\"text-[11px] text-muted-foreground\">\n                  <Shortcut keys={['⌫']} /> back\n                </span>\n              ) : (\n                <span className=\"text-[11px] text-muted-foreground\">\n                  <Shortcut keys={['Esc']} /> close\n                </span>\n              )}\n            </div>\n          </Command>\n        )}\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/action-button.tsx",
    "content": "'use client'\n\nimport { cn } from '../../../lib/utils'\n\ninterface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  icon?: React.ReactNode\n  label: string\n}\n\nexport function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {\n  return (\n    <button\n      {...props}\n      className={cn(\n        'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',\n        className,\n      )}\n    >\n      {icon}\n      <span>{label}</span>\n    </button>\n  )\n}\n\nexport function ActionGroup({\n  children,\n  className,\n}: {\n  children: React.ReactNode\n  className?: string\n}) {\n  return <div className={cn('flex gap-1.5', className)}>{children}</div>\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/material-picker.tsx",
    "content": "'use client'\n\nimport { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'\nimport { useState } from 'react'\n\nconst PRESET_COLORS: Record<MaterialPreset, string> = {\n  white: '#ffffff',\n  brick: '#8b4513',\n  concrete: '#808080',\n  wood: '#deb887',\n  glass: '#87ceeb',\n  metal: '#c0c0c0',\n  plaster: '#f5f5dc',\n  tile: '#d3d3d3',\n  marble: '#fafafa',\n  custom: '#ffffff',\n}\n\nconst PRESET_LABELS: Record<MaterialPreset, string> = {\n  white: 'White',\n  brick: 'Brick',\n  concrete: 'Concrete',\n  wood: 'Wood',\n  glass: 'Glass',\n  metal: 'Metal',\n  plaster: 'Plaster',\n  tile: 'Tile',\n  marble: 'Marble',\n  custom: 'Custom',\n}\n\ntype MaterialPickerProps = {\n  value?: MaterialSchema\n  onChange: (material: MaterialSchema) => void\n}\n\nexport function MaterialPicker({ value, onChange }: MaterialPickerProps) {\n  const [showCustom, setShowCustom] = useState<boolean>(value?.preset === 'custom' || !!value?.properties)\n\n  const currentPreset = value?.preset || 'white'\n  const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]\n\n  const handlePresetChange = (preset: MaterialPreset) => {\n    if (preset === 'custom') {\n      setShowCustom(true)\n      onChange({\n        preset: 'custom',\n        properties: {\n          color: value?.properties?.color || '#ffffff',\n          roughness: value?.properties?.roughness ?? 0.5,\n          metalness: value?.properties?.metalness ?? 0,\n          opacity: value?.properties?.opacity ?? 1,\n          transparent: value?.properties?.transparent ?? false,\n          side: value?.properties?.side ?? 'front',\n        },\n      })\n    } else {\n      setShowCustom(false)\n      onChange({ preset })\n    }\n  }\n\n  const handlePropertyChange = (prop: keyof typeof currentProps, val: typeof currentProps[keyof typeof currentProps]) => {\n    onChange({\n      preset: showCustom ? 'custom' : currentPreset,\n      properties: {\n        ...currentProps,\n        [prop]: val,\n      },\n    })\n  }\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"grid grid-cols-5 gap-1.5\">\n        {(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (\n          <button\n            className={`h-8 w-8 rounded border-2 transition-all ${\n              currentPreset === preset\n                ? 'border-blue-500 ring-2 ring-blue-500/30'\n                : 'border-gray-300 hover:border-gray-400'\n            }`}\n            key={preset}\n            onClick={() => handlePresetChange(preset)}\n            style={{\n              backgroundColor: PRESET_COLORS[preset],\n              backgroundImage: preset === 'glass' ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' : undefined,\n              backgroundSize: preset === 'glass' ? '8px 8px' : undefined,\n            }}\n            title={PRESET_LABELS[preset]}\n            type=\"button\"\n          />\n        ))}\n      </div>\n\n      {showCustom && (\n        <div className=\"space-y-2 pt-2\">\n          <div className=\"flex items-center gap-2\">\n            <label className=\"text-xs text-gray-500 w-16\">Color</label>\n            <input\n              className=\"h-7 w-12 rounded border border-gray-300 cursor-pointer\"\n              onChange={(e) => handlePropertyChange('color', e.target.value)}\n              type=\"color\"\n              value={currentProps.color}\n            />\n            <input\n              className=\"flex-1 h-7 px-2 text-xs border border-gray-300 rounded\"\n              onChange={(e) => handlePropertyChange('color', e.target.value)}\n              type=\"text\"\n              value={currentProps.color}\n            />\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <label className=\"text-xs text-gray-500 w-16\">Roughness</label>\n            <input\n              className=\"flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer\"\n              max={1}\n              min={0}\n              onChange={(e) => handlePropertyChange('roughness', parseFloat(e.target.value))}\n              step={0.01}\n              type=\"range\"\n              value={currentProps.roughness}\n            />\n            <span className=\"text-xs text-gray-400 w-8 text-right\">{currentProps.roughness.toFixed(2)}</span>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <label className=\"text-xs text-gray-500 w-16\">Metalness</label>\n            <input\n              className=\"flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer\"\n              max={1}\n              min={0}\n              onChange={(e) => handlePropertyChange('metalness', parseFloat(e.target.value))}\n              step={0.01}\n              type=\"range\"\n              value={currentProps.metalness}\n            />\n            <span className=\"text-xs text-gray-400 w-8 text-right\">{currentProps.metalness.toFixed(2)}</span>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <label className=\"text-xs text-gray-500 w-16\">Opacity</label>\n            <input\n              className=\"flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer\"\n              max={1}\n              min={0}\n              onChange={(e) => {\n                const opacity = parseFloat(e.target.value)\n                handlePropertyChange('opacity', opacity)\n                if (opacity < 1 && !currentProps.transparent) {\n                  handlePropertyChange('transparent', true)\n                }\n              }}\n              step={0.01}\n              type=\"range\"\n              value={currentProps.opacity}\n            />\n            <span className=\"text-xs text-gray-400 w-8 text-right\">{currentProps.opacity.toFixed(2)}</span>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <label className=\"text-xs text-gray-500 w-16\">Side</label>\n            <select\n              className=\"flex-1 h-7 px-2 text-xs border border-gray-300 rounded\"\n              onChange={(e) => handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')}\n              value={currentProps.side}\n            >\n              <option value=\"front\">Front</option>\n              <option value=\"back\">Back</option>\n              <option value=\"double\">Double</option>\n            </select>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/metric-control.tsx",
    "content": "'use client'\n\nimport { useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from '../../../lib/utils'\n\ninterface MetricControlProps {\n  label: React.ReactNode\n  value: number\n  onChange: (value: number) => void\n  min?: number\n  max?: number\n  precision?: number\n  step?: number\n  className?: string\n  unit?: string\n}\n\nexport function MetricControl({\n  label,\n  value,\n  onChange,\n  min = Number.NEGATIVE_INFINITY,\n  max = Number.POSITIVE_INFINITY,\n  precision = 2,\n  step = 1,\n  className,\n  unit = '',\n}: MetricControlProps) {\n  const viewerUnit = useViewer((state) => state.unit)\n  const isImperial = viewerUnit === 'imperial' && unit === 'm'\n  const multiplier = isImperial ? 3.280_84 : 1\n  const displayUnit = isImperial ? 'ft' : unit\n\n  const displayValue = value * multiplier\n\n  const [isEditing, setIsEditing] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [isHovered, setIsHovered] = useState(false)\n  const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))\n  const startXRef = useRef(0)\n  const startValueRef = useRef(0)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  const valueRef = useRef(value)\n  valueRef.current = value\n\n  const clamp = useCallback(\n    (val: number) => {\n      return Math.min(Math.max(val, min), max)\n    },\n    [min, max],\n  )\n\n  useEffect(() => {\n    if (!isEditing) {\n      setInputValue(displayValue.toFixed(precision))\n    }\n  }, [displayValue, precision, isEditing])\n\n  useEffect(() => {\n    const container = containerRef.current\n    if (!container) return\n\n    const handleWheel = (e: WheelEvent) => {\n      if (isEditing) return\n\n      e.preventDefault()\n\n      const direction = e.deltaY < 0 ? 1 : -1\n      let scrollStep = step / multiplier\n      if (e.shiftKey) scrollStep = (step * 10) / multiplier\n      else if (e.altKey) scrollStep = (step * 0.1) / multiplier\n\n      const newValue = clamp(valueRef.current + direction * scrollStep)\n      const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier\n\n      if (Math.abs(finalValue - valueRef.current) > 1e-6) {\n        onChange(finalValue)\n      }\n    }\n\n    container.addEventListener('wheel', handleWheel, { passive: false })\n    return () => container.removeEventListener('wheel', handleWheel)\n  }, [isEditing, step, clamp, onChange, precision, multiplier])\n\n  useEffect(() => {\n    if (!isHovered || isEditing) return\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      let direction = 0\n      if (e.key === 'ArrowUp') direction = 1\n      else if (e.key === 'ArrowDown') direction = -1\n\n      if (direction !== 0) {\n        e.preventDefault()\n        let scrollStep = step / multiplier\n        if (e.shiftKey) scrollStep = (step * 10) / multiplier\n        else if (e.altKey) scrollStep = (step * 0.1) / multiplier\n\n        const newValue = clamp(valueRef.current + direction * scrollStep)\n        const finalValue =\n          Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier\n\n        if (Math.abs(finalValue - valueRef.current) > 1e-6) {\n          onChange(finalValue)\n        }\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      if (isEditing) return\n      e.preventDefault()\n\n      setIsDragging(true)\n      startXRef.current = e.clientX\n      startValueRef.current = value\n      useScene.temporal.getState().pause()\n\n      let finalValue = value\n\n      const handlePointerMove = (moveEvent: PointerEvent) => {\n        const deltaX = moveEvent.clientX - startXRef.current\n\n        let dragStep = step / multiplier\n        if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier\n        else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier\n\n        const deltaValue = deltaX * dragStep\n        const newValue = clamp(startValueRef.current + deltaValue)\n        const newFinalValue =\n          Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier\n\n        if (Math.abs(newFinalValue - finalValue) > 1e-6) {\n          finalValue = newFinalValue\n          onChange(finalValue)\n        }\n      }\n\n      const handlePointerUp = () => {\n        setIsDragging(false)\n        document.removeEventListener('pointermove', handlePointerMove)\n        document.removeEventListener('pointerup', handlePointerUp)\n\n        if (Math.abs(finalValue - startValueRef.current) > 1e-6) {\n          onChange(startValueRef.current)\n          useScene.temporal.getState().resume()\n          onChange(finalValue)\n        } else {\n          useScene.temporal.getState().resume()\n        }\n      }\n\n      document.addEventListener('pointermove', handlePointerMove)\n      document.addEventListener('pointerup', handlePointerUp)\n    },\n    [isEditing, value, onChange, clamp, precision, step, multiplier],\n  )\n\n  const handleValueClick = useCallback(() => {\n    setIsEditing(true)\n    setInputValue((value * multiplier).toFixed(precision))\n  }, [value, multiplier, precision])\n\n  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    setInputValue(e.target.value)\n  }, [])\n\n  const submitValue = useCallback(() => {\n    const numValue = Number.parseFloat(inputValue)\n    if (Number.isNaN(numValue)) {\n      setInputValue((value * multiplier).toFixed(precision))\n    } else {\n      onChange(clamp(numValue / multiplier))\n    }\n    setIsEditing(false)\n  }, [inputValue, onChange, clamp, multiplier, value, precision])\n\n  const handleInputBlur = useCallback(() => {\n    submitValue()\n  }, [submitValue])\n\n  const handleInputKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === 'Enter') {\n        submitValue()\n      } else if (e.key === 'Escape') {\n        setInputValue((value * multiplier).toFixed(precision))\n        setIsEditing(false)\n      } else if (e.key === 'ArrowUp') {\n        e.preventDefault()\n        const newV = clamp(value + step / multiplier)\n        onChange(newV)\n        setInputValue((newV * multiplier).toFixed(precision))\n      } else if (e.key === 'ArrowDown') {\n        e.preventDefault()\n        const newV = clamp(value - step / multiplier)\n        onChange(newV)\n        setInputValue((newV * multiplier).toFixed(precision))\n      }\n    },\n    [submitValue, value, multiplier, precision, step, clamp, onChange],\n  )\n\n  return (\n    <div\n      className={cn(\n        'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',\n        isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',\n        className,\n      )}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      ref={containerRef}\n    >\n      <div\n        className={cn(\n          'select-none truncate text-muted-foreground transition-colors',\n          isDragging\n            ? 'cursor-ew-resize text-foreground'\n            : 'hover:cursor-ew-resize hover:text-foreground',\n        )}\n        onPointerDown={handlePointerDown}\n      >\n        {label}\n      </div>\n\n      <div className=\"flex shrink-0 justify-end\">\n        {isEditing ? (\n          <div className=\"flex items-center\">\n            <input\n              autoFocus\n              className=\"w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30\"\n              onBlur={handleInputBlur}\n              onChange={handleInputChange}\n              onKeyDown={handleInputKeyDown}\n              type=\"text\"\n              value={inputValue}\n            />\n            {displayUnit && <span className=\"ml-[1px] text-muted-foreground\">{displayUnit}</span>}\n          </div>\n        ) : (\n          <div\n            className=\"flex w-full cursor-text items-center justify-end text-foreground transition-colors hover:text-primary\"\n            onClick={handleValueClick}\n          >\n            <span className=\"font-mono tabular-nums tracking-tight\">\n              {Number(displayValue.toFixed(precision)).toFixed(precision)}\n            </span>\n            {displayUnit && <span className=\"ml-[1px] text-muted-foreground\">{displayUnit}</span>}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/panel-section.tsx",
    "content": "'use client'\n\nimport { ChevronDown } from 'lucide-react'\nimport { AnimatePresence, motion } from 'motion/react'\nimport { useState } from 'react'\nimport { cn } from '../../../lib/utils'\n\ninterface PanelSectionProps {\n  title: string\n  children: React.ReactNode\n  defaultExpanded?: boolean\n  className?: string\n}\n\nexport function PanelSection({\n  title,\n  children,\n  defaultExpanded = true,\n  className,\n}: PanelSectionProps) {\n  const [isExpanded, setIsExpanded] = useState(defaultExpanded)\n\n  return (\n    <motion.div\n      className={cn('flex shrink-0 flex-col overflow-hidden border-border/50 border-b', className)}\n      layout\n      transition={{ type: 'spring', bounce: 0, duration: 0.4 }}\n    >\n      <motion.button\n        className={cn(\n          'group/section flex h-10 shrink-0 items-center justify-between px-3 transition-all duration-200',\n          isExpanded\n            ? 'bg-accent/50 text-foreground'\n            : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n        )}\n        layout=\"position\"\n        onClick={() => setIsExpanded(!isExpanded)}\n        type=\"button\"\n      >\n        <span className=\"truncate font-medium text-sm\">{title}</span>\n        <ChevronDown\n          className={cn(\n            'h-4 w-4 transition-transform duration-200',\n            isExpanded ? 'rotate-180' : 'rotate-0',\n            isExpanded ? 'text-foreground' : 'opacity-0 group-hover/section:opacity-100',\n          )}\n        />\n      </motion.button>\n\n      <AnimatePresence initial={false}>\n        {isExpanded && (\n          <motion.div\n            animate={{ height: 'auto', opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            transition={{ type: 'spring', bounce: 0, duration: 0.4 }}\n          >\n            <div className=\"flex flex-col gap-1.5 p-3 pt-2\">{children}</div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/segmented-control.tsx",
    "content": "'use client'\n\nimport { cn } from '../../../lib/utils'\n\ninterface SegmentedControlProps<T extends string> {\n  value: T\n  onChange: (value: T) => void\n  options: { label: React.ReactNode; value: T }[]\n  className?: string\n}\n\nexport function SegmentedControl<T extends string>({\n  value,\n  onChange,\n  options,\n  className,\n}: SegmentedControlProps<T>) {\n  return (\n    <div\n      className={cn(\n        'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',\n        className,\n      )}\n    >\n      {options.map((option) => {\n        const isSelected = value === option.value\n        return (\n          <button\n            className={cn(\n              'relative flex h-full flex-1 items-center justify-center rounded-md font-medium text-xs transition-all duration-200',\n              isSelected\n                ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'\n                : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',\n            )}\n            key={option.value}\n            onClick={() => onChange(option.value)}\n            type=\"button\"\n          >\n            <span className=\"relative z-10 flex items-center gap-1.5\">{option.label}</span>\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/slider-control.tsx",
    "content": "'use client'\n\nimport { useScene } from '@pascal-app/core'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from '../../../lib/utils'\n\ninterface SliderControlProps {\n  label: React.ReactNode\n  value: number\n  onChange: (value: number) => void\n  min?: number\n  max?: number\n  precision?: number\n  step?: number\n  className?: string\n  unit?: string\n}\n\nexport function SliderControl({\n  label,\n  value,\n  onChange,\n  min = Number.NEGATIVE_INFINITY,\n  max = Number.POSITIVE_INFINITY,\n  precision = 0,\n  step = 1,\n  className,\n  unit = '',\n}: SliderControlProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [isHovered, setIsHovered] = useState(false)\n  const [inputValue, setInputValue] = useState(value.toFixed(precision))\n\n  const dragRef = useRef<{ accumulatedDx: number; startValue: number } | null>(null)\n  const labelRef = useRef<HTMLDivElement>(null)\n  const valueRef = useRef(value)\n  valueRef.current = value\n\n  const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])\n\n  useEffect(() => {\n    if (!isEditing) {\n      setInputValue(value.toFixed(precision))\n    }\n  }, [value, precision, isEditing])\n\n  // Wheel support on the label\n  useEffect(() => {\n    const el = labelRef.current\n    if (!el) return\n    const handleWheel = (e: WheelEvent) => {\n      if (isEditing) return\n      e.preventDefault()\n      const direction = e.deltaY < 0 ? 1 : -1\n      let s = step\n      if (e.shiftKey) s = step * 10\n      else if (e.altKey) s = step * 0.1\n      const newValue = clamp(valueRef.current + direction * s)\n      const final = Number.parseFloat(newValue.toFixed(precision))\n      if (final !== valueRef.current) onChange(final)\n    }\n    el.addEventListener('wheel', handleWheel, { passive: false })\n    return () => el.removeEventListener('wheel', handleWheel)\n  }, [isEditing, step, clamp, onChange, precision])\n\n  // Arrow key support while hovered\n  useEffect(() => {\n    if (!isHovered || isEditing) return\n    const handleKeyDown = (e: KeyboardEvent) => {\n      let direction = 0\n      if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1\n      else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1\n      if (direction !== 0) {\n        e.preventDefault()\n        let s = step\n        if (e.shiftKey) s = step * 10\n        else if (e.metaKey || e.ctrlKey) s = step * 0.1\n        const newValue = clamp(valueRef.current + direction * s)\n        const final = Number.parseFloat(newValue.toFixed(precision))\n        if (final !== valueRef.current) onChange(final)\n      }\n    }\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [isHovered, isEditing, step, clamp, onChange, precision])\n\n  const handleLabelPointerDown = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (isEditing) return\n      e.preventDefault()\n      // Use PointerLock for infinite dragging (Unity3D-style).\n      // Falls back to pointer capture if lock is denied.\n      const el = e.currentTarget\n      if (el.requestPointerLock) {\n        el.requestPointerLock()\n      } else {\n        el.setPointerCapture(e.pointerId)\n      }\n      dragRef.current = { accumulatedDx: 0, startValue: valueRef.current }\n      setIsDragging(true)\n      useScene.temporal.getState().pause()\n    },\n    [isEditing],\n  )\n\n  const handleLabelPointerMove = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (!dragRef.current) return\n      // Accumulate movementX for infinite dragging. movementX gives the\n      // delta since the last event, independent of screen bounds.\n      dragRef.current.accumulatedDx += e.movementX\n      const { accumulatedDx, startValue } = dragRef.current\n      let s = step\n      if (e.shiftKey) s = step * 10\n      else if (e.metaKey || e.ctrlKey) s = step * 0.1\n      // 4 px per step at default sensitivity\n      const newValue = clamp(Number.parseFloat((startValue + (accumulatedDx / 4) * s).toFixed(precision)))\n      onChange(newValue)\n    },\n    [step, precision, clamp, onChange],\n  )\n\n  const handleLabelPointerUp = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (!dragRef.current) return\n      const { startValue } = dragRef.current\n      const finalVal = valueRef.current\n      dragRef.current = null\n      setIsDragging(false)\n\n      if (document.pointerLockElement) {\n        document.exitPointerLock()\n      } else {\n        e.currentTarget.releasePointerCapture(e.pointerId)\n      }\n\n      if (startValue !== finalVal) {\n        onChange(startValue)\n        useScene.temporal.getState().resume()\n        onChange(finalVal)\n      } else {\n        useScene.temporal.getState().resume()\n      }\n    },\n    [onChange],\n  )\n\n  // Clean up drag state if pointer lock is lost unexpectedly (e.g. Escape key)\n  useEffect(() => {\n    const handlePointerLockChange = () => {\n      if (!document.pointerLockElement && dragRef.current) {\n        const { startValue } = dragRef.current\n        const finalVal = valueRef.current\n        dragRef.current = null\n        setIsDragging(false)\n\n        if (startValue !== finalVal) {\n          onChange(startValue)\n          useScene.temporal.getState().resume()\n          onChange(finalVal)\n        } else {\n          useScene.temporal.getState().resume()\n        }\n      }\n    }\n    document.addEventListener('pointerlockchange', handlePointerLockChange)\n    return () => document.removeEventListener('pointerlockchange', handlePointerLockChange)\n  }, [onChange])\n\n  const handleValueClick = useCallback(() => {\n    setIsEditing(true)\n    setInputValue(value.toFixed(precision))\n  }, [value, precision])\n\n  const submitValue = useCallback(() => {\n    const numValue = Number.parseFloat(inputValue)\n    if (Number.isNaN(numValue)) {\n      setInputValue(value.toFixed(precision))\n    } else {\n      onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))\n    }\n    setIsEditing(false)\n  }, [inputValue, onChange, clamp, precision, value])\n\n  const handleInputKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === 'Enter') {\n        submitValue()\n      } else if (e.key === 'Escape') {\n        setInputValue(value.toFixed(precision))\n        setIsEditing(false)\n      } else if (e.key === 'ArrowUp') {\n        e.preventDefault()\n        const newV = clamp(value + step)\n        onChange(newV)\n        setInputValue(newV.toFixed(precision))\n      } else if (e.key === 'ArrowDown') {\n        e.preventDefault()\n        const newV = clamp(value - step)\n        onChange(newV)\n        setInputValue(newV.toFixed(precision))\n      }\n    },\n    [submitValue, value, precision, step, clamp, onChange],\n  )\n\n  return (\n    <div\n      className={cn(\n        'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',\n        isDragging ? 'bg-white/5' : 'hover:bg-white/5',\n        className,\n      )}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n    >\n      {/* Label — drag handle */}\n      <div\n        className={cn(\n          'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',\n          isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',\n        )}\n        onPointerDown={handleLabelPointerDown}\n        onPointerMove={handleLabelPointerMove}\n        onPointerUp={handleLabelPointerUp}\n        ref={labelRef}\n      >\n        {/* Grip dots — 2×3 grid */}\n        <div\n          className={cn(\n            'grid grid-cols-2 gap-[2.5px] transition-opacity',\n            isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',\n          )}\n        >\n          {[...Array(6)].map((_, i) => (\n            <div className=\"h-[2px] w-[2px] rounded-full bg-current\" key={i} />\n          ))}\n        </div>\n        <span className=\"font-medium\">{label}</span>\n      </div>\n\n      <div className=\"flex-1\" />\n\n      {/* Value — click to edit */}\n      <div className=\"flex items-center text-xs\">\n        {isEditing ? (\n          <>\n            <input\n              autoFocus\n              className=\"w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30\"\n              onBlur={submitValue}\n              onChange={(e) => setInputValue(e.target.value)}\n              onKeyDown={handleInputKeyDown}\n              type=\"text\"\n              value={inputValue}\n            />\n            {unit && <span className=\"ml-[1px] text-muted-foreground\">{unit}</span>}\n          </>\n        ) : (\n          <div\n            className=\"flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground\"\n            onClick={handleValueClick}\n          >\n            <span className=\"font-mono tabular-nums tracking-tight\">\n              {Number(value.toFixed(precision)).toFixed(precision)}\n            </span>\n            {unit && <span className=\"ml-[1px] text-muted-foreground\">{unit}</span>}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/controls/toggle-control.tsx",
    "content": "'use client'\n\nimport { Check } from 'lucide-react'\nimport { cn } from '../../../lib/utils'\n\ninterface ToggleControlProps {\n  label: string\n  checked: boolean\n  onChange: (checked: boolean) => void\n  className?: string\n}\n\nexport function ToggleControl({ label, checked, onChange, className }: ToggleControlProps) {\n  return (\n    <div\n      className={cn(\n        'group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]',\n        className,\n      )}\n      onClick={() => onChange(!checked)}\n    >\n      <div className=\"select-none text-muted-foreground transition-colors group-hover:text-foreground\">\n        {label}\n      </div>\n\n      <div\n        className={cn(\n          'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',\n          checked\n            ? 'border-primary bg-primary text-primary-foreground'\n            : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',\n        )}\n      >\n        <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/floating-level-selector.tsx",
    "content": "'use client'\n\nimport { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useShallow } from 'zustand/react/shallow'\nimport { cn } from '../../lib/utils'\n\nfunction getLevelDisplayLabel(level: LevelNode) {\n  return level.name || `Level ${level.level}`\n}\n\nexport function FloatingLevelSelector() {\n  const selectedBuildingId = useViewer((s) => s.selection.buildingId)\n  const levelId = useViewer((s) => s.selection.levelId)\n  const setSelection = useViewer((s) => s.setSelection)\n\n  // Resolve the effective building ID — selected or first in scene (scalar, stable reference)\n  const resolvedBuildingId = useScene((state) => {\n    if (selectedBuildingId) return selectedBuildingId\n    const first = Object.values(state.nodes).find((n) => n?.type === 'building') as\n      | BuildingNode\n      | undefined\n    return first?.id ?? null\n  })\n\n  // Get levels for the resolved building (array, useShallow for stable reference)\n  const levels = useScene(\n    useShallow((state) => {\n      if (!resolvedBuildingId) return [] as LevelNode[]\n      const building = state.nodes[resolvedBuildingId]\n      if (!building || building.type !== 'building') return [] as LevelNode[]\n      return (building as BuildingNode).children\n        .map((id) => state.nodes[id])\n        .filter((node): node is LevelNode => node?.type === 'level')\n        .sort((a, b) => a.level - b.level)\n    }),\n  )\n\n  if (levels.length <= 1) return null\n\n  // Display highest level at top, ground at bottom\n  const reversedLevels = [...levels].reverse()\n\n  return (\n    <div className=\"pointer-events-auto absolute top-14 left-3 z-20\">\n      {/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */}\n      <div className=\"flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md\">\n        {reversedLevels.map((level) => {\n          const isSelected = level.id === levelId\n          return (\n            <button\n              className={cn(\n                'flex min-w-[80px] items-center justify-start rounded-lg px-2.5 py-1.5 font-medium text-xs transition-colors',\n                isSelected\n                  ? 'bg-white/10 text-foreground'\n                  : 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',\n              )}\n              key={level.id}\n              onClick={() =>\n                setSelection(\n                  resolvedBuildingId\n                    ? { buildingId: resolvedBuildingId, levelId: level.id }\n                    : { levelId: level.id },\n                )\n              }\n              title={getLevelDisplayLabel(level)}\n              type=\"button\"\n            >\n              <span className=\"truncate\">{getLevelDisplayLabel(level)}</span>\n            </button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/ceiling-helper.tsx",
    "content": "import { ShortcutToken } from '../primitives/shortcut-token'\n\nexport function CeilingHelper() {\n  return (\n    <div className=\"pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md\">\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Left click\" />\n        <span className=\"text-muted-foreground\">Add point</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Shift\" />\n        <span className=\"text-muted-foreground\">Allow non-45° angles</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Esc\" />\n        <span className=\"text-muted-foreground\">Cancel</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/helper-manager.tsx",
    "content": "'use client'\n\nimport useEditor from '../../../store/use-editor'\nimport { CeilingHelper } from './ceiling-helper'\nimport { ItemHelper } from './item-helper'\nimport { RoofHelper } from './roof-helper'\nimport { SlabHelper } from './slab-helper'\nimport { WallHelper } from './wall-helper'\n\nexport function HelperManager() {\n  const tool = useEditor((s) => s.tool)\n  const movingNode = useEditor((state) => state.movingNode)\n\n  if (movingNode) {\n    return <ItemHelper showEsc />\n  }\n\n  // Show appropriate helper based on current tool\n  switch (tool) {\n    case 'wall':\n      return <WallHelper />\n    case 'item':\n      return <ItemHelper />\n    case 'slab':\n      return <SlabHelper />\n    case 'ceiling':\n      return <CeilingHelper />\n    case 'roof':\n      return <RoofHelper />\n    default:\n      return null\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/item-helper.tsx",
    "content": "import { ShortcutToken } from '../primitives/shortcut-token'\n\ninterface ItemHelperProps {\n  showEsc?: boolean\n}\n\nexport function ItemHelper({ showEsc }: ItemHelperProps) {\n  return (\n    <div className=\"pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md\">\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Left click\" />\n        <span className=\"text-muted-foreground\">Place item</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"R\" />\n        <span className=\"text-muted-foreground\">Rotate counterclockwise</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"T\" />\n        <span className=\"text-muted-foreground\">Rotate clockwise</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Shift\" />\n        <span className=\"text-muted-foreground\">Free place</span>\n      </div>\n      {showEsc && (\n        <div className=\"flex items-center gap-2 text-sm\">\n          <ShortcutToken value=\"Esc\" />\n          <span className=\"text-muted-foreground\">Cancel</span>\n        </div>\n      )}\n      {!showEsc && (\n        <div className=\"flex items-center gap-2 text-sm\">\n          <ShortcutToken value=\"Right click\" />\n          <span className=\"text-muted-foreground\">Cancel</span>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/roof-helper.tsx",
    "content": "import { ShortcutToken } from '../primitives/shortcut-token'\n\nexport function RoofHelper() {\n  return (\n    <div className=\"pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md\">\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Left click\" />\n        <span className=\"text-muted-foreground\">Set corner</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Esc\" />\n        <span className=\"text-muted-foreground\">Cancel</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/slab-helper.tsx",
    "content": "import { ShortcutToken } from '../primitives/shortcut-token'\n\nexport function SlabHelper() {\n  return (\n    <div className=\"pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md\">\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Left click\" />\n        <span className=\"text-muted-foreground\">Add point</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Shift\" />\n        <span className=\"text-muted-foreground\">Allow non-45° angles</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Esc\" />\n        <span className=\"text-muted-foreground\">Cancel</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/helpers/wall-helper.tsx",
    "content": "import { ShortcutToken } from '../primitives/shortcut-token'\n\nexport function WallHelper() {\n  return (\n    <div className=\"pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md\">\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Left click\" />\n        <span className=\"text-muted-foreground\">Set wall start / end</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Shift\" />\n        <span className=\"text-muted-foreground\">Allow non-45° angles</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-sm\">\n        <ShortcutToken value=\"Esc\" />\n        <span className=\"text-muted-foreground\">Cancel</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/item-catalog/catalog-items.tsx",
    "content": "import { type AssetInput, ItemNode } from '@pascal-app/core'\nexport const CATALOG_ITEMS: AssetInput[] = [\n  {\n    id: 'tesla',\n    category: 'outdoor',\n    tags: ['floor', 'garage'],\n    name: 'Tesla',\n    thumbnail: '/items/tesla/thumbnail.webp',\n    src: '/items/tesla/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.7, 5],\n  },\n  {\n    id: 'ev-wall-charger',\n    category: 'appliance',\n    tags: ['wall', 'garage'],\n    name: 'Ev-wall-charger',\n    thumbnail: '/items/ev-wall-charger/thumbnail.webp',\n    src: '/items/ev-wall-charger/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.07, 0.4, 0.15],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.8, 0.5],\n    attachTo: 'wall',\n  },\n  {\n    id: 'pillar',\n    category: 'outdoor',\n    tags: ['structure', 'fencing'],\n    name: 'Pillar',\n    thumbnail: '/items/pillar/thumbnail.webp',\n    src: '/items/pillar/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1.3, 0.5],\n  },\n\n  {\n    id: 'high-fence',\n    category: 'outdoor',\n    tags: ['fencing'],\n    name: 'High Fence',\n    thumbnail: '/items/high-fence/thumbnail.webp',\n    src: '/items/high-fence/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.01, 0],\n    rotation: [0, 0, 0],\n    dimensions: [4, 4.1, 0.5],\n  },\n\n  {\n    id: 'medium-fence',\n    category: 'outdoor',\n    tags: ['fencing'],\n    name: 'Medium Fence',\n    thumbnail: '/items/medium-fence/thumbnail.webp',\n    src: '/items/medium-fence/model.glb',\n    scale: [0.49, 0.49, 0.49],\n    offset: [0, 0.01, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 2, 0.5],\n  },\n\n  {\n    id: 'low-fence',\n    category: 'outdoor',\n    tags: ['fencing'],\n    name: 'Low Fence',\n    thumbnail: '/items/low-fence/thumbnail.webp',\n    src: '/items/low-fence/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.01, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.8, 0.5],\n  },\n\n  {\n    id: 'bush',\n    category: 'outdoor',\n    tags: ['vegetation'],\n    name: 'Bush',\n    thumbnail: '/items/bush/thumbnail.webp',\n    src: '/items/bush/model.glb',\n    scale: [0.96, 0.96, 0.96],\n    offset: [-0.14, 0.01, -0.13],\n    rotation: [0, 0, 0],\n    dimensions: [3, 1.1, 1],\n  },\n\n  {\n    id: 'fir-tree',\n    category: 'outdoor',\n    tags: ['vegetation'],\n    name: 'Fir',\n    thumbnail: '/items/fir-tree/thumbnail.webp',\n    src: '/items/fir-tree/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.01, 0.05, -0.07],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 3, 0.5],\n  },\n\n  {\n    id: 'tree',\n    category: 'outdoor',\n    tags: ['vegetation'],\n    name: 'Tree',\n    thumbnail: '/items/tree/thumbnail.webp',\n    src: '/items/tree/model.glb',\n    scale: [0.65, 0.65, 0.65],\n    offset: [-0.02, 0.17, -0.04],\n    rotation: [0, 0, 0],\n    dimensions: [1, 5, 1],\n  },\n\n  {\n    id: 'palm',\n    category: 'outdoor',\n    tags: ['vegetation'],\n    name: 'Palm',\n    thumbnail: '/items/palm/thumbnail.webp',\n    src: '/items/palm/model.glb',\n    scale: [0.37, 0.37, 0.37],\n    offset: [0, 0, 0.02],\n    rotation: [0, 0, 0],\n    dimensions: [1, 4.5, 1],\n  },\n\n  {\n    id: 'patio-umbrella',\n    category: 'outdoor',\n    tags: ['leisure', 'floor'],\n    name: 'Patio Umbrella',\n    thumbnail: '/items/patio-umbrella/thumbnail.webp',\n    src: '/items/patio-umbrella/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 3.7, 0.5],\n  },\n\n  {\n    id: 'sunbed',\n    category: 'outdoor',\n    tags: ['leisure', 'seating', 'floor'],\n    name: 'Sunbed',\n    thumbnail: '/items/sunbed/thumbnail.webp',\n    src: '/items/sunbed/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.04, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.2, 1.5],\n  },\n\n  {\n    id: 'window-double',\n    category: 'window',\n    tags: ['wall'],\n    name: 'Double Window',\n    thumbnail: '/items/window-double/thumbnail.webp',\n    src: '/items/window-double/model.glb',\n    scale: [0.81, 0.81, 0.81],\n    offset: [0, -0.32, 0],\n    rotation: [0, 3.14, 0],\n    dimensions: [1.5, 1.5, 0.5],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'window-simple',\n    category: 'window',\n    tags: ['wall'],\n    name: 'Simple Window',\n    thumbnail: '/items/window-simple/thumbnail.webp',\n    src: '/items/window-simple/model.glb',\n    scale: [1, 1, 1],\n    offset: [1.06, -0.21, 0.05],\n    rotation: [0, 3.14, 0],\n    dimensions: [1.5, 2, 0.5],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'window-rectangle',\n    category: 'window',\n    tags: ['wall'],\n    name: 'Rectangle Window',\n    thumbnail: '/items/window-rectangle/thumbnail.webp',\n    src: '/items/window-rectangle/model.glb',\n    scale: [0.81, 0.81, 0.81],\n    offset: [-1.41, -0.28, 0.08],\n    rotation: [0, 3.14, 0],\n    dimensions: [2.5, 1.5, 0.5],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'door-bar',\n    category: 'door',\n    tags: ['wall'],\n    name: 'Door with bar',\n    thumbnail: '/items/door-bar/thumbnail.webp',\n    src: '/items/door-bar/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.48, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 2.5, 0.5],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'glass-door',\n    category: 'door',\n    tags: ['wall'],\n    name: 'Glass Door',\n    thumbnail: '/items/glass-door/thumbnail.webp',\n    src: '/items/glass-door/model.glb',\n    scale: [0.9, 0.9, 0.9],\n    offset: [-0.52, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 2.5, 0.4],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'door',\n    category: 'door',\n    tags: ['wall'],\n    name: 'Door',\n    thumbnail: '/items/door/thumbnail.webp',\n    src: '/items/door/model.glb',\n    scale: [0.79, 0.79, 0.79],\n    offset: [-0.43, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 2, 0.4],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'parking-spot',\n    category: 'outdoor',\n    tags: ['leisure', 'floor'],\n    name: 'Parking Spot',\n    thumbnail: '/items/parking-spot/thumbnail.webp',\n    src: '/items/parking-spot/model.glb',\n    scale: [0.9, 1, 0.78],\n    offset: [0, 0, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [5, 1, 2.5],\n  },\n\n  {\n    id: 'outdoor-playhouse',\n    category: 'outdoor',\n    tags: ['leisure', 'kids', 'floor'],\n    name: 'Outdoor Playhouse',\n    thumbnail: '/items/outdoor-playhouse/thumbnail.webp',\n    src: '/items/outdoor-playhouse/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 1],\n  },\n\n  {\n    id: 'skate',\n    category: 'outdoor',\n    tags: ['leisure', 'kids', 'floor'],\n    name: 'Skate',\n    thumbnail: '/items/skate/thumbnail.webp',\n    src: '/items/skate/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.2, 0.5],\n  },\n\n  {\n    id: 'scooter',\n    category: 'outdoor',\n    tags: ['leisure', 'kids', 'floor'],\n    name: 'Scooter',\n    thumbnail: '/items/scooter/thumbnail.webp',\n    src: '/items/scooter/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.11, 0, 0.17],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.9, 0.5],\n  },\n\n  {\n    id: 'basket-hoop',\n    category: 'outdoor',\n    tags: ['leisure', 'sports', 'floor'],\n    name: 'Basket Hoop',\n    thumbnail: '/items/basket-hoop/thumbnail.webp',\n    src: '/items/basket-hoop/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.8, 1],\n  },\n\n  {\n    id: 'ball',\n    category: 'outdoor',\n    tags: ['leisure', 'sports', 'floor'],\n    name: 'Ball',\n    thumbnail: '/items/ball/thumbnail.webp',\n    src: '/items/ball/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.12, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'wine-bottle',\n    category: 'kitchen',\n    tags: ['countertop', 'decor'],\n    name: 'Wine Bottle',\n    thumbnail: '/items/wine-bottle/thumbnail.webp',\n    src: '/items/wine-bottle/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.05, 0, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.4, 0.5],\n  },\n\n  {\n    id: 'fruits',\n    category: 'kitchen',\n    tags: ['countertop', 'decor'],\n    name: 'Fruits',\n    thumbnail: '/items/fruits/thumbnail.webp',\n    src: '/items/fruits/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'cutting-board',\n    category: 'kitchen',\n    tags: ['countertop'],\n    name: 'Cutting Board',\n    thumbnail: '/items/cutting-board/thumbnail.webp',\n    src: '/items/cutting-board/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.1, 0.5],\n  },\n\n  {\n    id: 'frying-pan',\n    category: 'kitchen',\n    tags: ['countertop'],\n    name: 'Frying Pan',\n    thumbnail: '/items/frying-pan/thumbnail.webp',\n    src: '/items/frying-pan/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.1, 1],\n  },\n\n  {\n    id: 'kitchen-utensils',\n    category: 'kitchen',\n    tags: ['countertop'],\n    name: 'Kitchen Utensils',\n    thumbnail: '/items/kitchen-utensils/thumbnail.webp',\n    src: '/items/kitchen-utensils/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n  },\n\n  {\n    id: 'microwave',\n    category: 'kitchen',\n    tags: ['countertop', 'electronics'],\n    name: 'Microwave',\n    thumbnail: '/items/microwave/thumbnail.webp',\n    src: '/items/microwave/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.03],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.3, 0.5],\n  },\n\n  {\n    id: 'stove',\n    category: 'kitchen',\n    tags: ['floor', 'large'],\n    name: 'Stove',\n    thumbnail: '/items/stove/thumbnail.webp',\n    src: '/items/stove/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.05],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1, 1],\n  },\n\n  {\n    id: 'fridge',\n    category: 'kitchen',\n    tags: ['floor', 'large'],\n    name: 'Fridge',\n    thumbnail: '/items/fridge/thumbnail.webp',\n    src: '/items/fridge/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.01, 0, -0.05],\n    rotation: [0, 0, 0],\n    dimensions: [1, 2, 1],\n  },\n\n  {\n    id: 'hood',\n    category: 'kitchen',\n    tags: ['wall'],\n    name: 'Hood',\n    thumbnail: '/items/hood/thumbnail.webp',\n    src: '/items/hood/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.52, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 1, 1.1],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'kitchen-shelf',\n    category: 'kitchen',\n    tags: ['wall', 'storage'],\n    name: 'Kitchen Shelf',\n    thumbnail: '/items/kitchen-shelf/thumbnail.webp',\n    src: '/items/kitchen-shelf/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.52, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 1, 1.1],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'kitchen-counter',\n    category: 'kitchen',\n    tags: ['floor', 'large', 'storage'],\n    name: 'Kitchen Counter',\n    thumbnail: '/items/kitchen-counter/thumbnail.webp',\n    src: '/items/kitchen-counter/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.8, 1],\n    surface: {\n      height: 0.75,\n    },\n  },\n\n  {\n    id: 'kitchen-cabinet',\n    category: 'kitchen',\n    tags: ['floor', 'large', 'storage'],\n    name: 'Kitchen Cabinet',\n    thumbnail: '/items/kitchen-cabinet/thumbnail.webp',\n    src: '/items/kitchen-cabinet/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.1, 1],\n    surface: {\n      height: 1.1,\n    },\n  },\n\n  {\n    id: 'kitchen',\n    category: 'kitchen',\n    tags: ['floor', 'large'],\n    name: 'Kitchen',\n    thumbnail: '/items/kitchen/thumbnail.webp',\n    src: '/items/kitchen/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 1.1, 1],\n  },\n\n  {\n    id: 'toilet-paper',\n    category: 'bathroom',\n    tags: ['wall', 'decor'],\n    name: 'Toilet Paper',\n    thumbnail: '/items/toilet-paper/thumbnail.webp',\n    src: '/items/toilet-paper/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.19, 0.12],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'shower-rug',\n    category: 'bathroom',\n    tags: ['floor', 'decor'],\n    name: 'Shower Rug',\n    thumbnail: '/items/shower-rug/thumbnail.webp',\n    src: '/items/shower-rug/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.1, 0.5],\n  },\n\n  {\n    id: 'laundry-bag',\n    category: 'bathroom',\n    tags: ['floor'],\n    name: 'Laundry Bag',\n    thumbnail: '/items/laundry-bag/thumbnail.webp',\n    src: '/items/laundry-bag/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.8, 0.5],\n  },\n\n  {\n    id: 'drying-rack',\n    category: 'bathroom',\n    tags: ['floor'],\n    name: 'Drying Rack',\n    thumbnail: '/items/drying-rack/thumbnail.webp',\n    src: '/items/drying-rack/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.1, 1],\n  },\n\n  {\n    id: 'washing-machine',\n    category: 'bathroom',\n    tags: ['floor', 'large', 'electronics'],\n    name: 'Washing Machine',\n    thumbnail: '/items/washing-machine/thumbnail.webp',\n    src: '/items/washing-machine/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1, 1],\n  },\n\n  {\n    id: 'toilet',\n    category: 'bathroom',\n    tags: ['floor', 'large'],\n    name: 'Toilet',\n    thumbnail: '/items/toilet/thumbnail.webp',\n    src: '/items/toilet/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.23],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.9, 1],\n  },\n\n  {\n    id: 'shower-square',\n    category: 'bathroom',\n    tags: ['floor', 'large'],\n    name: 'Squared Shower',\n    thumbnail: '/items/shower-square/thumbnail.webp',\n    src: '/items/shower-square/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.41, 0, -0.42],\n    rotation: [0, 0, 0],\n    dimensions: [1, 2, 1],\n  },\n\n  {\n    id: 'shower-angle',\n    category: 'bathroom',\n    tags: ['floor', 'large'],\n    name: 'Angle Shower',\n    thumbnail: '/items/shower-angle/thumbnail.webp',\n    src: '/items/shower-angle/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.41, 0, -0.42],\n    rotation: [0, 0, 0],\n    dimensions: [1, 2, 1],\n  },\n\n  {\n    id: 'bathtub',\n    category: 'bathroom',\n    tags: ['floor', 'large'],\n    name: 'Bathtub',\n    thumbnail: '/items/bathtub/thumbnail.webp',\n    src: '/items/bathtub/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 0.8, 1.5],\n  },\n\n  {\n    id: 'bathroom-sink',\n    category: 'bathroom',\n    tags: ['floor', 'large'],\n    name: 'Bathroom Sink',\n    thumbnail: '/items/bathroom-sink/thumbnail.webp',\n    src: '/items/bathroom-sink/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.11, 0, 0.02],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1, 1.5],\n  },\n\n  {\n    id: 'ceiling-fan',\n    category: 'appliance',\n    tags: ['ceiling', 'climate'],\n    name: 'Ceiling fan',\n    thumbnail: '/items/ceiling-fan/thumbnail.webp',\n    src: '/items/ceiling-fan/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.12, 0.49, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.5, 1.5],\n    attachTo: 'ceiling',\n    interactive: {\n      effects: [\n        {\n          kind: 'animation',\n          clips: {\n            on: 'On',\n          },\n        },\n      ],\n      controls: [\n        {\n          kind: 'toggle',\n        },\n      ],\n    },\n  },\n\n  {\n    id: 'electric-panel',\n    category: 'appliance',\n    tags: ['wall', 'electrical'],\n    name: 'Electric Panel',\n    thumbnail: '/items/electric-panel/thumbnail.webp',\n    src: '/items/electric-panel/model.glb',\n    scale: [0.61, 0.74, 0.7],\n    offset: [0, 0, 0.06],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1, 0.3],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'sprinkler',\n    category: 'appliance',\n    tags: ['ceiling', 'safety'],\n    name: 'Sprinkler',\n    thumbnail: '/items/sprinkler/thumbnail.webp',\n    src: '/items/sprinkler/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.45, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n    attachTo: 'ceiling',\n  },\n\n  {\n    id: 'smoke-detector',\n    category: 'appliance',\n    tags: ['ceiling', 'safety'],\n    name: 'Smoke Detector',\n    thumbnail: '/items/smoke-detector/thumbnail.webp',\n    src: '/items/smoke-detector/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.49, 0],\n    rotation: [Math.PI, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n    attachTo: 'ceiling',\n  },\n\n  {\n    id: 'fire-detector',\n    category: 'appliance',\n    tags: ['wall', 'safety'],\n    name: 'Fire Detector',\n    thumbnail: '/items/fire-detector/thumbnail.webp',\n    src: '/items/fire-detector/model.glb',\n    scale: [0.9, 1.4, 0.7],\n    offset: [0.02, 0.05, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.3],\n    attachTo: 'wall',\n  },\n\n  {\n    id: 'exit-sign',\n    category: 'appliance',\n    tags: ['wall', 'safety'],\n    name: 'Exit Sign',\n    thumbnail: '/items/exit-sign/thumbnail.webp',\n    src: '/items/exit-sign/model.glb',\n    scale: [0.6, 0.5, 0.7],\n    offset: [0, 0.04, 0.05],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.5, 0.3],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'hydrant',\n    category: 'appliance',\n    tags: ['floor', 'safety'],\n    name: 'Hydrant',\n    thumbnail: '/items/hydrant/thumbnail.webp',\n    src: '/items/hydrant/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.9, 1],\n  },\n\n  {\n    id: 'alarm-keypad',\n    category: 'appliance',\n    tags: ['wall', 'safety', 'electrical'],\n    name: 'Alarm Keypad',\n    thumbnail: '/items/alarm-keypad/thumbnail.webp',\n    src: '/items/alarm-keypad/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.1, 0.5],\n  },\n\n  {\n    id: 'thermostat',\n    category: 'appliance',\n    tags: ['wall', 'climate', 'electrical'],\n    name: 'Thermostat',\n    thumbnail: '/items/thermostat/thumbnail.webp',\n    src: '/items/thermostat/model.glb',\n    scale: [2.08, 2.1, 2.59],\n    offset: [0, 0, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.1],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'air-conditioning',\n    category: 'appliance',\n    tags: ['wall', 'climate'],\n    name: 'Air Conditioning',\n    thumbnail: '/items/air-conditioning/thumbnail.webp',\n    src: '/items/air-conditioning/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.37, 0.21],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1, 0.9],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'ac-block',\n    category: 'appliance',\n    tags: ['floor', 'climate'],\n    name: 'AC block',\n    thumbnail: '/items/ac-block/thumbnail.webp',\n    src: '/items/ac-block/model.glb',\n    scale: [0.79, 0.79, 0.79],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.1, 1, 1.1],\n  },\n\n  {\n    id: 'toaster',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Toaster',\n    thumbnail: '/items/toaster/thumbnail.webp',\n    src: '/items/toaster/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'sewing-machine',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Sewing Machine',\n    thumbnail: '/items/sewing-machine/thumbnail.webp',\n    src: '/items/sewing-machine/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.7, 0.5],\n  },\n\n  {\n    id: 'kettle',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Kettle',\n    thumbnail: '/items/kettle/thumbnail.webp',\n    src: '/items/kettle/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'iron',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Iron',\n    thumbnail: '/items/iron/thumbnail.webp',\n    src: '/items/iron/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'coffee-machine',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Coffee Machine',\n    thumbnail: '/items/coffee-machine/thumbnail.webp',\n    src: '/items/coffee-machine/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.03],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'television',\n    category: 'appliance',\n    tags: ['floor', 'electronics'],\n    name: 'Television',\n    thumbnail: '/items/television/thumbnail.webp',\n    src: '/items/television/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.1, 0.5],\n  },\n\n  {\n    id: 'computer',\n    category: 'appliance',\n    tags: ['countertop', 'electronics'],\n    name: 'Computer',\n    thumbnail: '/items/computer/thumbnail.webp',\n    src: '/items/computer/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.01, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.5, 0.5],\n  },\n\n  {\n    id: 'stereo-speaker',\n    category: 'appliance',\n    tags: ['floor', 'electronics'],\n    name: 'Stereo Speaker',\n    thumbnail: '/items/stereo-speaker/thumbnail.webp',\n    src: '/items/stereo-speaker/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.02, 0, -0.01],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1.1, 0.5],\n  },\n\n  {\n    id: 'threadmill',\n    category: 'furniture',\n    tags: ['floor', 'fitness'],\n    name: 'Threadmill',\n    thumbnail: '/items/threadmill/thumbnail.webp',\n    src: '/items/threadmill/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 1.5, 1],\n  },\n\n  {\n    id: 'barbell-stand',\n    category: 'furniture',\n    tags: ['floor', 'fitness'],\n    name: 'Barbell Stand',\n    thumbnail: '/items/barbell-stand/thumbnail.webp',\n    src: '/items/barbell-stand/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 1.3, 2],\n  },\n\n  {\n    id: 'barbell',\n    category: 'furniture',\n    tags: ['floor', 'fitness'],\n    name: 'Barbell',\n    thumbnail: '/items/barbell/thumbnail.webp',\n    src: '/items/barbell/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.4, 2],\n  },\n\n  {\n    id: 'toy',\n    category: 'furniture',\n    tags: ['floor', 'kids', 'decor'],\n    name: 'Toy',\n    thumbnail: '/items/toy/thumbnail.webp',\n    src: '/items/toy/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n  },\n\n  {\n    id: 'car-toy',\n    category: 'furniture',\n    tags: ['floor', 'kids', 'decor'],\n    name: 'Car Toy',\n    thumbnail: '/items/car-toy/thumbnail.webp',\n    src: '/items/car-toy/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.4, 1],\n  },\n\n  {\n    id: 'easel',\n    category: 'furniture',\n    tags: ['floor', 'decor'],\n    name: 'Easel',\n    thumbnail: '/items/easel/thumbnail.webp',\n    src: '/items/easel/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 2.3, 1],\n  },\n\n  {\n    id: 'pool-table',\n    category: 'furniture',\n    tags: ['floor', 'leisure'],\n    name: 'Pool table',\n    thumbnail: '/items/pool-table/thumbnail.webp',\n    src: '/items/pool-table/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 1, 4],\n  },\n\n  {\n    id: 'guitar',\n    category: 'furniture',\n    tags: ['floor', 'decor'],\n    name: 'Guitar',\n    thumbnail: '/items/guitar/thumbnail.webp',\n    src: '/items/guitar/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.32, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1.2, 0.5],\n  },\n\n  {\n    id: 'piano',\n    category: 'furniture',\n    tags: ['floor', 'decor'],\n    name: 'Piano',\n    thumbnail: '/items/piano/thumbnail.webp',\n    src: '/items/piano/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.5, 1],\n  },\n\n  {\n    id: 'round-carpet',\n    category: 'furniture',\n    tags: ['floor', 'decor'],\n    name: 'Round Carpet',\n    thumbnail: '/items/round-carpet/thumbnail.webp',\n    src: '/items/round-carpet/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 0.1, 2.5],\n  },\n\n  {\n    id: 'rectangular-carpet',\n    category: 'furniture',\n    tags: ['floor', 'decor'],\n    name: 'Rectangular Carpet',\n    thumbnail: '/items/rectangular-carpet/thumbnail.webp',\n    src: '/items/rectangular-carpet/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [3, 0.1, 2],\n  },\n\n  {\n    id: 'cactus',\n    category: 'furniture',\n    tags: ['floor', 'decor', 'vegetation'],\n    name: 'Cactus',\n    thumbnail: '/items/cactus/thumbnail.webp',\n    src: '/items/cactus/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.4, 0.5],\n  },\n\n  {\n    id: 'small-indoor-plant',\n    category: 'furniture',\n    tags: ['countertop', 'decor', 'vegetation'],\n    name: 'Small Plant',\n    thumbnail: '/items/small-indoor-plant/thumbnail.webp',\n    src: '/items/small-indoor-plant/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.01, 0, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.7, 0.5],\n  },\n\n  {\n    id: 'indoor-plant',\n    category: 'furniture',\n    tags: ['floor', 'decor', 'vegetation'],\n    name: 'Indoor Plant',\n    thumbnail: '/items/indoor-plant/thumbnail.webp',\n    src: '/items/indoor-plant/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.05, 0, 0.07],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.7, 1],\n  },\n\n  {\n    id: 'ironing-board',\n    category: 'furniture',\n    tags: ['floor'],\n    name: 'Ironing Board',\n    thumbnail: '/items/ironing-board/thumbnail.webp',\n    src: '/items/ironing-board/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 1, 1],\n  },\n\n  {\n    id: 'coat-rack',\n    category: 'furniture',\n    tags: ['floor', 'storage'],\n    name: 'Coat Rack',\n    thumbnail: '/items/coat-rack/thumbnail.webp',\n    src: '/items/coat-rack/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1.8, 0.5],\n  },\n\n  {\n    id: 'trash-bin',\n    category: 'furniture',\n    tags: ['floor'],\n    name: 'Trash Bin',\n    thumbnail: '/items/trash-bin/thumbnail.webp',\n    src: '/items/trash-bin/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.6, 0.5],\n  },\n\n  {\n    id: 'round-mirror',\n    category: 'furniture',\n    tags: ['wall', 'decor'],\n    name: 'Rounded Mirror',\n    thumbnail: '/items/round-mirror/thumbnail.webp',\n    src: '/items/round-mirror/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.32, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1, 0.1],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'picture',\n    category: 'furniture',\n    tags: ['wall', 'decor'],\n    name: 'Picture',\n    thumbnail: '/items/picture/thumbnail.webp',\n    src: '/items/picture/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.02, 0.45, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1, 0.2],\n    attachTo: 'wall-side',\n  },\n\n  {\n    id: 'books',\n    category: 'furniture',\n    tags: ['countertop', 'decor'],\n    name: 'Books',\n    thumbnail: '/items/books/thumbnail.webp',\n    src: '/items/books/model.glb',\n    scale: [1, 1, 1],\n    offset: [-0.08, 0, 0.02],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.3, 0.5],\n  },\n\n  {\n    id: 'column',\n    category: 'furniture',\n    tags: ['floor', 'structure'],\n    name: 'Column',\n    thumbnail: '/items/column/thumbnail.webp',\n    src: '/items/column/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 1.26, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 2.6, 0.5],\n  },\n\n  {\n    id: 'stairs',\n    category: 'furniture',\n    tags: ['floor', 'structure'],\n    name: 'Stairs',\n    thumbnail: '/items/stairs/thumbnail.webp',\n    src: '/items/stairs/model.glb',\n    scale: [0.61, 0.61, 0.61],\n    offset: [0, 0.03, 1.8],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 2.5, 3.5],\n  },\n\n  // {\n  //   id: \"suspended-fireplace\",\n  //   category: \"furniture\",\n  //   tags: [\"ceiling\", \"decor\"],\n  //   name: \"Suspended Fireplace\",\n  //   thumbnail: \"/items/suspended-fireplace/thumbnail.webp\",\n  //   src: \"/items/suspended-fireplace/model.glb\",\n  //   scale: [1, 1, 1],\n  //   offset: [0, 0.45, 0],\n  //   rotation: [0, 0, 0],\n  //   dimensions: [0.5, 0.5, 0.5],\n  //   attachTo: \"ceiling\",\n  // },\n\n  {\n    id: 'tv-stand',\n    category: 'furniture',\n    tags: ['floor', 'storage'],\n    name: 'TV Stand',\n    thumbnail: '/items/tv-stand/thumbnail.webp',\n    src: '/items/tv-stand/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.21, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.4, 0.5],\n    surface: {\n      height: 0.36,\n    },\n  },\n\n  {\n    id: 'shelf',\n    category: 'furniture',\n    tags: ['wall', 'storage'],\n    name: 'Shelf',\n    thumbnail: '/items/shelf/thumbnail.webp',\n    src: '/items/shelf/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.1, 0.01],\n    rotation: [0, 0, 0],\n    dimensions: [1, 0.5, 0.7],\n    attachTo: 'wall-side',\n    surface: {\n      height: 0.12,\n    },\n  },\n\n  {\n    id: 'bookshelf',\n    category: 'furniture',\n    tags: ['floor', 'storage'],\n    name: 'Bookshelf',\n    thumbnail: '/items/bookshelf/thumbnail.webp',\n    src: '/items/bookshelf/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 2, 0.5],\n  },\n\n  {\n    id: 'ceiling-lamp',\n    category: 'furniture',\n    tags: ['ceiling', 'lighting'],\n    name: 'Ceiling Lamp',\n    thumbnail: '/items/ceiling-lamp/thumbnail.webp',\n    src: '/items/ceiling-lamp/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.98, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1, 1],\n    attachTo: 'ceiling',\n    interactive: {\n      controls: [\n        {\n          kind: 'toggle',\n        },\n        {\n          kind: 'slider',\n          label: 'Intensity',\n          min: 0,\n          max: 100,\n          unit: '%',\n          displayMode: 'dial',\n          default: 100,\n        },\n      ],\n      effects: [\n        {\n          kind: 'light',\n          intensityRange: [0, 2],\n          color: '#ffffff',\n          offset: [0, -0.5, 0],\n        },\n      ],\n    },\n  },\n  {\n    id: 'recessed-light',\n    category: 'furniture',\n    tags: ['ceiling', 'lighting'],\n    name: 'Recessed Light',\n    thumbnail: '/items/recessed-light/thumbnail.webp',\n    src: '/items/recessed-light/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0.094, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.1, 0.5],\n    attachTo: 'ceiling',\n    interactive: {\n      controls: [\n        {\n          kind: 'toggle',\n        },\n        {\n          kind: 'slider',\n          label: 'Intensity',\n          min: 0,\n          max: 100,\n          unit: '%',\n          displayMode: 'dial',\n          default: 100,\n        },\n      ],\n      effects: [\n        {\n          kind: 'light',\n          intensityRange: [0, 2],\n          color: '#ffffff',\n          offset: [0, -0.1, 0],\n        },\n      ],\n    },\n  },\n\n  {\n    id: 'floor-lamp',\n    category: 'furniture',\n    tags: ['floor', 'lighting'],\n    name: 'Floor Lamp',\n    thumbnail: '/items/floor-lamp/thumbnail.webp',\n    src: '/items/floor-lamp/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.04, 0, 0.02],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.9, 1],\n    interactive: {\n      controls: [\n        {\n          kind: 'toggle',\n        },\n        {\n          kind: 'slider',\n          label: 'Intensity',\n          min: 0,\n          max: 100,\n          unit: '%',\n          displayMode: 'dial',\n          default: 100,\n        },\n      ],\n      effects: [\n        {\n          kind: 'light',\n          intensityRange: [0, 2],\n          color: '#ffffff',\n          offset: [0, 1.4, 0],\n        },\n      ],\n    },\n  },\n\n  {\n    id: 'table-lamp',\n    category: 'furniture',\n    tags: ['countertop', 'lighting'],\n    name: 'Table Lamp',\n    thumbnail: '/items/table-lamp/thumbnail.webp',\n    src: '/items/table-lamp/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.8, 1],\n  },\n\n  {\n    id: 'closet',\n    category: 'furniture',\n    tags: ['floor', 'storage', 'bedroom'],\n    name: 'Closet',\n    thumbnail: '/items/closet/thumbnail.webp',\n    src: '/items/closet/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.01],\n    rotation: [0, 0, 0],\n    dimensions: [2, 2.5, 1],\n  },\n\n  {\n    id: 'dresser',\n    category: 'furniture',\n    tags: ['floor', 'storage', 'bedroom'],\n    name: 'Dresser',\n    thumbnail: '/items/dresser/thumbnail.webp',\n    src: '/items/dresser/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 0.8, 1],\n    surface: {\n      height: 0.8,\n    },\n  },\n\n  {\n    id: 'bunkbed',\n    category: 'furniture',\n    tags: ['floor', 'bedroom'],\n    name: 'Bunkbed',\n    thumbnail: '/items/bunkbed/thumbnail.webp',\n    src: '/items/bunkbed/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.09],\n    rotation: [0, 0, 0],\n    dimensions: [2, 1.6, 1.5],\n  },\n\n  {\n    id: 'double-bed',\n    category: 'furniture',\n    tags: ['floor', 'bedroom'],\n    name: 'Double Bed',\n    thumbnail: '/items/double-bed/thumbnail.webp',\n    src: '/items/double-bed/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.03],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.8, 2.5],\n  },\n\n  {\n    id: 'single-bed',\n    category: 'furniture',\n    tags: ['floor', 'bedroom'],\n    name: 'Single Bed',\n    thumbnail: '/items/single-bed/thumbnail.webp',\n    src: '/items/single-bed/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 0.7, 2.5],\n  },\n\n  {\n    id: 'sofa',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Sofa',\n    thumbnail: '/items/sofa/thumbnail.webp',\n    src: '/items/sofa/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0.04],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 0.8, 1.5],\n  },\n\n  {\n    id: 'lounge-chair',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Lounge Chair',\n    thumbnail: '/items/lounge-chair/thumbnail.webp',\n    src: '/items/lounge-chair/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0.09],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.1, 1.5],\n  },\n\n  {\n    id: 'stool',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Stool',\n    thumbnail: '/items/stool/thumbnail.webp',\n    src: '/items/stool/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.2, 1],\n  },\n\n  {\n    id: 'dining-chair',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Dining Chair',\n    thumbnail: '/items/dining-chair/thumbnail.webp',\n    src: '/items/dining-chair/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 1, 0.5],\n  },\n\n  {\n    id: 'office-chair',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Office Chair',\n    thumbnail: '/items/office-chair/thumbnail.webp',\n    src: '/items/office-chair/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.01, 0, 0.03],\n    rotation: [0, 0, 0],\n    dimensions: [1, 1.2, 1],\n  },\n\n  {\n    id: 'livingroom-chair',\n    category: 'furniture',\n    tags: ['floor', 'seating'],\n    name: 'Livingroom Chair',\n    thumbnail: '/items/livingroom-chair/thumbnail.webp',\n    src: '/items/livingroom-chair/model.glb',\n    scale: [1, 1, 1],\n    offset: [0.01, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [1.5, 0.8, 1.5],\n  },\n\n  {\n    id: 'bedside-table',\n    category: 'furniture',\n    tags: ['floor', 'bedroom'],\n    name: 'Bedside Table',\n    thumbnail: '/items/bedside-table/thumbnail.webp',\n    src: '/items/bedside-table/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.01],\n    rotation: [0, 0, 0],\n    dimensions: [0.5, 0.5, 0.5],\n    surface: {\n      height: 0.5,\n    },\n  },\n\n  {\n    id: 'coffee-table',\n    category: 'furniture',\n    tags: ['floor', 'table'],\n    name: 'Coffee Table',\n    thumbnail: '/items/coffee-table/thumbnail.webp',\n    src: '/items/coffee-table/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.4, 1.5],\n    surface: {\n      height: 0.3,\n    },\n  },\n\n  {\n    id: 'office-table',\n    category: 'furniture',\n    tags: ['floor', 'table'],\n    name: 'Office Table',\n    thumbnail: '/items/office-table/thumbnail.webp',\n    src: '/items/office-table/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, 0],\n    rotation: [0, 0, 0],\n    dimensions: [2, 0.8, 1],\n    surface: {\n      height: 0.75,\n    },\n  },\n\n  {\n    id: 'dining-table',\n    category: 'furniture',\n    tags: ['floor', 'table'],\n    name: 'Dining Table',\n    thumbnail: '/items/dining-table/thumbnail.webp',\n    src: '/items/dining-table/model.glb',\n    scale: [1, 1, 1],\n    offset: [0, 0, -0.01],\n    rotation: [0, 0, 0],\n    dimensions: [2.5, 0.8, 1],\n    surface: {\n      height: 0.8,\n    },\n  },\n]\n"
  },
  {
    "path": "packages/editor/src/components/ui/item-catalog/item-catalog.tsx",
    "content": "'use client'\n\nimport type { AssetInput } from '@pascal-app/core'\nimport { resolveCdnUrl } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useEffect, useState } from 'react'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from './../../../components/ui/primitives/tooltip'\nimport { cn } from './../../../lib/utils'\nimport useEditor, { type CatalogCategory } from './../../../store/use-editor'\nimport { CATALOG_ITEMS } from './catalog-items'\n\nconst PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])\n\nexport function ItemCatalog({ category }: { category: CatalogCategory }) {\n  const selectedItem = useEditor((state) => state.selectedItem)\n  const setSelectedItem = useEditor((state) => state.setSelectedItem)\n  const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)\n  const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)\n\n  const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)\n\n  // Collect tags available in this category\n  const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))\n  const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))\n  const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))\n  const hasFilters = allTags.length > 1\n\n  // Count items for a placement tag given the current functional filter\n  const placementCount = (tag: string | null) =>\n    categoryItems.filter((item) => {\n      const tags = item.tags ?? []\n      if (tag !== null && !tags.includes(tag)) return false\n      if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false\n      return true\n    }).length\n\n  // Count items for a functional tag given the current placement filter\n  const functionalCount = (tag: string) =>\n    categoryItems.filter((item) => {\n      const tags = item.tags ?? []\n      if (!tags.includes(tag)) return false\n      if (activePlacementTag && !tags.includes(activePlacementTag)) return false\n      return true\n    }).length\n\n  const filteredItems = categoryItems.filter((item) => {\n    const tags = item.tags ?? []\n    if (activePlacementTag && !tags.includes(activePlacementTag)) return false\n    if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false\n    return true\n  })\n\n  // Auto-select first item if current selection is not in the filtered list\n  useEffect(() => {\n    const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)\n    if (!isCurrentItemInCategory && filteredItems.length > 0) {\n      setSelectedItem(filteredItems[0] as AssetInput)\n    }\n  }, [filteredItems, selectedItem?.src, setSelectedItem])\n\n  // Get attachment icon based on attachTo type\n  const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {\n    if (attachTo === 'wall' || attachTo === 'wall-side') {\n      return '/icons/wall.png'\n    }\n    if (attachTo === 'ceiling') {\n      return '/icons/ceiling.png'\n    }\n    return null\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {/* Filter chips */}\n      {hasFilters && (\n        <div className=\"flex flex-col gap-1.5\">\n          {/* Placement row */}\n          {placementTags.length > 0 && (\n            <div className=\"flex flex-wrap gap-1\">\n              <button\n                className={cn(\n                  'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',\n                  activePlacementTag === null\n                    ? 'bg-blue-500 text-white'\n                    : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',\n                )}\n                onClick={() => setActivePlacementTag(null)}\n                type=\"button\"\n              >\n                All\n              </button>\n              {placementTags.map((tag) => {\n                const count = placementCount(tag)\n                const isActive = activePlacementTag === tag\n                const isEmpty = count === 0 && !isActive\n                return (\n                  <button\n                    className={cn(\n                      'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',\n                      isActive\n                        ? 'bg-blue-500 text-white'\n                        : isEmpty\n                          ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'\n                          : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',\n                    )}\n                    disabled={isEmpty}\n                    key={tag}\n                    onClick={() => setActivePlacementTag(isActive ? null : tag)}\n                    type=\"button\"\n                  >\n                    {tag}\n                    <span\n                      className={cn(\n                        'text-[10px]',\n                        isActive ? 'text-blue-200' : isEmpty ? 'text-zinc-600' : 'text-blue-500/70',\n                      )}\n                    >\n                      {count}\n                    </span>\n                  </button>\n                )\n              })}\n            </div>\n          )}\n\n          {/* Functional row */}\n          {functionalTags.length > 0 && (\n            <div className=\"flex flex-wrap gap-1\">\n              {functionalTags.map((tag) => {\n                const count = functionalCount(tag)\n                const isActive = activeFunctionalTag === tag\n                const isEmpty = count === 0 && !isActive\n                return (\n                  <button\n                    className={cn(\n                      'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',\n                      isActive\n                        ? 'bg-violet-500 text-white'\n                        : isEmpty\n                          ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'\n                          : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',\n                    )}\n                    disabled={isEmpty}\n                    key={tag}\n                    onClick={() => setActiveFunctionalTag(isActive ? null : tag)}\n                    type=\"button\"\n                  >\n                    {tag}\n                    <span\n                      className={cn(\n                        'text-[10px]',\n                        isActive\n                          ? 'text-violet-200'\n                          : isEmpty\n                            ? 'text-zinc-600'\n                            : 'text-zinc-500/70',\n                      )}\n                    >\n                      {count}\n                    </span>\n                  </button>\n                )\n              })}\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Items */}\n      <div className=\"-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2\">\n        {filteredItems.map((item, index) => {\n          const isSelected = selectedItem?.src === item?.src\n          const attachmentIcon = getAttachmentIcon(item?.attachTo)\n          return (\n            <Tooltip key={index}>\n              <TooltipTrigger asChild>\n                <button\n                  className={cn(\n                    'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',\n                    isSelected && 'ring-2 ring-primary-foreground',\n                  )}\n                  onClick={() => setSelectedItem(item)}\n                  type=\"button\"\n                >\n                  <Image\n                    alt={item.name}\n                    className=\"rounded-lg object-cover\"\n                    fill\n                    loading=\"eager\"\n                    sizes=\"56px\"\n                    src={resolveCdnUrl(item.thumbnail) || ''}\n                  />\n                  {attachmentIcon && (\n                    <div className=\"absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60\">\n                      <Image\n                        alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}\n                        className=\"h-4 w-4\"\n                        height={16}\n                        src={attachmentIcon}\n                        width={16}\n                      />\n                    </div>\n                  )}\n                </button>\n              </TooltipTrigger>\n              <TooltipContent className=\"text-xs\" side=\"top\">\n                {item.name}\n              </TooltipContent>\n            </Tooltip>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/ceiling-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Edit, Plus, Trash2 } from 'lucide-react'\nimport { useCallback, useEffect } from 'react'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function CeilingPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const editingHole = useEditor((s) => s.editingHole)\n  const setEditingHole = useEditor((s) => s.setEditingHole)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId\n    ? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)\n    : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<CeilingNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n    setEditingHole(null)\n  }, [setSelection, setEditingHole])\n\n  useEffect(() => {\n    if (!node) {\n      setEditingHole(null)\n    }\n  }, [node, setEditingHole])\n\n  useEffect(() => {\n    return () => {\n      setEditingHole(null)\n    }\n  }, [setEditingHole])\n\n  const handleAddHole = useCallback(() => {\n    if (!(node && selectedId)) return\n\n    const polygon = node.polygon\n    let cx = 0\n    let cz = 0\n    for (const [x, z] of polygon) {\n      cx += x\n      cz += z\n    }\n    cx /= polygon.length\n    cz /= polygon.length\n\n    const holeSize = 0.5\n    const newHole: Array<[number, number]> = [\n      [cx - holeSize, cz - holeSize],\n      [cx + holeSize, cz - holeSize],\n      [cx + holeSize, cz + holeSize],\n      [cx - holeSize, cz + holeSize],\n    ]\n    const currentHoles = node?.holes || []\n    handleUpdate({ holes: [...currentHoles, newHole] })\n    setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })\n  }, [node, selectedId, handleUpdate, setEditingHole])\n\n  const handleEditHole = useCallback(\n    (index: number) => {\n      if (!selectedId) return\n      setEditingHole({ nodeId: selectedId, holeIndex: index })\n    },\n    [selectedId, setEditingHole],\n  )\n\n  const handleDeleteHole = useCallback(\n    (index: number) => {\n      if (!selectedId) return\n      const currentHoles = node?.holes || []\n      const newHoles = currentHoles.filter((_, i) => i !== index)\n      handleUpdate({ holes: newHoles })\n      if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {\n        setEditingHole(null)\n      }\n    },\n    [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],\n  )\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null\n\n  const calculateArea = (polygon: Array<[number, number]>): number => {\n    if (polygon.length < 3) return 0\n    let area = 0\n    const n = polygon.length\n    for (let i = 0; i < n; i++) {\n      const j = (i + 1) % n\n      const pi = polygon[i]\n      const pj = polygon[j]\n      if (pi && pj) {\n        area += pi[0] * pj[1]\n        area -= pj[0] * pi[1]\n      }\n    }\n    return Math.abs(area) / 2\n  }\n\n  const area = calculateArea(node.polygon)\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/ceiling.png\"\n      onClose={handleClose}\n      title={node.name || 'Ceiling'}\n      width={320}\n    >\n      <PanelSection title=\"Height\">\n        <SliderControl\n          label=\"Height\"\n          max={6}\n          min={0}\n          onChange={(v) => handleUpdate({ height: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.height * 1000) / 1000}\n        />\n\n        <div className=\"mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1\">\n          <ActionButton label=\"Low (2.4m)\" onClick={() => handleUpdate({ height: 2.4 })} />\n          <ActionButton label=\"Standard (2.5m)\" onClick={() => handleUpdate({ height: 2.5 })} />\n          <ActionButton label=\"High (3.0m)\" onClick={() => handleUpdate({ height: 3.0 })} />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Info\">\n        <div className=\"flex items-center justify-between px-2 py-1 text-muted-foreground text-sm\">\n          <span>Area</span>\n          <span className=\"font-mono text-white\">{area.toFixed(2)} m²</span>\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Holes\">\n        {node.holes && node.holes.length > 0 ? (\n          <div className=\"flex flex-col gap-1 pb-2\">\n            {node.holes.map((hole, index) => {\n              const holeArea = calculateArea(hole)\n              const isEditing =\n                editingHole?.nodeId === selectedId && editingHole?.holeIndex === index\n              return (\n                <div\n                  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${\n                    isEditing\n                      ? 'border-primary/50 bg-primary/10'\n                      : 'border-transparent hover:bg-accent/30'\n                  }`}\n                  key={index}\n                >\n                  <div className=\"min-w-0 flex-1\">\n                    <p\n                      className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}\n                    >\n                      Hole {index + 1} {isEditing && '(Editing)'}\n                    </p>\n                    <p className=\"text-[10px] text-muted-foreground\">\n                      {holeArea.toFixed(2)} m² · {hole.length} pts\n                    </p>\n                  </div>\n                  <div className=\"flex items-center gap-1\">\n                    {isEditing ? (\n                      <ActionButton\n                        className=\"h-7 bg-primary text-primary-foreground hover:bg-primary/90\"\n                        label=\"Done\"\n                        onClick={() => setEditingHole(null)}\n                      />\n                    ) : (\n                      <>\n                        <button\n                          className=\"flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground\"\n                          onClick={() => handleEditHole(index)}\n                          type=\"button\"\n                        >\n                          <Edit className=\"h-3.5 w-3.5\" />\n                        </button>\n                        <button\n                          className=\"flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300\"\n                          onClick={() => handleDeleteHole(index)}\n                          type=\"button\"\n                        >\n                          <Trash2 className=\"h-3.5 w-3.5\" />\n                        </button>\n                      </>\n                    )}\n                  </div>\n                </div>\n              )\n            })}\n          </div>\n        ) : (\n          <div className=\"px-2 py-3 text-center text-muted-foreground text-xs\">No holes</div>\n        )}\n\n        <div className=\"px-1 pt-1 pb-1\">\n          <ActionButton\n            className=\"w-full\"\n            disabled={editingHole?.nodeId === selectedId}\n            icon={<Plus className=\"h-3.5 w-3.5\" />}\n            label=\"Add Hole\"\n            onClick={handleAddHole}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/collections/collections-popover.tsx",
    "content": "'use client'\n\nimport type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'\nimport { useScene } from '@pascal-app/core'\nimport {\n  Check,\n  ChevronDown,\n  ChevronRight,\n  Layers,\n  MoreHorizontal,\n  Pencil,\n  Plus,\n  Trash2,\n  X,\n} from 'lucide-react'\nimport { useState } from 'react'\nimport { ColorDot } from '../../../../components/ui/primitives/color-dot'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '../../../../components/ui/primitives/dropdown-menu'\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '../../../../components/ui/primitives/popover'\nimport { cn } from '../../../../lib/utils'\n\ninterface CollectionsPopoverProps {\n  nodeId: AnyNodeId\n  collectionIds?: CollectionId[]\n  children: React.ReactNode\n}\n\nexport function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) {\n  const collections = useScene((s) => s.collections)\n  const nodes = useScene((s) => s.nodes)\n  const createCollection = useScene((s) => s.createCollection)\n  const deleteCollection = useScene((s) => s.deleteCollection)\n  const updateCollection = useScene((s) => s.updateCollection)\n  const addToCollection = useScene((s) => s.addToCollection)\n  const removeFromCollection = useScene((s) => s.removeFromCollection)\n\n  const [open, setOpen] = useState(false)\n  const [showCreateInput, setShowCreateInput] = useState(false)\n  const [createName, setCreateName] = useState('')\n\n  const [renamingId, setRenamingId] = useState<CollectionId | null>(null)\n  const [renameValue, setRenameValue] = useState('')\n  const [renameColor, setRenameColor] = useState('')\n\n  const [deletingId, setDeletingId] = useState<CollectionId | null>(null)\n  const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(new Set())\n\n  const memberIds = collectionIds ?? []\n  const allCollections = Object.values(collections)\n\n  const handleCreate = () => {\n    if (!createName.trim()) return\n    createCollection(createName.trim(), [nodeId])\n    setCreateName('')\n    setShowCreateInput(false)\n  }\n\n  const handleRenameConfirm = (id: CollectionId) => {\n    if (!renameValue.trim()) return\n    updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined })\n    setRenamingId(null)\n  }\n\n  const toggleMembership = (collectionId: CollectionId) => {\n    if (memberIds.includes(collectionId)) {\n      removeFromCollection(collectionId, nodeId)\n    } else {\n      addToCollection(collectionId, nodeId)\n    }\n  }\n\n  const toggleExpand = (collectionId: CollectionId) => {\n    setExpandedIds((prev) => {\n      const next = new Set(prev)\n      if (next.has(collectionId)) next.delete(collectionId)\n      else next.add(collectionId)\n      return next\n    })\n  }\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        className=\"w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl\"\n        side=\"left\"\n        sideOffset={8}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between border-border/50 border-b px-3 py-2.5\">\n          <div className=\"flex items-center gap-1.5\">\n            <Layers className=\"h-3.5 w-3.5 text-muted-foreground\" />\n            <span className=\"font-semibold text-foreground text-xs tracking-tight\">\n              Collections\n            </span>\n          </div>\n          <button\n            className=\"flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground\"\n            onClick={() => {\n              setShowCreateInput((v) => !v)\n              setCreateName('')\n            }}\n            type=\"button\"\n          >\n            <Plus className=\"h-3 w-3\" />\n            New\n          </button>\n        </div>\n\n        {/* Create input */}\n        {showCreateInput && (\n          <div className=\"flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2\">\n            <input\n              autoFocus\n              className=\"min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30\"\n              onChange={(e) => setCreateName(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') handleCreate()\n                if (e.key === 'Escape') {\n                  setShowCreateInput(false)\n                  setCreateName('')\n                }\n              }}\n              placeholder=\"Collection name…\"\n              value={createName}\n            />\n            <button\n              className=\"flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30 disabled:opacity-40\"\n              disabled={!createName.trim()}\n              onClick={handleCreate}\n              type=\"button\"\n            >\n              <Check className=\"h-3.5 w-3.5\" />\n            </button>\n            <button\n              className=\"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10\"\n              onClick={() => {\n                setShowCreateInput(false)\n                setCreateName('')\n              }}\n              type=\"button\"\n            >\n              <X className=\"h-3.5 w-3.5\" />\n            </button>\n          </div>\n        )}\n\n        {/* Collections list */}\n        <div className=\"no-scrollbar max-h-72 overflow-y-auto\">\n          {allCollections.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center gap-2 px-4 py-8 text-center\">\n              <Layers className=\"h-6 w-6 text-muted-foreground/40\" />\n              <p className=\"text-muted-foreground text-xs\">\n                No collections yet. Create one to group items together.\n              </p>\n            </div>\n          ) : (\n            <ul className=\"divide-y divide-border/30\">\n              {allCollections.map((collection) => {\n                const isIn = memberIds.includes(collection.id)\n                const isExpanded = expandedIds.has(collection.id)\n                const isRenaming = renamingId === collection.id\n                const isDeleting = deletingId === collection.id\n\n                if (isDeleting) {\n                  return (\n                    <li\n                      className=\"flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5\"\n                      key={collection.id}\n                    >\n                      <span className=\"truncate text-foreground/80 text-xs\">\n                        Delete \"{collection.name}\"?\n                      </span>\n                      <div className=\"flex shrink-0 items-center gap-1\">\n                        <button\n                          className=\"rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30\"\n                          onClick={() => {\n                            deleteCollection(collection.id)\n                            setDeletingId(null)\n                          }}\n                          type=\"button\"\n                        >\n                          Delete\n                        </button>\n                        <button\n                          className=\"rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10\"\n                          onClick={() => setDeletingId(null)}\n                          type=\"button\"\n                        >\n                          Cancel\n                        </button>\n                      </div>\n                    </li>\n                  )\n                }\n\n                if (isRenaming) {\n                  return (\n                    <li className=\"flex items-center gap-1.5 px-3 py-2\" key={collection.id}>\n                      <ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />\n                      <input\n                        autoFocus\n                        className=\"min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/30\"\n                        onChange={(e) => setRenameValue(e.target.value)}\n                        onKeyDown={(e) => {\n                          if (e.key === 'Enter') handleRenameConfirm(collection.id)\n                          if (e.key === 'Escape') setRenamingId(null)\n                        }}\n                        value={renameValue}\n                      />\n                      <button\n                        className=\"flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30\"\n                        onClick={() => handleRenameConfirm(collection.id)}\n                        type=\"button\"\n                      >\n                        <Check className=\"h-3.5 w-3.5\" />\n                      </button>\n                      <button\n                        className=\"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10\"\n                        onClick={() => setRenamingId(null)}\n                        type=\"button\"\n                      >\n                        <X className=\"h-3.5 w-3.5\" />\n                      </button>\n                    </li>\n                  )\n                }\n\n                return (\n                  <li key={collection.id}>\n                    <div className=\"group flex items-center gap-2 px-3 py-2 transition-colors hover:bg-white/5\">\n                      {/* Color dot — click to pick color */}\n                      <ColorDot\n                        color={collection.color ?? '#6366f1'}\n                        onChange={(c) => updateCollection(collection.id, { color: c })}\n                      />\n\n                      {/* Name + count — clicking toggles membership */}\n                      <button\n                        className=\"flex min-w-0 flex-1 items-center gap-1.5 text-left\"\n                        onClick={() => toggleMembership(collection.id)}\n                        type=\"button\"\n                      >\n                        <span\n                          className={cn(\n                            'truncate font-medium text-xs',\n                            isIn ? 'text-foreground' : 'text-muted-foreground',\n                          )}\n                        >\n                          {collection.name}\n                        </span>\n                        <span className=\"shrink-0 text-[10px] text-muted-foreground/60\">\n                          {collection.nodeIds.length}\n                        </span>\n                      </button>\n\n                      {/* Membership check */}\n                      <div\n                        className={cn(\n                          'pointer-events-none flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',\n                          isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',\n                        )}\n                      >\n                        {isIn && <Check className=\"h-2.5 w-2.5\" />}\n                      </div>\n\n                      {/* Expand toggle (only if has members) */}\n                      {collection.nodeIds.length > 0 && (\n                        <button\n                          className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground\"\n                          onClick={() => toggleExpand(collection.id)}\n                          type=\"button\"\n                        >\n                          {isExpanded ? (\n                            <ChevronDown className=\"h-3 w-3\" />\n                          ) : (\n                            <ChevronRight className=\"h-3 w-3\" />\n                          )}\n                        </button>\n                      )}\n\n                      {/* More dropdown */}\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <button\n                            className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-colors hover:bg-white/10 hover:text-foreground group-hover:opacity-100\"\n                            type=\"button\"\n                          >\n                            <MoreHorizontal className=\"h-3.5 w-3.5\" />\n                          </button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent align=\"start\" className=\"min-w-40\" side=\"left\">\n                          <DropdownMenuItem\n                            onClick={() => {\n                              setRenamingId(collection.id)\n                              setRenameValue(collection.name)\n                              setRenameColor(collection.color ?? '')\n                            }}\n                          >\n                            <Pencil className=\"h-3.5 w-3.5\" />\n                            Rename\n                          </DropdownMenuItem>\n                          <DropdownMenuItem\n                            onClick={() => setDeletingId(collection.id)}\n                            variant=\"destructive\"\n                          >\n                            <Trash2 className=\"h-3.5 w-3.5\" />\n                            Delete\n                          </DropdownMenuItem>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    </div>\n\n                    {/* Expanded member list */}\n                    {isExpanded && (\n                      <ul className=\"flex flex-col gap-0.5 pr-3 pb-1 pl-6\">\n                        {collection.nodeIds.map((nid) => {\n                          const n = nodes[nid]\n                          return (\n                            <li className=\"flex items-center gap-1.5 py-0.5\" key={nid}>\n                              <span className=\"h-1 w-1 shrink-0 rounded-full bg-muted-foreground/40\" />\n                              <span\n                                className={cn(\n                                  'truncate text-[11px]',\n                                  nid === nodeId\n                                    ? 'font-medium text-foreground'\n                                    : 'text-muted-foreground',\n                                )}\n                              >\n                                {n?.name ?? nid}\n                              </span>\n                            </li>\n                          )\n                        })}\n                      </ul>\n                    )}\n                  </li>\n                )\n              })}\n            </ul>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/door-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type AnyNodeId, type MaterialSchema, DoorNode, emitter, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { usePresetsAdapter } from '../../../contexts/presets-context'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SegmentedControl } from '../controls/segmented-control'\nimport { SliderControl } from '../controls/slider-control'\nimport { ToggleControl } from '../controls/toggle-control'\nimport { PanelWrapper } from './panel-wrapper'\nimport { PresetsPopover } from './presets/presets-popover'\n\nexport function DoorPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const deleteNode = useScene((s) => s.deleteNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const adapter = usePresetsAdapter()\n\n  const selectedId = selectedIds[0]\n  const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<DoorNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n      useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleFlip = useCallback(() => {\n    if (!node) return\n    handleUpdate({\n      side: node.side === 'front' ? 'back' : 'front',\n      rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],\n    })\n  }, [node, handleUpdate])\n\n  const handleMove = useCallback(() => {\n    if (!node) return\n    sfxEmitter.emit('sfx:item-pick')\n    setMovingNode(node)\n    setSelection({ selectedIds: [] })\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    deleteNode(selectedId as AnyNode['id'])\n    if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)\n    setSelection({ selectedIds: [] })\n  }, [selectedId, node, deleteNode, setSelection])\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n    useScene.temporal.getState().pause()\n    const cloned = structuredClone(node) as any\n    delete cloned.id\n    cloned.metadata = { ...cloned.metadata, isNew: true }\n    const duplicate = DoorNode.parse(cloned)\n    useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)\n    setMovingNode(duplicate)\n    setSelection({ selectedIds: [] })\n  }, [node, setMovingNode, setSelection])\n\n  const setSegmentHeightRatio = (segIdx: number, newVal: number) => {\n    if (!node) return\n    const numSegs = node.segments.length\n    const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)\n    const normH = node.segments.map((s) => s.heightRatio / totalH)\n    const clamped = Math.max(0.05, Math.min(0.95, newVal))\n    const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1\n    const delta = clamped - normH[segIdx]!\n    const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)\n    const newRatios = normH.map((v, i) => {\n      if (i === segIdx) return clamped\n      if (i === neighborIdx) return neighborVal\n      return v\n    })\n    const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))\n    handleUpdate({ segments: updated })\n  }\n\n  const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {\n    const seg = node?.segments[segIdx]\n    if (!seg) return\n    const normRatios = (() => {\n      const sum = seg.columnRatios.reduce((a, b) => a + b, 0)\n      return seg.columnRatios.map((r) => r / sum)\n    })()\n    const numCols = normRatios.length\n    const clamped = Math.max(0.05, Math.min(0.95, newVal))\n    const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1\n    const delta = clamped - normRatios[colIdx]!\n    const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)\n    const newRatios = normRatios.map((v, i) => {\n      if (i === colIdx) return clamped\n      if (i === neighborIdx) return neighborVal\n      return v\n    })\n    const updated = node?.segments.map((s, idx) =>\n      idx === segIdx ? { ...s, columnRatios: newRatios } : s,\n    )\n    handleUpdate({ segments: updated })\n  }\n\n  const getDoorPresetData = useCallback(() => {\n    if (!node) return null\n    return {\n      width: node.width,\n      height: node.height,\n      frameThickness: node.frameThickness,\n      frameDepth: node.frameDepth,\n      contentPadding: node.contentPadding,\n      hingesSide: node.hingesSide,\n      swingDirection: node.swingDirection,\n      threshold: node.threshold,\n      thresholdHeight: node.thresholdHeight,\n      handle: node.handle,\n      handleHeight: node.handleHeight,\n      handleSide: node.handleSide,\n      doorCloser: node.doorCloser,\n      panicBar: node.panicBar,\n      panicBarHeight: node.panicBarHeight,\n      segments: node.segments,\n    }\n  }, [node])\n\n  const handleSavePreset = useCallback(\n    async (name: string) => {\n      const data = getDoorPresetData()\n      if (!(data && selectedId)) return\n      const presetId = await adapter.savePreset('door', name, data)\n      if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })\n    },\n    [getDoorPresetData, selectedId, adapter],\n  )\n\n  const handleOverwritePreset = useCallback(\n    async (id: string) => {\n      const data = getDoorPresetData()\n      if (!(data && selectedId)) return\n      await adapter.overwritePreset('door', id, data)\n      emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })\n    },\n    [getDoorPresetData, selectedId, adapter],\n  )\n\n  const handleApplyPreset = useCallback(\n    (data: Record<string, unknown>) => {\n      handleUpdate(data as Partial<DoorNode>)\n    },\n    [handleUpdate],\n  )\n\n  if (!node || node.type !== 'door' || selectedIds.length !== 1) return null\n\n  const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)\n  const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/door.png\"\n      onClose={handleClose}\n      title={node.name || 'Door'}\n      width={320}\n    >\n      {/* Presets strip */}\n      <div className=\"border-border/30 border-b px-3 pt-2.5 pb-1.5\">\n        <PresetsPopover\n          isAuthenticated={adapter.isAuthenticated}\n          onApply={handleApplyPreset}\n          onDelete={(id) => adapter.deletePreset(id)}\n          onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}\n          onOverwrite={handleOverwritePreset}\n          onRename={(id, name) => adapter.renamePreset(id, name)}\n          onSave={handleSavePreset}\n          onToggleCommunity={adapter.togglePresetCommunity}\n          tabs={adapter.tabs}\n          type=\"door\"\n        >\n          <button className=\"flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground\">\n            <BookMarked className=\"h-3.5 w-3.5 shrink-0\" />\n            <span>Presets</span>\n          </button>\n        </PresetsPopover>\n      </div>\n\n      <PanelSection title=\"Position\">\n        <SliderControl\n          label={\n            <>\n              X<sub className=\"ml-[1px] text-[11px] opacity-70\">wall</sub>\n            </>\n          }\n          max={10}\n          min={-10}\n          onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <div className=\"px-1 pt-2 pb-1\">\n          <ActionButton\n            className=\"w-full\"\n            icon={<FlipHorizontal2 className=\"h-4 w-4\" />}\n            label=\"Flip Side\"\n            onClick={handleFlip}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Dimensions\">\n        <SliderControl\n          label=\"Width\"\n          max={3}\n          min={0.5}\n          onChange={(v) => handleUpdate({ width: v })}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.width * 100) / 100}\n        />\n        <SliderControl\n          label=\"Height\"\n          max={4}\n          min={1.0}\n          onChange={(v) =>\n            handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })\n          }\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.height * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Frame\">\n        <SliderControl\n          label=\"Thickness\"\n          max={0.2}\n          min={0.01}\n          onChange={(v) => handleUpdate({ frameThickness: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.frameThickness * 1000) / 1000}\n        />\n        <SliderControl\n          label=\"Depth\"\n          max={0.3}\n          min={0.01}\n          onChange={(v) => handleUpdate({ frameDepth: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.frameDepth * 1000) / 1000}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Content Padding\">\n        <SliderControl\n          label=\"Horizontal\"\n          max={0.2}\n          min={0}\n          onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}\n          precision={3}\n          step={0.005}\n          unit=\"m\"\n          value={Math.round(node.contentPadding[0] * 1000) / 1000}\n        />\n        <SliderControl\n          label=\"Vertical\"\n          max={0.2}\n          min={0}\n          onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}\n          precision={3}\n          step={0.005}\n          unit=\"m\"\n          value={Math.round(node.contentPadding[1] * 1000) / 1000}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Swing\">\n        <div className=\"flex flex-col gap-2 px-1 pb-1\">\n          <div className=\"space-y-1\">\n            <span className=\"font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n              Hinges Side\n            </span>\n            <SegmentedControl\n              onChange={(v) => handleUpdate({ hingesSide: v })}\n              options={[\n                { label: 'Left', value: 'left' },\n                { label: 'Right', value: 'right' },\n              ]}\n              value={node.hingesSide}\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <span className=\"font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n              Direction\n            </span>\n            <SegmentedControl\n              onChange={(v) => handleUpdate({ swingDirection: v })}\n              options={[\n                { label: 'Inward', value: 'inward' },\n                { label: 'Outward', value: 'outward' },\n              ]}\n              value={node.swingDirection}\n            />\n          </div>\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Threshold\">\n        <ToggleControl\n          checked={node.threshold}\n          label=\"Enable Threshold\"\n          onChange={(checked) => handleUpdate({ threshold: checked })}\n        />\n        {node.threshold && (\n          <div className=\"mt-1 flex flex-col gap-1\">\n            <SliderControl\n              label=\"Height\"\n              max={0.1}\n              min={0.005}\n              onChange={(v) => handleUpdate({ thresholdHeight: v })}\n              precision={3}\n              step={0.005}\n              unit=\"m\"\n              value={Math.round(node.thresholdHeight * 1000) / 1000}\n            />\n          </div>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Handle\">\n        <ToggleControl\n          checked={node.handle}\n          label=\"Enable Handle\"\n          onChange={(checked) => handleUpdate({ handle: checked })}\n        />\n        {node.handle && (\n          <div className=\"mt-1 flex flex-col gap-1\">\n            <SliderControl\n              label=\"Height\"\n              max={node.height - 0.1}\n              min={0.5}\n              onChange={(v) => handleUpdate({ handleHeight: v })}\n              precision={2}\n              step={0.05}\n              unit=\"m\"\n              value={Math.round(node.handleHeight * 100) / 100}\n            />\n            <div className=\"space-y-1\">\n              <span className=\"font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n                Handle Side\n              </span>\n              <SegmentedControl\n                onChange={(v) => handleUpdate({ handleSide: v })}\n                options={[\n                  { label: 'Left', value: 'left' },\n                  { label: 'Right', value: 'right' },\n                ]}\n                value={node.handleSide}\n              />\n            </div>\n          </div>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Hardware\">\n        <ToggleControl\n          checked={node.doorCloser}\n          label=\"Door Closer\"\n          onChange={(checked) => handleUpdate({ doorCloser: checked })}\n        />\n        <ToggleControl\n          checked={node.panicBar}\n          label=\"Panic Bar\"\n          onChange={(checked) => handleUpdate({ panicBar: checked })}\n        />\n        {node.panicBar && (\n          <div className=\"mt-1 flex flex-col gap-1\">\n            <SliderControl\n              label=\"Bar Height\"\n              max={node.height - 0.1}\n              min={0.5}\n              onChange={(v) => handleUpdate({ panicBarHeight: v })}\n              precision={2}\n              step={0.05}\n              unit=\"m\"\n              value={Math.round(node.panicBarHeight * 100) / 100}\n            />\n          </div>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Segments\">\n        {node.segments.map((seg, i) => {\n          const numCols = seg.columnRatios.length\n          const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)\n          const normCols = seg.columnRatios.map((r) => r / colSum)\n          return (\n            <div className=\"mb-2 flex flex-col gap-1\" key={i}>\n              <div className=\"flex items-center justify-between pb-1\">\n                <span className=\"font-medium text-white/80 text-xs\">Segment {i + 1}</span>\n              </div>\n\n              <SegmentedControl\n                onChange={(t) => {\n                  const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))\n                  handleUpdate({ segments: updated })\n                }}\n                options={[\n                  { label: 'Panel', value: 'panel' },\n                  { label: 'Glass', value: 'glass' },\n                  { label: 'Empty', value: 'empty' },\n                ]}\n                value={seg.type}\n              />\n\n              <SliderControl\n                label=\"Height\"\n                max={95}\n                min={5}\n                onChange={(v) => setSegmentHeightRatio(i, v / 100)}\n                precision={1}\n                step={1}\n                unit=\"%\"\n                value={Math.round(normHeights[i]! * 100 * 10) / 10}\n              />\n\n              <SliderControl\n                label=\"Columns\"\n                max={8}\n                min={1}\n                onChange={(v) => {\n                  const n = Math.max(1, Math.min(8, Math.round(v)))\n                  const updated = node.segments.map((s, idx) =>\n                    idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,\n                  )\n                  handleUpdate({ segments: updated })\n                }}\n                precision={0}\n                step={1}\n                value={numCols}\n              />\n\n              {numCols > 1 && (\n                <div className=\"mt-1 border-border/50 border-t pt-1\">\n                  {normCols.map((ratio, ci) => (\n                    <SliderControl\n                      key={`c-${ci}`}\n                      label={`C${ci + 1}`}\n                      max={95}\n                      min={5}\n                      onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}\n                      precision={1}\n                      step={1}\n                      unit=\"%\"\n                      value={Math.round(ratio * 100 * 10) / 10}\n                    />\n                  ))}\n                  <SliderControl\n                    label=\"Divider\"\n                    max={0.1}\n                    min={0.005}\n                    onChange={(v) => {\n                      const updated = node.segments.map((s, idx) =>\n                        idx === i ? { ...s, dividerThickness: v } : s,\n                      )\n                      handleUpdate({ segments: updated })\n                    }}\n                    precision={3}\n                    step={0.005}\n                    unit=\"m\"\n                    value={Math.round(seg.dividerThickness * 1000) / 1000}\n                  />\n                </div>\n              )}\n\n              {seg.type === 'panel' && (\n                <div className=\"mt-1 border-border/50 border-t pt-1\">\n                  <SliderControl\n                    label=\"Inset\"\n                    max={0.1}\n                    min={0}\n                    onChange={(v) => {\n                      const updated = node.segments.map((s, idx) =>\n                        idx === i ? { ...s, panelInset: v } : s,\n                      )\n                      handleUpdate({ segments: updated })\n                    }}\n                    precision={3}\n                    step={0.005}\n                    unit=\"m\"\n                    value={Math.round(seg.panelInset * 1000) / 1000}\n                  />\n                  <SliderControl\n                    label=\"Depth\"\n                    max={0.1}\n                    min={0}\n                    onChange={(v) => {\n                      const updated = node.segments.map((s, idx) =>\n                        idx === i ? { ...s, panelDepth: v } : s,\n                      )\n                      handleUpdate({ segments: updated })\n                    }}\n                    precision={3}\n                    step={0.005}\n                    unit=\"m\"\n                    value={Math.round(seg.panelDepth * 1000) / 1000}\n                  />\n                </div>\n              )}\n            </div>\n          )\n        })}\n\n        <div className=\"flex gap-1.5 px-1 pt-1\">\n          <ActionButton\n            label=\"+ Add Segment\"\n            onClick={() => {\n              const updated = [\n                ...node.segments,\n                {\n                  type: 'panel' as const,\n                  heightRatio: 1,\n                  columnRatios: [1],\n                  dividerThickness: 0.03,\n                  panelDepth: 0.01,\n                  panelInset: 0.04,\n                },\n              ]\n              handleUpdate({ segments: updated })\n            }}\n          />\n          {node.segments.length > 1 && (\n            <ActionButton\n              className=\"text-white/60 hover:text-white\"\n              label=\"- Remove\"\n              onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}\n            />\n          )}\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={(material) => handleUpdate({ material })}\n          value={node.material}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/item-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'\nimport { useCallback, useState } from 'react'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport { cn } from '../../../lib/utils'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { CollectionsPopover } from './collections/collections-popover'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function ItemPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const deleteNode = useScene((s) => s.deleteNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined\n\n  const [uniformScale, setUniformScale] = useState(true)\n\n  const handleUpdate = useCallback(\n    (updates: Partial<ItemNode>) => {\n      if (!(selectedId && node)) return\n      updateNode(selectedId as AnyNode['id'], updates)\n\n      if (node.asset.attachTo === 'wall' && node.parentId) {\n        requestAnimationFrame(() => {\n          useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])\n        })\n      }\n    },\n    [selectedId, node, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleMove = useCallback(() => {\n    if (node) {\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(node)\n      setSelection({ selectedIds: [] })\n    }\n  }, [node, setMovingNode, setSelection])\n\n  const handleDuplicate = useCallback(() => {\n    if (!node) return\n    sfxEmitter.emit('sfx:item-pick')\n    const proto = ItemNode.parse({\n      position: [...node.position] as [number, number, number],\n      rotation: [...node.rotation] as [number, number, number],\n      name: node.name,\n      asset: node.asset,\n      parentId: node.parentId,\n      side: node.side,\n      metadata: { isNew: true },\n    })\n    setMovingNode(proto)\n    setSelection({ selectedIds: [] })\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!selectedId) return\n    sfxEmitter.emit('sfx:item-delete')\n    deleteNode(selectedId as AnyNode['id'])\n    setSelection({ selectedIds: [] })\n  }, [selectedId, deleteNode, setSelection])\n\n  if (!node || node.type !== 'item' || selectedIds.length !== 1) return null\n\n  return (\n    <PanelWrapper\n      icon={node.asset.thumbnail || '/icons/furniture.png'}\n      onClose={handleClose}\n      title={node.name || node.asset.name}\n      width={300}\n    >\n      <PanelSection title=\"Position\">\n        <SliderControl\n          label={\n            <>\n              X<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={node.position[0] + 2}\n          min={node.position[0] - 2}\n          onChange={(value) =>\n            handleUpdate({ position: [value, node.position[1], node.position[2]] })\n          }\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <SliderControl\n          label={\n            <>\n              Y<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={node.position[1] + 2}\n          min={node.position[1] - 2}\n          onChange={(value) =>\n            handleUpdate({ position: [node.position[0], value, node.position[2]] })\n          }\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <SliderControl\n          label={\n            <>\n              Z<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={node.position[2] + 2}\n          min={node.position[2] - 2}\n          onChange={(value) =>\n            handleUpdate({ position: [node.position[0], node.position[1], value] })\n          }\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Rotation\">\n        <SliderControl\n          label={\n            <>\n              Y<sub className=\"ml-[1px] text-[11px] opacity-70\">rot</sub>\n            </>\n          }\n          max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}\n          min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}\n          onChange={(degrees) => {\n            const radians = (degrees * Math.PI) / 180\n            handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation[1] * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              const currentDegrees = (node.rotation[1] * 180) / Math.PI\n              const radians = ((currentDegrees - 45) * Math.PI) / 180\n              handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })\n            }}\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              const currentDegrees = (node.rotation[1] * 180) / Math.PI\n              const radians = ((currentDegrees + 45) * Math.PI) / 180\n              handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })\n            }}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Scale\">\n        <div className=\"flex items-center justify-between px-2 pb-2\">\n          <span className=\"font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n            Uniform Scale\n          </span>\n          <button\n            className={cn(\n              'flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground',\n              uniformScale ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',\n            )}\n            onClick={() => setUniformScale((v) => !v)}\n            type=\"button\"\n          >\n            {uniformScale ? <Link className=\"h-3.5 w-3.5\" /> : <Link2Off className=\"h-3.5 w-3.5\" />}\n          </button>\n        </div>\n\n        {uniformScale ? (\n          <SliderControl\n            label={\n              <>\n                XYZ<sub className=\"ml-[1px] text-[11px] opacity-70\">scale</sub>\n              </>\n            }\n            max={10}\n            min={0.01}\n            onChange={(value) => {\n              const v = Math.max(0.01, value)\n              handleUpdate({ scale: [v, v, v] })\n            }}\n            precision={2}\n            step={0.1}\n            value={Math.round(node.scale[0] * 100) / 100}\n          />\n        ) : (\n          <>\n            <SliderControl\n              label={\n                <>\n                  X<sub className=\"ml-[1px] text-[11px] opacity-70\">scale</sub>\n                </>\n              }\n              max={10}\n              min={0.01}\n              onChange={(value) =>\n                handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })\n              }\n              precision={2}\n              step={0.1}\n              value={Math.round(node.scale[0] * 100) / 100}\n            />\n            <SliderControl\n              label={\n                <>\n                  Y<sub className=\"ml-[1px] text-[11px] opacity-70\">scale</sub>\n                </>\n              }\n              max={10}\n              min={0.01}\n              onChange={(value) =>\n                handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })\n              }\n              precision={2}\n              step={0.1}\n              value={Math.round(node.scale[1] * 100) / 100}\n            />\n            <SliderControl\n              label={\n                <>\n                  Z<sub className=\"ml-[1px] text-[11px] opacity-70\">scale</sub>\n                </>\n              }\n              max={10}\n              min={0.01}\n              onChange={(value) =>\n                handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })\n              }\n              precision={2}\n              step={0.1}\n              value={Math.round(node.scale[2] * 100) / 100}\n            />\n          </>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Info\">\n        <div className=\"flex items-center justify-between px-2 py-1 text-muted-foreground text-sm\">\n          <span>Dimensions</span>\n          {(() => {\n            const [w, h, d] = getScaledDimensions(node)\n            return (\n              <span className=\"font-mono text-white\">\n                {Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}\n              </span>\n            )\n          })()}\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Collections\">\n        <ActionGroup>\n          <CollectionsPopover\n            collectionIds={node.collectionIds}\n            nodeId={selectedId as AnyNode['id']}\n          >\n            <ActionButton label=\"Manage collections…\" />\n          </CollectionsPopover>\n        </ActionGroup>\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/panel-manager.tsx",
    "content": "'use client'\n\nimport { type AnyNodeId, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport useEditor from '../../../store/use-editor'\nimport { CeilingPanel } from './ceiling-panel'\nimport { DoorPanel } from './door-panel'\nimport { ItemPanel } from './item-panel'\nimport { ReferencePanel } from './reference-panel'\nimport { RoofPanel } from './roof-panel'\nimport { RoofSegmentPanel } from './roof-segment-panel'\nimport { SlabPanel } from './slab-panel'\nimport { StairPanel } from './stair-panel'\nimport { StairSegmentPanel } from './stair-segment-panel'\nimport { WallPanel } from './wall-panel'\nimport { WindowPanel } from './window-panel'\n\nexport function PanelManager() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)\n  const nodes = useScene((s) => s.nodes)\n\n  // Show reference panel if a reference is selected\n  if (selectedReferenceId) {\n    return <ReferencePanel />\n  }\n\n  // Show appropriate panel based on selected node type\n  if (selectedIds.length === 1) {\n    const selectedNode = selectedIds[0]\n    const node = nodes[selectedNode as AnyNodeId]\n    if (node) {\n      switch (node.type) {\n        case 'item':\n          return <ItemPanel />\n        case 'roof':\n          return <RoofPanel />\n        case 'roof-segment':\n          return <RoofSegmentPanel />\n        case 'slab':\n          return <SlabPanel />\n        case 'stair':\n          return <StairPanel />\n        case 'stair-segment':\n          return <StairSegmentPanel />\n        case 'ceiling':\n          return <CeilingPanel />\n        case 'wall':\n          return <WallPanel />\n        case 'door':\n          return <DoorPanel />\n        case 'window':\n          return <WindowPanel />\n      }\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/panel-wrapper.tsx",
    "content": "'use client'\n\nimport { ChevronLeft, RotateCcw, X } from 'lucide-react'\nimport Image from 'next/image'\nimport { cn } from '../../../lib/utils'\n\ninterface PanelWrapperProps {\n  title: string\n  icon?: string\n  onClose?: () => void\n  onReset?: () => void\n  onBack?: () => void\n  children: React.ReactNode\n  className?: string\n  width?: number | string\n}\n\nexport function PanelWrapper({\n  title,\n  icon,\n  onClose,\n  onReset,\n  onBack,\n  children,\n  className,\n  width = 320, // default width\n}: PanelWrapperProps) {\n  return (\n    <div\n      className={cn(\n        'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',\n        className,\n      )}\n      style={{ width }}\n    >\n      {/* Header */}\n      <div className=\"flex items-center justify-between border-border/50 border-b px-3 py-3\">\n        <div className=\"flex items-center gap-2\">\n          {onBack && (\n            <button\n              className=\"mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground\"\n              onClick={onBack}\n              type=\"button\"\n            >\n              <ChevronLeft className=\"h-4 w-4\" />\n            </button>\n          )}\n          {icon && (\n            <Image alt=\"\" className=\"shrink-0 object-contain\" height={16} src={icon} width={16} />\n          )}\n          <h2 className=\"truncate font-semibold text-foreground text-sm tracking-tight\">{title}</h2>\n        </div>\n\n        <div className=\"flex items-center gap-1\">\n          {onReset && (\n            <button\n              className=\"flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground\"\n              onClick={onReset}\n              type=\"button\"\n            >\n              <RotateCcw className=\"h-4 w-4\" />\n            </button>\n          )}\n          {onClose && (\n            <button\n              className=\"flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground\"\n              onClick={onClose}\n              type=\"button\"\n            >\n              <X className=\"h-4 w-4\" />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Content */}\n      <div className=\"no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto\">{children}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/presets/presets-popover.tsx",
    "content": "'use client'\n\nimport { emitter } from '@pascal-app/core'\nimport {\n  BookMarked,\n  Check,\n  Globe,\n  GlobeLock,\n  MoreHorizontal,\n  Pencil,\n  Plus,\n  Save,\n  Trash2,\n  Users,\n  X,\n} from 'lucide-react'\nimport { useCallback, useEffect, useState } from 'react'\nimport type { PresetsTab } from '../../../../contexts/presets-context'\nimport { cn } from '../../../../lib/utils'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '../../primitives/dropdown-menu'\nimport { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'\n\nexport type PresetType = 'door' | 'window'\n\nexport interface PresetData {\n  id: string\n  type: string\n  name: string\n  data: Record<string, unknown>\n  thumbnail_url: string | null\n  user_id: string | null\n  is_community: boolean\n  created_at: string\n}\n\ninterface PresetsPopoverProps {\n  type: PresetType\n  children: React.ReactNode\n  isAuthenticated?: boolean\n  tabs?: PresetsTab[]\n  onFetchPresets: (tab: PresetsTab) => Promise<PresetData[]>\n  onApply: (data: Record<string, unknown>) => void\n  onSave: (name: string) => Promise<void>\n  onOverwrite: (id: string) => Promise<void>\n  onRename: (id: string, name: string) => Promise<void>\n  onDelete: (id: string) => Promise<void>\n  onToggleCommunity?: (id: string, current: boolean) => Promise<void>\n}\n\nexport function PresetsPopover({\n  type,\n  onApply,\n  onSave,\n  onOverwrite,\n  onFetchPresets,\n  onRename,\n  onDelete,\n  onToggleCommunity,\n  children,\n  isAuthenticated = false,\n  tabs = ['community', 'mine'],\n}: PresetsPopoverProps) {\n  const defaultTab = tabs[0] ?? 'mine'\n  const [open, setOpen] = useState(false)\n  const [tab, setTab] = useState<PresetsTab>(defaultTab)\n  const [presets, setPresets] = useState<PresetData[]>([])\n  const [loading, setLoading] = useState(false)\n\n  const [showSaveInput, setShowSaveInput] = useState(false)\n  const [saveName, setSaveName] = useState('')\n  const [saving, setSaving] = useState(false)\n\n  const [renamingId, setRenamingId] = useState<string | null>(null)\n  const [renameValue, setRenameValue] = useState('')\n  const [deletingId, setDeletingId] = useState<string | null>(null)\n  const [overwrittenId, setOverwrittenId] = useState<string | null>(null)\n\n  const fetchPresets = useCallback(async () => {\n    setLoading(true)\n    try {\n      const data = await onFetchPresets(tab)\n      setPresets(data)\n    } finally {\n      setLoading(false)\n    }\n  }, [onFetchPresets, tab])\n\n  useEffect(() => {\n    if (open) fetchPresets()\n  }, [open, fetchPresets])\n\n  useEffect(() => {\n    if (!isAuthenticated && tab === 'mine') setTab(defaultTab)\n  }, [isAuthenticated, tab, defaultTab])\n\n  useEffect(() => {\n    const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {\n      setPresets((prev) =>\n        prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)),\n      )\n    }\n    emitter.on('preset:thumbnail-updated', handler)\n    return () => emitter.off('preset:thumbnail-updated', handler)\n  }, [])\n\n  const handleSaveNew = async () => {\n    if (!saveName.trim()) return\n    setSaving(true)\n    try {\n      await onSave(saveName.trim())\n      setSaveName('')\n      setShowSaveInput(false)\n      if (tab === 'mine') fetchPresets()\n      else setTab('mine')\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  const handleRename = async (id: string) => {\n    if (!renameValue.trim()) return\n    await onRename(id, renameValue.trim())\n    setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))\n    setRenamingId(null)\n  }\n\n  const handleDelete = async (id: string) => {\n    await onDelete(id)\n    setPresets((prev) => prev.filter((p) => p.id !== id))\n    setDeletingId(null)\n  }\n\n  const handleOverwrite = async (id: string) => {\n    await onOverwrite(id)\n    setOverwrittenId(id)\n    setTimeout(() => setOverwrittenId(null), 1500)\n  }\n\n  const handleToggleCommunity = async (id: string, current: boolean) => {\n    if (!onToggleCommunity) return\n    await onToggleCommunity(id, current)\n    setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))\n  }\n\n  const showTabs = tabs.length > 1\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        className=\"w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl\"\n        side=\"left\"\n        sideOffset={8}\n      >\n        <div className=\"flex items-center justify-between border-border/50 border-b px-3 py-2.5\">\n          <div className=\"flex items-center gap-1.5\">\n            <BookMarked className=\"h-3.5 w-3.5 text-muted-foreground\" />\n            <span className=\"font-semibold text-foreground text-xs tracking-tight\">\n              {type === 'door' ? 'Door' : 'Window'} Presets\n            </span>\n          </div>\n          {isAuthenticated && (\n            <button\n              className=\"flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground\"\n              onClick={() => {\n                setShowSaveInput((v) => !v)\n                setSaveName('')\n              }}\n              type=\"button\"\n            >\n              <Plus className=\"h-3 w-3\" />\n              Save new\n            </button>\n          )}\n        </div>\n\n        {showSaveInput && (\n          <div className=\"flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2\">\n            <input\n              autoFocus\n              className=\"min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30\"\n              onChange={(e) => setSaveName(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') handleSaveNew()\n                if (e.key === 'Escape') {\n                  setShowSaveInput(false)\n                  setSaveName('')\n                }\n              }}\n              placeholder=\"Preset name…\"\n              value={saveName}\n            />\n            <button\n              className=\"flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30 disabled:opacity-40\"\n              disabled={!saveName.trim() || saving}\n              onClick={handleSaveNew}\n              type=\"button\"\n            >\n              <Check className=\"h-3.5 w-3.5\" />\n            </button>\n            <button\n              className=\"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10\"\n              onClick={() => {\n                setShowSaveInput(false)\n                setSaveName('')\n              }}\n              type=\"button\"\n            >\n              <X className=\"h-3.5 w-3.5\" />\n            </button>\n          </div>\n        )}\n\n        {showTabs && (\n          <div className=\"flex border-border/50 border-b\">\n            {tabs.includes('community') && (\n              <TabButton active={tab === 'community'} onClick={() => setTab('community')}>\n                <Users className=\"h-3 w-3\" />\n                Community\n              </TabButton>\n            )}\n            {tabs.includes('mine') && (\n              <TabButton\n                active={tab === 'mine'}\n                disabled={!isAuthenticated}\n                onClick={() => {\n                  if (isAuthenticated) setTab('mine')\n                }}\n              >\n                <BookMarked className=\"h-3 w-3\" />\n                My presets\n              </TabButton>\n            )}\n          </div>\n        )}\n\n        <div className=\"no-scrollbar max-h-72 overflow-y-auto\">\n          {loading ? (\n            <div className=\"flex items-center justify-center py-8\">\n              <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground\" />\n            </div>\n          ) : presets.length === 0 ? (\n            <EmptyState isAuthenticated={isAuthenticated} tab={tab} />\n          ) : (\n            <ul className=\"divide-y divide-border/30\">\n              {presets.map((preset) => (\n                <PresetRow\n                  deletingId={deletingId}\n                  isMine={tab === 'mine'}\n                  key={preset.id}\n                  onApply={() => {\n                    onApply(preset.data)\n                    setOpen(false)\n                  }}\n                  onDeleteCancel={() => setDeletingId(null)}\n                  onDeleteConfirm={() => handleDelete(preset.id)}\n                  onDeleteRequest={() => setDeletingId(preset.id)}\n                  onOverwrite={() => handleOverwrite(preset.id)}\n                  onRenameCancel={() => setRenamingId(null)}\n                  onRenameChange={setRenameValue}\n                  onRenameConfirm={() => handleRename(preset.id)}\n                  onStartRename={() => {\n                    setRenamingId(preset.id)\n                    setRenameValue(preset.name)\n                  }}\n                  onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}\n                  overwrittenId={overwrittenId}\n                  preset={preset}\n                  renameValue={renameValue}\n                  renamingId={renamingId}\n                  showCommunityToggle={!!onToggleCommunity}\n                />\n              ))}\n            </ul>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nfunction TabButton({\n  active,\n  onClick,\n  disabled,\n  children,\n}: {\n  active: boolean\n  onClick: () => void\n  disabled?: boolean\n  children: React.ReactNode\n}) {\n  return (\n    <button\n      className={cn(\n        'flex flex-1 items-center justify-center gap-1.5 py-2 font-medium text-[11px] transition-colors',\n        active\n          ? '-mb-px border-primary border-b-2 text-foreground'\n          : 'text-muted-foreground hover:text-foreground',\n        disabled && 'cursor-not-allowed opacity-40',\n      )}\n      disabled={disabled}\n      onClick={onClick}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  )\n}\n\nfunction EmptyState({ tab, isAuthenticated }: { tab: PresetsTab; isAuthenticated: boolean }) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-2 px-4 py-8 text-center\">\n      <BookMarked className=\"h-6 w-6 text-muted-foreground/40\" />\n      <p className=\"text-muted-foreground text-xs\">\n        {tab === 'community'\n          ? 'No community presets yet.'\n          : isAuthenticated\n            ? 'No presets saved yet. Use \"Save new\" to save the current configuration.'\n            : 'Sign in to save and view your presets.'}\n      </p>\n    </div>\n  )\n}\n\ninterface PresetRowProps {\n  preset: PresetData\n  isMine: boolean\n  showCommunityToggle: boolean\n  renamingId: string | null\n  renameValue: string\n  deletingId: string | null\n  overwrittenId: string | null\n  onApply: () => void\n  onOverwrite: () => void\n  onToggleCommunity: () => void\n  onStartRename: () => void\n  onRenameChange: (v: string) => void\n  onRenameConfirm: () => void\n  onRenameCancel: () => void\n  onDeleteRequest: () => void\n  onDeleteConfirm: () => void\n  onDeleteCancel: () => void\n}\n\nfunction PresetRow({\n  preset,\n  isMine,\n  showCommunityToggle,\n  renamingId,\n  renameValue,\n  deletingId,\n  overwrittenId,\n  onApply,\n  onOverwrite,\n  onToggleCommunity,\n  onStartRename,\n  onRenameChange,\n  onRenameConfirm,\n  onRenameCancel,\n  onDeleteRequest,\n  onDeleteConfirm,\n  onDeleteCancel,\n}: PresetRowProps) {\n  const isRenaming = renamingId === preset.id\n  const isDeleting = deletingId === preset.id\n  const justOverwritten = overwrittenId === preset.id\n\n  if (isDeleting) {\n    return (\n      <li className=\"flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5\">\n        <span className=\"truncate text-foreground/80 text-xs\">Delete \"{preset.name}\"?</span>\n        <div className=\"flex shrink-0 items-center gap-1\">\n          <button\n            className=\"rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30\"\n            onClick={onDeleteConfirm}\n            type=\"button\"\n          >\n            Delete\n          </button>\n          <button\n            className=\"rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10\"\n            onClick={onDeleteCancel}\n            type=\"button\"\n          >\n            Cancel\n          </button>\n        </div>\n      </li>\n    )\n  }\n\n  if (isRenaming) {\n    return (\n      <li className=\"flex items-center gap-1.5 px-3 py-2\">\n        <input\n          autoFocus\n          className=\"min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/30\"\n          onChange={(e) => onRenameChange(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') onRenameConfirm()\n            if (e.key === 'Escape') onRenameCancel()\n          }}\n          value={renameValue}\n        />\n        <button\n          className=\"flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30\"\n          onClick={onRenameConfirm}\n          type=\"button\"\n        >\n          <Check className=\"h-3.5 w-3.5\" />\n        </button>\n        <button\n          className=\"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10\"\n          onClick={onRenameCancel}\n          type=\"button\"\n        >\n          <X className=\"h-3.5 w-3.5\" />\n        </button>\n      </li>\n    )\n  }\n\n  return (\n    <li className=\"group flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-white/5\">\n      <div className=\"h-12 w-12 shrink-0 overflow-hidden rounded-md border border-border/40 bg-white/5\">\n        {preset.thumbnail_url ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            alt={preset.name}\n            className=\"h-full w-full object-cover\"\n            src={preset.thumbnail_url}\n          />\n        ) : (\n          <div className=\"flex h-full w-full items-center justify-center\">\n            <div className=\"h-3 w-5 rounded-sm border border-muted-foreground/30\" />\n          </div>\n        )}\n      </div>\n      <button className=\"min-w-0 flex-1 text-left\" onClick={onApply} type=\"button\">\n        <span className=\"flex items-center gap-1.5\">\n          <span className=\"block truncate font-medium text-foreground text-xs group-hover:text-foreground/90\">\n            {preset.name}\n          </span>\n          {isMine && preset.is_community && (\n            <Globe className=\"h-2.5 w-2.5 shrink-0 text-muted-foreground/50\" />\n          )}\n        </span>\n        <span className=\"block text-[10px] text-muted-foreground/60\">\n          {new Date(preset.created_at).toLocaleDateString()}\n        </span>\n      </button>\n      {isMine && (\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <button\n              className={cn(\n                'flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors group-hover:opacity-100',\n                justOverwritten\n                  ? 'bg-green-500/10 text-green-400 opacity-100'\n                  : 'text-muted-foreground hover:bg-white/10 hover:text-foreground',\n              )}\n              type=\"button\"\n            >\n              {justOverwritten ? (\n                <Check className=\"h-3 w-3\" />\n              ) : (\n                <MoreHorizontal className=\"h-3.5 w-3.5\" />\n              )}\n            </button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"start\" className=\"min-w-44\" side=\"left\">\n            <DropdownMenuItem onClick={onOverwrite}>\n              <Save className=\"h-3.5 w-3.5\" />\n              Update with current\n            </DropdownMenuItem>\n            {showCommunityToggle && (\n              <DropdownMenuItem onClick={onToggleCommunity}>\n                {preset.is_community ? (\n                  <>\n                    <GlobeLock className=\"h-3.5 w-3.5\" />\n                    Remove from community\n                  </>\n                ) : (\n                  <>\n                    <Globe className=\"h-3.5 w-3.5\" />\n                    Share with community\n                  </>\n                )}\n              </DropdownMenuItem>\n            )}\n            <DropdownMenuItem onClick={onStartRename}>\n              <Pencil className=\"h-3.5 w-3.5\" />\n              Rename\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={onDeleteRequest} variant=\"destructive\">\n              <Trash2 className=\"h-3.5 w-3.5\" />\n              Delete\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      )}\n    </li>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/reference-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'\nimport { Box, Image as ImageIcon } from 'lucide-react'\nimport { useCallback } from 'react'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\ntype ReferenceNode = ScanNode | GuideNode\n\nexport function ReferencePanel() {\n  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)\n  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n\n  const node = selectedReferenceId\n    ? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)\n    : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<ReferenceNode>) => {\n      if (!selectedReferenceId) return\n      updateNode(selectedReferenceId as AnyNode['id'], updates)\n    },\n    [selectedReferenceId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelectedReferenceId(null)\n  }, [setSelectedReferenceId])\n\n  if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null\n\n  const isScan = node.type === 'scan'\n\n  return (\n    <PanelWrapper\n      icon={isScan ? undefined : undefined}\n      onClose={handleClose}\n      title={node.name || (isScan ? '3D Scan' : 'Guide Image')}\n      width={300}\n    >\n      <PanelSection title=\"Position\">\n        <SliderControl\n          label={\n            <>\n              X<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={50}\n          min={-50}\n          onChange={(value) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[0] = value\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <SliderControl\n          label={\n            <>\n              Y<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={50}\n          min={-50}\n          onChange={(value) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[1] = value\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <SliderControl\n          label={\n            <>\n              Z<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          max={50}\n          min={-50}\n          onChange={(value) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[2] = value\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Rotation\">\n        <SliderControl\n          label={\n            <>\n              Y<sub className=\"ml-[1px] text-[11px] opacity-70\">rot</sub>\n            </>\n          }\n          max={180}\n          min={-180}\n          onChange={(degrees) => {\n            const radians = (degrees * Math.PI) / 180\n            handleUpdate({\n              rotation: [node.rotation[0], radians, node.rotation[2]],\n            })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation[1] * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() =>\n              handleUpdate({\n                rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],\n              })\n            }\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() =>\n              handleUpdate({\n                rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],\n              })\n            }\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Scale & Opacity\">\n        <SliderControl\n          label={\n            <>\n              XYZ<sub className=\"ml-[1px] text-[11px] opacity-70\">scale</sub>\n            </>\n          }\n          max={10}\n          min={0.01}\n          onChange={(value) => {\n            if (value > 0) {\n              handleUpdate({ scale: value })\n            }\n          }}\n          precision={2}\n          step={0.1}\n          value={Math.round(node.scale * 100) / 100}\n        />\n\n        <SliderControl\n          label=\"Opacity\"\n          max={100}\n          min={0}\n          onChange={(v) => handleUpdate({ opacity: v })}\n          precision={0}\n          step={1}\n          unit=\"%\"\n          value={node.opacity}\n        />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/roof-panel.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  type MaterialSchema,\n  type RoofNode,\n  RoofNode as RoofNodeSchema,\n  type RoofSegmentNode,\n  RoofSegmentNode as RoofSegmentNodeSchema,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Copy, Move, Plus, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function RoofPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const createNode = useScene((s) => s.createNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<RoofNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleAddSegment = useCallback(() => {\n    if (!node) return\n    const segment = RoofSegmentNodeSchema.parse({\n      width: 6,\n      depth: 6,\n      wallHeight: 0.5,\n      roofHeight: 2.5,\n      roofType: 'gable',\n      position: [2, 0, 2],\n    })\n    createNode(segment, node.id as AnyNodeId)\n  }, [node, createNode])\n\n  const handleSelectSegment = useCallback(\n    (segmentId: string) => {\n      setSelection({ selectedIds: [segmentId as AnyNode['id']] })\n    },\n    [setSelection],\n  )\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n\n    let duplicateInfo = structuredClone(node) as any\n    delete duplicateInfo.id\n    duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }\n    // Offset slightly so it's visible\n    duplicateInfo.position = [\n      duplicateInfo.position[0] + 1,\n      duplicateInfo.position[1],\n      duplicateInfo.position[2] + 1,\n    ]\n\n    try {\n      const duplicate = RoofNodeSchema.parse(duplicateInfo)\n      useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n\n      // Also duplicate all child segments\n      const nodesState = useScene.getState().nodes\n      const children = node.children || []\n\n      for (const childId of children) {\n        const childNode = nodesState[childId]\n        if (childNode && childNode.type === 'roof-segment') {\n          let childDuplicateInfo = structuredClone(childNode) as any\n          delete childDuplicateInfo.id\n          childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }\n          const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)\n          useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)\n        }\n      }\n\n      setSelection({ selectedIds: [] })\n      setMovingNode(duplicate)\n    } catch (e) {\n      console.error('Failed to duplicate roof', e)\n    }\n  }, [node, setSelection, setMovingNode])\n\n  const handleMove = useCallback(() => {\n    if (node) {\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(node)\n      setSelection({ selectedIds: [] })\n    }\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    const parentId = node.parentId\n    useScene.getState().deleteNode(selectedId as AnyNodeId)\n    if (parentId) {\n      useScene.getState().dirtyNodes.add(parentId as AnyNodeId)\n    }\n    setSelection({ selectedIds: [] })\n  }, [selectedId, node, setSelection])\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null\n\n  const segments = (node.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)\n    .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/roof.png\"\n      onClose={handleClose}\n      title={node.name || 'Roof'}\n      width={300}\n    >\n      <PanelSection title=\"Segments\">\n        <div className=\"flex flex-col gap-1\">\n          {segments.map((seg, i) => (\n            <button\n              className=\"flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]\"\n              key={seg.id}\n              onClick={() => handleSelectSegment(seg.id)}\n              type=\"button\"\n            >\n              <span className=\"truncate\">{seg.name || `Segment ${i + 1}`}</span>\n              <span className=\"text-muted-foreground text-xs capitalize\">{seg.roofType}</span>\n            </button>\n          ))}\n        </div>\n        <ActionButton\n          icon={<Plus className=\"h-3.5 w-3.5\" />}\n          label=\"Add Segment\"\n          onClick={handleAddSegment}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Position\">\n        <MetricControl\n          label=\"X\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[0] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Y\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[1] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Z\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[2] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n        <SliderControl\n          label=\"Rotation\"\n          max={180}\n          min={-180}\n          onChange={(degrees) => {\n            handleUpdate({ rotation: (degrees * Math.PI) / 180 })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation - Math.PI / 4 })\n            }}\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation + Math.PI / 4 })\n            }}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/roof-segment-panel.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  type MaterialSchema,\n  type RoofSegmentNode,\n  RoofSegmentNode as RoofSegmentNodeSchema,\n  type RoofType,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Copy, Move, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SegmentedControl } from '../controls/segmented-control'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nconst ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [\n  { label: 'Hip', value: 'hip' },\n  { label: 'Gable', value: 'gable' },\n  { label: 'Shed', value: 'shed' },\n  { label: 'Flat', value: 'flat' },\n]\n\nconst ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [\n  { label: 'Gambrel', value: 'gambrel' },\n  { label: 'Dutch', value: 'dutch' },\n  { label: 'Mansard', value: 'mansard' },\n]\n\nexport function RoofSegmentPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId\n    ? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)\n    : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<RoofSegmentNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleBack = useCallback(() => {\n    if (node?.parentId) {\n      setSelection({ selectedIds: [node.parentId] })\n    }\n  }, [node?.parentId, setSelection])\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n\n    let duplicateInfo = structuredClone(node) as any\n    delete duplicateInfo.id\n    duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }\n    // Offset slightly so it's visible\n    duplicateInfo.position = [\n      duplicateInfo.position[0] + 1,\n      duplicateInfo.position[1],\n      duplicateInfo.position[2] + 1,\n    ]\n\n    try {\n      const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)\n      useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n      setSelection({ selectedIds: [] })\n      setMovingNode(duplicate)\n    } catch (e) {\n      console.error('Failed to duplicate roof segment', e)\n    }\n  }, [node, setSelection, setMovingNode])\n\n  const handleMove = useCallback(() => {\n    if (node) {\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(node)\n      setSelection({ selectedIds: [] })\n    }\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    const parentId = node.parentId\n    useScene.getState().deleteNode(selectedId as AnyNodeId)\n    if (parentId) {\n      useScene.getState().dirtyNodes.add(parentId as AnyNodeId)\n      setSelection({ selectedIds: [parentId] })\n    } else {\n      setSelection({ selectedIds: [] })\n    }\n  }, [selectedId, node, setSelection])\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/roof.png\"\n      onBack={handleBack}\n      onClose={handleClose}\n      title={node.name || 'Roof Segment'}\n      width={300}\n    >\n      <PanelSection title=\"Roof Type\">\n        <SegmentedControl\n          onChange={(v) => handleUpdate({ roofType: v })}\n          options={ROOF_TYPE_OPTIONS}\n          value={node.roofType}\n        />\n        <SegmentedControl\n          onChange={(v) => handleUpdate({ roofType: v })}\n          options={ROOF_TYPE_OPTIONS_2}\n          value={node.roofType}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Footprint\">\n        <SliderControl\n          label=\"Width\"\n          max={25}\n          min={0.5}\n          onChange={(v) => handleUpdate({ width: v })}\n          precision={2}\n          step={0.5}\n          unit=\"m\"\n          value={Math.round(node.width * 100) / 100}\n        />\n        <SliderControl\n          label=\"Depth\"\n          max={25}\n          min={0.5}\n          onChange={(v) => handleUpdate({ depth: v })}\n          precision={2}\n          step={0.5}\n          unit=\"m\"\n          value={Math.round(node.depth * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Heights\">\n        <SliderControl\n          label=\"Wall\"\n          max={5}\n          min={0}\n          onChange={(v) => handleUpdate({ wallHeight: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.wallHeight * 100) / 100}\n        />\n        <SliderControl\n          label=\"Roof\"\n          max={15}\n          min={0}\n          onChange={(v) => handleUpdate({ roofHeight: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.roofHeight * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Structure\">\n        <SliderControl\n          label=\"Wall Thick.\"\n          max={1}\n          min={0.05}\n          onChange={(v) => handleUpdate({ wallThickness: v })}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.wallThickness * 100) / 100}\n        />\n        <SliderControl\n          label=\"Deck Thick.\"\n          max={0.3}\n          min={0.04}\n          onChange={(v) => handleUpdate({ deckThickness: v })}\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.deckThickness * 100) / 100}\n        />\n        <SliderControl\n          label=\"Overhang\"\n          max={1}\n          min={0}\n          onChange={(v) => handleUpdate({ overhang: v })}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.overhang * 100) / 100}\n        />\n        <SliderControl\n          label=\"Shingle Thick.\"\n          max={0.3}\n          min={0.02}\n          onChange={(v) => handleUpdate({ shingleThickness: v })}\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.shingleThickness * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Position\">\n        <MetricControl\n          label=\"X\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[0] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Y\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[1] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Z\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[2] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n        <SliderControl\n          label=\"Rotation\"\n          max={180}\n          min={-180}\n          onChange={(degrees) => {\n            handleUpdate({ rotation: (degrees * Math.PI) / 180 })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation - Math.PI / 4 })\n            }}\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation + Math.PI / 4 })\n            }}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/slab-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Edit, Plus, Trash2 } from 'lucide-react'\nimport { useCallback, useEffect } from 'react'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function SlabPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const editingHole = useEditor((s) => s.editingHole)\n  const setEditingHole = useEditor((s) => s.setEditingHole)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<SlabNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n    setEditingHole(null)\n  }, [setSelection, setEditingHole])\n\n  useEffect(() => {\n    if (!node) {\n      setEditingHole(null)\n    }\n  }, [node, setEditingHole])\n\n  useEffect(() => {\n    return () => {\n      setEditingHole(null)\n    }\n  }, [setEditingHole])\n\n  const handleAddHole = useCallback(() => {\n    if (!(node && selectedId)) return\n\n    const polygon = node.polygon\n    let cx = 0\n    let cz = 0\n    for (const [x, z] of polygon) {\n      cx += x\n      cz += z\n    }\n    cx /= polygon.length\n    cz /= polygon.length\n\n    const holeSize = 0.5\n    const newHole: Array<[number, number]> = [\n      [cx - holeSize, cz - holeSize],\n      [cx + holeSize, cz - holeSize],\n      [cx + holeSize, cz + holeSize],\n      [cx - holeSize, cz + holeSize],\n    ]\n    const currentHoles = node?.holes || []\n    handleUpdate({ holes: [...currentHoles, newHole] })\n    setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })\n  }, [node, selectedId, handleUpdate, setEditingHole])\n\n  const handleEditHole = useCallback(\n    (index: number) => {\n      if (!selectedId) return\n      setEditingHole({ nodeId: selectedId, holeIndex: index })\n    },\n    [selectedId, setEditingHole],\n  )\n\n  const handleDeleteHole = useCallback(\n    (index: number) => {\n      if (!selectedId) return\n      const currentHoles = node?.holes || []\n      const newHoles = currentHoles.filter((_, i) => i !== index)\n      handleUpdate({ holes: newHoles })\n      if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {\n        setEditingHole(null)\n      }\n    },\n    [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],\n  )\n\n  if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null\n\n  const calculateArea = (polygon: Array<[number, number]>): number => {\n    if (polygon.length < 3) return 0\n    let area = 0\n    const n = polygon.length\n    for (let i = 0; i < n; i++) {\n      const j = (i + 1) % n\n      const pi = polygon[i]\n      const pj = polygon[j]\n      if (pi && pj) {\n        area += pi[0] * pj[1]\n        area -= pj[0] * pi[1]\n      }\n    }\n    return Math.abs(area) / 2\n  }\n\n  const area = calculateArea(node.polygon)\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/floor.png\"\n      onClose={handleClose}\n      title={node.name || 'Slab'}\n      width={320}\n    >\n      <PanelSection title=\"Elevation\">\n        <SliderControl\n          label=\"Height\"\n          max={1}\n          min={-1}\n          onChange={(v) => handleUpdate({ elevation: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.elevation * 1000) / 1000}\n        />\n\n        <div className=\"mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1\">\n          <ActionButton label=\"Sunken (-15cm)\" onClick={() => handleUpdate({ elevation: -0.15 })} />\n          <ActionButton label=\"Ground (0m)\" onClick={() => handleUpdate({ elevation: 0 })} />\n          <ActionButton label=\"Raised (+5cm)\" onClick={() => handleUpdate({ elevation: 0.05 })} />\n          <ActionButton label=\"Step (+15cm)\" onClick={() => handleUpdate({ elevation: 0.15 })} />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Info\">\n        <div className=\"flex items-center justify-between px-2 py-1 text-muted-foreground text-sm\">\n          <span>Area</span>\n          <span className=\"font-mono text-white\">{area.toFixed(2)} m²</span>\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Holes\">\n        {node.holes && node.holes.length > 0 ? (\n          <div className=\"flex flex-col gap-1 pb-2\">\n            {node.holes.map((hole, index) => {\n              const holeArea = calculateArea(hole)\n              const isEditing =\n                editingHole?.nodeId === selectedId && editingHole?.holeIndex === index\n              return (\n                <div\n                  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${\n                    isEditing\n                      ? 'border-primary/50 bg-primary/10'\n                      : 'border-transparent hover:bg-accent/30'\n                  }`}\n                  key={index}\n                >\n                  <div className=\"min-w-0 flex-1\">\n                    <p\n                      className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}\n                    >\n                      Hole {index + 1} {isEditing && '(Editing)'}\n                    </p>\n                    <p className=\"text-[10px] text-muted-foreground\">\n                      {holeArea.toFixed(2)} m² · {hole.length} pts\n                    </p>\n                  </div>\n                  <div className=\"flex items-center gap-1\">\n                    {isEditing ? (\n                      <ActionButton\n                        className=\"h-7 bg-primary text-primary-foreground hover:bg-primary/90\"\n                        label=\"Done\"\n                        onClick={() => setEditingHole(null)}\n                      />\n                    ) : (\n                      <>\n                        <button\n                          className=\"flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground\"\n                          onClick={() => handleEditHole(index)}\n                          type=\"button\"\n                        >\n                          <Edit className=\"h-3.5 w-3.5\" />\n                        </button>\n                        <button\n                          className=\"flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300\"\n                          onClick={() => handleDeleteHole(index)}\n                          type=\"button\"\n                        >\n                          <Trash2 className=\"h-3.5 w-3.5\" />\n                        </button>\n                      </>\n                    )}\n                  </div>\n                </div>\n              )\n            })}\n          </div>\n        ) : (\n          <div className=\"px-2 py-3 text-center text-muted-foreground text-xs\">No holes</div>\n        )}\n\n        <div className=\"px-1 pt-1 pb-1\">\n          <ActionButton\n            className=\"w-full\"\n            disabled={editingHole?.nodeId === selectedId}\n            icon={<Plus className=\"h-3.5 w-3.5\" />}\n            label=\"Add Hole\"\n            onClick={handleAddHole}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/stair-panel.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  type MaterialSchema,\n  type StairNode,\n  StairNode as StairNodeSchema,\n  type StairSegmentNode,\n  StairSegmentNode as StairSegmentNodeSchema,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Copy, Move, Plus, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function StairPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const createNode = useScene((s) => s.createNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId\n    ? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)\n    : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<StairNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleMaterialChange = useCallback(\n    (material: MaterialSchema) => {\n      handleUpdate({ material })\n    },\n    [handleUpdate],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const getLastSegmentFillDefaults = useCallback(() => {\n    if (!node) return { fillToFloor: true }\n    const children = node.children ?? []\n    const lastChildId = children[children.length - 1]\n    if (lastChildId) {\n      const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined\n      if (lastChild?.type === 'stair-segment') {\n        return { fillToFloor: lastChild.fillToFloor }\n      }\n    }\n    return { fillToFloor: true }\n  }, [node, nodes])\n\n  const handleAddFlight = useCallback(() => {\n    if (!node) return\n    const { fillToFloor } = getLastSegmentFillDefaults()\n    const segment = StairSegmentNodeSchema.parse({\n      segmentType: 'stair',\n      width: 1.0,\n      length: 3.0,\n      height: 2.5,\n      stepCount: 10,\n      attachmentSide: 'front',\n      fillToFloor,\n      thickness: 0.25,\n      position: [0, 0, 0],\n    })\n    createNode(segment, node.id as AnyNodeId)\n  }, [node, createNode, getLastSegmentFillDefaults])\n\n  const handleAddLanding = useCallback(() => {\n    if (!node) return\n    const { fillToFloor } = getLastSegmentFillDefaults()\n    const segment = StairSegmentNodeSchema.parse({\n      segmentType: 'landing',\n      width: 1.0,\n      length: 1.0,\n      height: 0,\n      stepCount: 0,\n      attachmentSide: 'front',\n      fillToFloor,\n      thickness: 0.32,\n      position: [0, 0, 0],\n    })\n    createNode(segment, node.id as AnyNodeId)\n  }, [node, createNode, getLastSegmentFillDefaults])\n\n  const handleSelectSegment = useCallback(\n    (segmentId: string) => {\n      setSelection({ selectedIds: [segmentId as AnyNode['id']] })\n    },\n    [setSelection],\n  )\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n\n    let duplicateInfo = structuredClone(node) as any\n    delete duplicateInfo.id\n    duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }\n    duplicateInfo.position = [\n      duplicateInfo.position[0] + 1,\n      duplicateInfo.position[1],\n      duplicateInfo.position[2] + 1,\n    ]\n\n    try {\n      const duplicate = StairNodeSchema.parse(duplicateInfo)\n      useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n\n      // Also duplicate all child segments\n      const nodesState = useScene.getState().nodes\n      const children = node.children || []\n\n      for (const childId of children) {\n        const childNode = nodesState[childId]\n        if (childNode && childNode.type === 'stair-segment') {\n          let childDuplicateInfo = structuredClone(childNode) as any\n          delete childDuplicateInfo.id\n          childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }\n          const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)\n          useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)\n        }\n      }\n\n      setSelection({ selectedIds: [] })\n      setMovingNode(duplicate)\n    } catch (e) {\n      console.error('Failed to duplicate stair', e)\n    }\n  }, [node, setSelection, setMovingNode])\n\n  const handleMove = useCallback(() => {\n    if (node) {\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(node)\n      setSelection({ selectedIds: [] })\n    }\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    const parentId = node.parentId\n    useScene.getState().deleteNode(selectedId as AnyNodeId)\n    if (parentId) {\n      useScene.getState().dirtyNodes.add(parentId as AnyNodeId)\n    }\n    setSelection({ selectedIds: [] })\n  }, [selectedId, node, setSelection])\n\n  if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null\n\n  const segments = (node.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)\n    .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/stairs.png\"\n      onClose={handleClose}\n      title={node.name || 'Staircase'}\n      width={300}\n    >\n      <PanelSection title=\"Segments\">\n        <div className=\"flex flex-col gap-1\">\n          {segments.map((seg, i) => (\n            <button\n              className=\"flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]\"\n              key={seg.id}\n              onClick={() => handleSelectSegment(seg.id)}\n              type=\"button\"\n            >\n              <span className=\"truncate\">{seg.name || `Segment ${i + 1}`}</span>\n              <span className=\"text-muted-foreground text-xs capitalize\">{seg.segmentType}</span>\n            </button>\n          ))}\n        </div>\n        <div className=\"flex gap-1.5\">\n          <ActionButton\n            icon={<Plus className=\"h-3.5 w-3.5\" />}\n            label=\"Add flight\"\n            onClick={handleAddFlight}\n          />\n          <ActionButton\n            icon={<Plus className=\"h-3.5 w-3.5\" />}\n            label=\"Add landing\"\n            onClick={handleAddLanding}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Position\">\n        <MetricControl\n          label=\"X\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[0] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Y\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[1] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Z\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[2] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n        <SliderControl\n          label=\"Rotation\"\n          max={180}\n          min={-180}\n          onChange={(degrees) => {\n            handleUpdate({ rotation: (degrees * Math.PI) / 180 })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation - Math.PI / 4 })\n            }}\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation + Math.PI / 4 })\n            }}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n      <PanelSection title=\"Material\">\n        <MaterialPicker onChange={handleMaterialChange} value={node.material} />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/stair-segment-panel.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  type AttachmentSide,\n  type MaterialSchema,\n  type StairSegmentNode,\n  StairSegmentNode as StairSegmentNodeSchema,\n  type StairSegmentType,\n  useScene,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Copy, Move, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SegmentedControl } from '../controls/segmented-control'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nconst SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [\n  { label: 'Flight', value: 'stair' },\n  { label: 'Landing', value: 'landing' },\n]\n\nconst ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [\n  { label: 'Front', value: 'front' },\n  { label: 'Left', value: 'left' },\n  { label: 'Right', value: 'right' },\n]\n\nexport function StairSegmentPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId\n    ? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)\n    : undefined\n\n  // Check if this is the first segment in the parent stair\n  const isFirstSegment = (() => {\n    if (!node?.parentId) return true\n    const parent = nodes[node.parentId as AnyNodeId]\n    if (!parent || parent.type !== 'stair') return true\n    const children = (parent as any).children ?? []\n    return children[0] === node.id\n  })()\n\n  const handleUpdate = useCallback(\n    (updates: Partial<StairSegmentNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleMaterialChange = useCallback(\n    (material: MaterialSchema) => {\n      handleUpdate({ material })\n    },\n    [handleUpdate],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleBack = useCallback(() => {\n    if (node?.parentId) {\n      setSelection({ selectedIds: [node.parentId] })\n    }\n  }, [node?.parentId, setSelection])\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n\n    let duplicateInfo = structuredClone(node) as any\n    delete duplicateInfo.id\n    duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }\n    duplicateInfo.position = [\n      duplicateInfo.position[0] + 1,\n      duplicateInfo.position[1],\n      duplicateInfo.position[2] + 1,\n    ]\n\n    try {\n      const duplicate = StairSegmentNodeSchema.parse(duplicateInfo)\n      useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)\n      setSelection({ selectedIds: [] })\n      setMovingNode(duplicate)\n    } catch (e) {\n      console.error('Failed to duplicate stair segment', e)\n    }\n  }, [node, setSelection, setMovingNode])\n\n  const handleMove = useCallback(() => {\n    if (node) {\n      sfxEmitter.emit('sfx:item-pick')\n      setMovingNode(node)\n      setSelection({ selectedIds: [] })\n    }\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    const parentId = node.parentId\n    useScene.getState().deleteNode(selectedId as AnyNodeId)\n    if (parentId) {\n      useScene.getState().dirtyNodes.add(parentId as AnyNodeId)\n      setSelection({ selectedIds: [parentId] })\n    } else {\n      setSelection({ selectedIds: [] })\n    }\n  }, [selectedId, node, setSelection])\n\n  if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/stairs.png\"\n      onBack={handleBack}\n      onClose={handleClose}\n      title={node.name || 'Stair Segment'}\n      width={300}\n    >\n      <PanelSection title=\"Type\">\n        <SegmentedControl\n          onChange={(v) => {\n            const updates: Partial<StairSegmentNode> = { segmentType: v }\n            if (v === 'landing') {\n              updates.height = 0\n              updates.stepCount = 0\n              updates.length = 1.0\n            } else {\n              updates.height = 2.5\n              updates.stepCount = 10\n              updates.length = 3.0\n            }\n            handleUpdate(updates)\n          }}\n          options={SEGMENT_TYPE_OPTIONS}\n          value={node.segmentType}\n        />\n      </PanelSection>\n\n      {!isFirstSegment && (\n        <PanelSection title=\"Attachment\">\n          <SegmentedControl\n            onChange={(v) => handleUpdate({ attachmentSide: v })}\n            options={ATTACHMENT_SIDE_OPTIONS}\n            value={node.attachmentSide}\n          />\n        </PanelSection>\n      )}\n\n      <PanelSection title=\"Dimensions\">\n        <SliderControl\n          label=\"Width\"\n          max={5}\n          min={0.5}\n          onChange={(v) => handleUpdate({ width: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.width * 100) / 100}\n        />\n        <SliderControl\n          label=\"Length\"\n          max={10}\n          min={0.5}\n          onChange={(v) => handleUpdate({ length: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.length * 100) / 100}\n        />\n        {node.segmentType === 'stair' && (\n          <>\n            <SliderControl\n              label=\"Height\"\n              max={10}\n              min={0.5}\n              onChange={(v) => handleUpdate({ height: v })}\n              precision={2}\n              step={0.1}\n              unit=\"m\"\n              value={Math.round(node.height * 100) / 100}\n            />\n            <SliderControl\n              label=\"Steps\"\n              max={30}\n              min={2}\n              onChange={(v) => handleUpdate({ stepCount: Math.round(v) })}\n              precision={0}\n              step={1}\n              unit=\"\"\n              value={node.stepCount}\n            />\n          </>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Structure\">\n        <div className=\"flex items-center justify-between px-1 py-1\">\n          <span className=\"text-muted-foreground text-xs\">Fill to floor</span>\n          <button\n            className={`relative h-5 w-10 rounded-full transition-colors ${\n              node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'\n            }`}\n            onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}\n            type=\"button\"\n          >\n            <div\n              className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${\n                node.fillToFloor ? 'left-6' : 'left-1'\n              }`}\n            />\n          </button>\n        </div>\n        {!node.fillToFloor && (\n          <SliderControl\n            label=\"Thickness\"\n            max={1}\n            min={0.05}\n            onChange={(v) => handleUpdate({ thickness: v })}\n            precision={2}\n            step={0.05}\n            unit=\"m\"\n            value={Math.round((node.thickness ?? 0.25) * 100) / 100}\n          />\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Position\">\n        <MetricControl\n          label=\"X\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[0] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Y\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[1] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <MetricControl\n          label=\"Z\"\n          max={50}\n          min={-50}\n          onChange={(v) => {\n            const pos = [...node.position] as [number, number, number]\n            pos[2] = v\n            handleUpdate({ position: pos })\n          }}\n          precision={2}\n          step={0.05}\n          unit=\"m\"\n          value={Math.round(node.position[2] * 100) / 100}\n        />\n        <SliderControl\n          label=\"Rotation\"\n          max={180}\n          min={-180}\n          onChange={(degrees) => {\n            handleUpdate({ rotation: (degrees * Math.PI) / 180 })\n          }}\n          precision={0}\n          step={1}\n          unit=\"°\"\n          value={Math.round((node.rotation * 180) / Math.PI)}\n        />\n        <div className=\"flex gap-1.5 px-1 pt-2 pb-1\">\n          <ActionButton\n            label=\"-45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation - Math.PI / 4 })\n            }}\n          />\n          <ActionButton\n            label=\"+45°\"\n            onClick={() => {\n              sfxEmitter.emit('sfx:item-rotate')\n              handleUpdate({ rotation: node.rotation + Math.PI / 4 })\n            }}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n      <PanelSection title=\"Material\">\n        <MaterialPicker onChange={handleMaterialChange} value={node.material} />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/wall-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type AnyNodeId, type MaterialSchema, useScene, type WallNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useCallback } from 'react'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { PanelWrapper } from './panel-wrapper'\n\nexport function WallPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n\n  const selectedId = selectedIds[0]\n  const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<WallNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n      useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleUpdateLength = useCallback((newLength: number) => {\n    if (!node || newLength <= 0) return\n\n    const dx = node.end[0] - node.start[0]\n    const dz = node.end[1] - node.start[1]\n    const currentLength = Math.sqrt(dx * dx + dz * dz)\n\n    if (currentLength === 0) return\n\n    const dirX = dx / currentLength\n    const dirZ = dz / currentLength\n\n    const newEnd: [number, number] = [\n      node.start[0] + dirX * newLength,\n      node.start[1] + dirZ * newLength\n    ]\n\n    handleUpdate({ end: newEnd })\n  }, [node, handleUpdate])\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null\n\n  const dx = node.end[0] - node.start[0]\n  const dz = node.end[1] - node.start[1]\n  const length = Math.sqrt(dx * dx + dz * dz)\n\n  const height = node.height ?? 2.5\n  const thickness = node.thickness ?? 0.1\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/wall.png\"\n      onClose={handleClose}\n      title={node.name || 'Wall'}\n      width={280}\n    >\n      <PanelSection title=\"Dimensions\">\n        <SliderControl\n          label=\"Length\"\n          max={20}\n          min={0.1}\n          onChange={handleUpdateLength}\n          precision={2}\n          step={0.01}\n          unit=\"m\"\n          value={length}\n        />\n        <SliderControl\n          label=\"Height\"\n          max={6}\n          min={0.1}\n          onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(height * 100) / 100}\n        />\n        <SliderControl\n          label=\"Thickness\"\n          max={1}\n          min={0.05}\n          onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(thickness * 1000) / 1000}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/panels/window-panel.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type AnyNodeId, emitter, type MaterialSchema, useScene, WindowNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { usePresetsAdapter } from '../../../contexts/presets-context'\nimport { sfxEmitter } from '../../../lib/sfx-bus'\nimport useEditor from '../../../store/use-editor'\nimport { ActionButton, ActionGroup } from '../controls/action-button'\nimport { MaterialPicker } from '../controls/material-picker'\nimport { MetricControl } from '../controls/metric-control'\nimport { PanelSection } from '../controls/panel-section'\nimport { SliderControl } from '../controls/slider-control'\nimport { ToggleControl } from '../controls/toggle-control'\nimport { PanelWrapper } from './panel-wrapper'\nimport { PresetsPopover } from './presets/presets-popover'\n\nexport function WindowPanel() {\n  const selectedIds = useViewer((s) => s.selection.selectedIds)\n  const setSelection = useViewer((s) => s.setSelection)\n  const nodes = useScene((s) => s.nodes)\n  const updateNode = useScene((s) => s.updateNode)\n  const deleteNode = useScene((s) => s.deleteNode)\n  const setMovingNode = useEditor((s) => s.setMovingNode)\n\n  const adapter = usePresetsAdapter()\n\n  const selectedId = selectedIds[0]\n  const node = selectedId\n    ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)\n    : undefined\n\n  const handleUpdate = useCallback(\n    (updates: Partial<WindowNode>) => {\n      if (!selectedId) return\n      updateNode(selectedId as AnyNode['id'], updates)\n      useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)\n    },\n    [selectedId, updateNode],\n  )\n\n  const handleClose = useCallback(() => {\n    setSelection({ selectedIds: [] })\n  }, [setSelection])\n\n  const handleFlip = useCallback(() => {\n    if (!node) return\n    handleUpdate({\n      side: node.side === 'front' ? 'back' : 'front',\n      rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],\n    })\n  }, [node, handleUpdate])\n\n  const handleMove = useCallback(() => {\n    if (!node) return\n    sfxEmitter.emit('sfx:item-pick')\n    setMovingNode(node)\n    setSelection({ selectedIds: [] })\n  }, [node, setMovingNode, setSelection])\n\n  const handleDelete = useCallback(() => {\n    if (!(selectedId && node)) return\n    sfxEmitter.emit('sfx:item-delete')\n    deleteNode(selectedId as AnyNode['id'])\n    if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)\n    setSelection({ selectedIds: [] })\n  }, [selectedId, node, deleteNode, setSelection])\n\n  const handleDuplicate = useCallback(() => {\n    if (!node?.parentId) return\n    sfxEmitter.emit('sfx:item-pick')\n    useScene.temporal.getState().pause()\n    const duplicate = WindowNode.parse({\n      position: [...node.position] as [number, number, number],\n      rotation: [...node.rotation] as [number, number, number],\n      side: node.side,\n      wallId: node.wallId,\n      parentId: node.parentId,\n      width: node.width,\n      height: node.height,\n      frameThickness: node.frameThickness,\n      frameDepth: node.frameDepth,\n      columnRatios: [...node.columnRatios],\n      rowRatios: [...node.rowRatios],\n      columnDividerThickness: node.columnDividerThickness,\n      rowDividerThickness: node.rowDividerThickness,\n      sill: node.sill,\n      sillDepth: node.sillDepth,\n      sillThickness: node.sillThickness,\n      metadata: { isNew: true },\n    })\n    useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)\n    setMovingNode(duplicate)\n    setSelection({ selectedIds: [] })\n  }, [node, setMovingNode, setSelection])\n\n  const getWindowPresetData = useCallback(() => {\n    if (!node) return null\n    return {\n      width: node.width,\n      height: node.height,\n      frameThickness: node.frameThickness,\n      frameDepth: node.frameDepth,\n      columnRatios: node.columnRatios,\n      rowRatios: node.rowRatios,\n      columnDividerThickness: node.columnDividerThickness,\n      rowDividerThickness: node.rowDividerThickness,\n      sill: node.sill,\n      sillDepth: node.sillDepth,\n      sillThickness: node.sillThickness,\n    }\n  }, [node])\n\n  const handleSavePreset = useCallback(\n    async (name: string) => {\n      const data = getWindowPresetData()\n      if (!(data && selectedId)) return\n      const presetId = await adapter.savePreset('window', name, data)\n      if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })\n    },\n    [getWindowPresetData, selectedId, adapter],\n  )\n\n  const handleOverwritePreset = useCallback(\n    async (id: string) => {\n      const data = getWindowPresetData()\n      if (!(data && selectedId)) return\n      await adapter.overwritePreset('window', id, data)\n      emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })\n    },\n    [getWindowPresetData, selectedId, adapter],\n  )\n\n  const handleApplyPreset = useCallback(\n    (data: Record<string, unknown>) => {\n      handleUpdate(data as Partial<WindowNode>)\n    },\n    [handleUpdate],\n  )\n\n  const handleMaterialChange = useCallback((material: MaterialSchema) => {\n    handleUpdate({ material })\n  }, [handleUpdate])\n\n  if (!node || node.type !== 'window' || selectedIds.length !== 1) return null\n\n  const numCols = node.columnRatios.length\n  const numRows = node.rowRatios.length\n\n  const colSum = node.columnRatios.reduce((a, b) => a + b, 0)\n  const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)\n  const normCols = node.columnRatios.map((r) => r / colSum)\n  const normRows = node.rowRatios.map((r) => r / rowSum)\n\n  const setColumnRatio = (index: number, newVal: number) => {\n    const clamped = Math.max(0.05, Math.min(0.95, newVal))\n    const neighborIdx = index < numCols - 1 ? index + 1 : index - 1\n    const delta = clamped - normCols[index]!\n    const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)\n    const newRatios = normCols.map((v, i) => {\n      if (i === index) return clamped\n      if (i === neighborIdx) return neighborVal\n      return v\n    })\n    handleUpdate({ columnRatios: newRatios })\n  }\n\n  const setRowRatio = (index: number, newVal: number) => {\n    const clamped = Math.max(0.05, Math.min(0.95, newVal))\n    const neighborIdx = index < numRows - 1 ? index + 1 : index - 1\n    const delta = clamped - normRows[index]!\n    const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)\n    const newRatios = normRows.map((v, i) => {\n      if (i === index) return clamped\n      if (i === neighborIdx) return neighborVal\n      return v\n    })\n    handleUpdate({ rowRatios: newRatios })\n  }\n\n  return (\n    <PanelWrapper\n      icon=\"/icons/window.png\"\n      onClose={handleClose}\n      title={node.name || 'Window'}\n      width={320}\n    >\n      {/* Presets strip */}\n      <div className=\"border-border/30 border-b px-3 pt-2.5 pb-1.5\">\n        <PresetsPopover\n          isAuthenticated={adapter.isAuthenticated}\n          onApply={handleApplyPreset}\n          onDelete={(id) => adapter.deletePreset(id)}\n          onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}\n          onOverwrite={handleOverwritePreset}\n          onRename={(id, name) => adapter.renamePreset(id, name)}\n          onSave={handleSavePreset}\n          onToggleCommunity={adapter.togglePresetCommunity}\n          tabs={adapter.tabs}\n          type=\"window\"\n        >\n          <button className=\"flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground\">\n            <BookMarked className=\"h-3.5 w-3.5 shrink-0\" />\n            <span>Presets</span>\n          </button>\n        </PresetsPopover>\n      </div>\n\n      <PanelSection title=\"Position\">\n        <SliderControl\n          label={\n            <>\n              X<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[0] * 100) / 100}\n        />\n        <SliderControl\n          label={\n            <>\n              Y<sub className=\"ml-[1px] text-[11px] opacity-70\">pos</sub>\n            </>\n          }\n          onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.position[1] * 100) / 100}\n        />\n        <div className=\"px-1 pt-2 pb-1\">\n          <ActionButton\n            className=\"w-full\"\n            icon={<FlipHorizontal2 className=\"h-4 w-4\" />}\n            label=\"Flip Side\"\n            onClick={handleFlip}\n          />\n        </div>\n      </PanelSection>\n\n      <PanelSection title=\"Dimensions\">\n        <SliderControl\n          label=\"Width\"\n          min={0}\n          onChange={(v) => handleUpdate({ width: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.width * 100) / 100}\n        />\n        <SliderControl\n          label=\"Height\"\n          min={0}\n          onChange={(v) => handleUpdate({ height: v })}\n          precision={2}\n          step={0.1}\n          unit=\"m\"\n          value={Math.round(node.height * 100) / 100}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Frame\">\n        <SliderControl\n          label=\"Thickness\"\n          min={0}\n          onChange={(v) => handleUpdate({ frameThickness: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.frameThickness * 1000) / 1000}\n        />\n        <SliderControl\n          label=\"Depth\"\n          min={0}\n          onChange={(v) => handleUpdate({ frameDepth: v })}\n          precision={3}\n          step={0.01}\n          unit=\"m\"\n          value={Math.round(node.frameDepth * 1000) / 1000}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Grid\">\n        <SliderControl\n          label=\"Columns\"\n          max={8}\n          min={1}\n          onChange={(v) => {\n            const n = Math.max(1, Math.min(8, Math.round(v)))\n            handleUpdate({ columnRatios: Array(n).fill(1 / n) })\n          }}\n          precision={0}\n          step={1}\n          value={numCols}\n        />\n        <SliderControl\n          label=\"Rows\"\n          max={8}\n          min={1}\n          onChange={(v) => {\n            const n = Math.max(1, Math.min(8, Math.round(v)))\n            handleUpdate({ rowRatios: Array(n).fill(1 / n) })\n          }}\n          precision={0}\n          step={1}\n          value={numRows}\n        />\n\n        {numCols > 1 && (\n          <div className=\"mt-2 flex flex-col gap-1\">\n            <div className=\"mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n              Col Widths\n            </div>\n            {normCols.map((ratio, i) => (\n              <SliderControl\n                key={`c-${i}`}\n                label={`C${i + 1}`}\n                max={95}\n                min={5}\n                onChange={(v) => setColumnRatio(i, v / 100)}\n                precision={1}\n                step={1}\n                unit=\"%\"\n                value={Math.round(ratio * 100 * 10) / 10}\n              />\n            ))}\n            <div className=\"mt-1 border-border/50 border-t pt-1\">\n              <SliderControl\n                label=\"Divider\"\n                max={0.1}\n                min={0.005}\n                onChange={(v) => handleUpdate({ columnDividerThickness: v })}\n                precision={3}\n                step={0.01}\n                unit=\"m\"\n                value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}\n              />\n            </div>\n          </div>\n        )}\n\n        {numRows > 1 && (\n          <div className=\"mt-2 flex flex-col gap-1\">\n            <div className=\"mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider\">\n              Row Heights\n            </div>\n            {normRows.map((ratio, i) => (\n              <SliderControl\n                key={`r-${i}`}\n                label={`R${i + 1}`}\n                max={95}\n                min={5}\n                onChange={(v) => setRowRatio(i, v / 100)}\n                precision={1}\n                step={1}\n                unit=\"%\"\n                value={Math.round(ratio * 100 * 10) / 10}\n              />\n            ))}\n            <div className=\"mt-1 border-border/50 border-t pt-1\">\n              <SliderControl\n                label=\"Divider\"\n                max={0.1}\n                min={0.005}\n                onChange={(v) => handleUpdate({ rowDividerThickness: v })}\n                precision={3}\n                step={0.01}\n                unit=\"m\"\n                value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}\n              />\n            </div>\n          </div>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Sill\">\n        <ToggleControl\n          checked={node.sill}\n          label=\"Enable Sill\"\n          onChange={(checked) => handleUpdate({ sill: checked })}\n        />\n        {node.sill && (\n          <div className=\"mt-1 flex flex-col gap-1\">\n            <SliderControl\n              label=\"Depth\"\n              min={0}\n              onChange={(v) => handleUpdate({ sillDepth: v })}\n              precision={3}\n              step={0.01}\n              unit=\"m\"\n              value={Math.round(node.sillDepth * 1000) / 1000}\n            />\n            <SliderControl\n              label=\"Thickness\"\n              min={0}\n              onChange={(v) => handleUpdate({ sillThickness: v })}\n              precision={3}\n              step={0.01}\n              unit=\"m\"\n              value={Math.round(node.sillThickness * 1000) / 1000}\n            />\n          </div>\n        )}\n      </PanelSection>\n\n      <PanelSection title=\"Material\">\n        <MaterialPicker\n          onChange={handleMaterialChange}\n          value={node.material}\n        />\n      </PanelSection>\n\n      <PanelSection title=\"Actions\">\n        <ActionGroup>\n          <ActionButton icon={<Move className=\"h-3.5 w-3.5\" />} label=\"Move\" onClick={handleMove} />\n          <ActionButton\n            icon={<Copy className=\"h-3.5 w-3.5\" />}\n            label=\"Duplicate\"\n            onClick={handleDuplicate}\n          />\n          <ActionButton\n            className=\"hover:bg-red-500/20\"\n            icon={<Trash2 className=\"h-3.5 w-3.5 text-red-400\" />}\n            label=\"Delete\"\n            onClick={handleDelete}\n          />\n        </ActionGroup>\n      </PanelSection>\n    </PanelWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/button.tsx",
    "content": "import { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nconst buttonVariants = cva(\n  \"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-barlow font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive:\n          'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',\n        outline:\n          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',\n        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n        sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',\n        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',\n        icon: 'size-9',\n        'icon-sm': 'size-8',\n        'icon-lg': 'size-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n)\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ref,\n  ...props\n}: React.ComponentProps<'button'> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  if (asChild) {\n    return (\n      <Slot\n        className={cn(buttonVariants({ variant, size, className }))}\n        data-slot=\"button\"\n        ref={ref as never}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <button\n      className={cn(buttonVariants({ variant, size, className }))}\n      data-slot=\"button\"\n      ref={ref}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/card.tsx",
    "content": "import type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Card({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn(\n        'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',\n        className,\n      )}\n      data-slot=\"card\"\n      {...props}\n    />\n  )\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn(\n        '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',\n        className,\n      )}\n      data-slot=\"card-header\"\n      {...props}\n    />\n  )\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('font-semibold leading-none', className)}\n      data-slot=\"card-title\"\n      {...props}\n    />\n  )\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('text-muted-foreground text-sm', className)}\n      data-slot=\"card-description\"\n      {...props}\n    />\n  )\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}\n      data-slot=\"card-action\"\n      {...props}\n    />\n  )\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return <div className={cn('px-6', className)} data-slot=\"card-content\" {...props} />\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex items-center px-6 [.border-t]:pt-6', className)}\n      data-slot=\"card-footer\"\n      {...props}\n    />\n  )\n}\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/color-dot.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { cn } from '../../../lib/utils'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\n\nexport const PALETTE_COLORS = [\n  '#ef4444', // Red        0°\n  '#f97316', // Orange    30°\n  '#f59e0b', // Amber     45°\n  '#84cc16', // Lime      85°\n  '#22c55e', // Green    142°\n  '#10b981', // Emerald  160°\n  '#06b6d4', // Cyan     190°\n  '#3b82f6', // Blue     217°\n  '#6366f1', // Indigo   239°\n  '#a855f7', // Violet   270°\n  '#64748b', // Dark gray\n  '#cccccc', // Light gray\n]\n\ninterface ColorDotProps {\n  color: string\n  onChange: (color: string) => void\n}\n\nexport function ColorDot({ color, onChange }: ColorDotProps) {\n  const [open, setOpen] = useState(false)\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger asChild>\n        <button\n          className=\"relative h-3 w-3 shrink-0 cursor-pointer rounded-sm border border-border/50 transition-all hover:ring-1 hover:ring-ring/50\"\n          onClick={(e) => e.stopPropagation()}\n          style={{ backgroundColor: color }}\n          type=\"button\"\n        />\n      </PopoverTrigger>\n      <PopoverContent align=\"center\" className=\"w-auto p-1.5\" side=\"left\" sideOffset={6}>\n        <div className=\"grid grid-cols-4 gap-1\">\n          {PALETTE_COLORS.map((c) => (\n            <button\n              className={cn(\n                'h-5 w-5 rounded-sm border transition-transform hover:scale-110',\n                c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',\n              )}\n              key={c}\n              onClick={() => {\n                onChange(c)\n                setOpen(false)\n              }}\n              style={{ backgroundColor: c }}\n              type=\"button\"\n            />\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/context-menu.tsx",
    "content": "'use client'\n\nimport * as ContextMenuPrimitive from '@radix-ui/react-context-menu'\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />\n}\n\nfunction ContextMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />\n}\n\nfunction ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />\n}\n\nfunction ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />\n}\n\nfunction ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />\n}\n\nfunction ContextMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return <ContextMenuPrimitive.RadioGroup data-slot=\"context-menu-radio-group\" {...props} />\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      className={cn(\n        \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-inset={inset}\n      data-slot=\"context-menu-sub-trigger\"\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction ContextMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className,\n      )}\n      data-slot=\"context-menu-sub-content\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className,\n        )}\n        data-slot=\"context-menu-content\"\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  )\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: 'default' | 'destructive'\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      className={cn(\n        \"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-inset={inset}\n      data-slot=\"context-menu-item\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      checked={checked}\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-slot=\"context-menu-checkbox-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-slot=\"context-menu-radio-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  )\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      className={cn(\n        'px-2 py-1.5 font-barlow font-medium text-foreground text-sm data-[inset]:pl-8',\n        className,\n      )}\n      data-inset={inset}\n      data-slot=\"context-menu-label\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      className={cn('-mx-1 my-1 h-px bg-border', className)}\n      data-slot=\"context-menu-separator\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}\n      data-slot=\"context-menu-shortcut\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/dialog.tsx",
    "content": "'use client'\n\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { XIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className,\n      )}\n      data-slot=\"dialog-overlay\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg',\n          className,\n        )}\n        data-slot=\"dialog-content\"\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            className=\"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\"\n            data-slot=\"dialog-close\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-2 text-center sm:text-left', className)}\n      data-slot=\"dialog-header\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}\n      data-slot=\"dialog-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      className={cn('font-semibold text-lg leading-none', className)}\n      data-slot=\"dialog-title\"\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      className={cn('text-muted-foreground text-sm', className)}\n      data-slot=\"dialog-description\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/dropdown-menu.tsx",
    "content": "'use client'\n\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return <DropdownMenuPrimitive.Trigger data-slot=\"dropdown-menu-trigger\" {...props} />\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className,\n        )}\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = 'default',\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: 'default' | 'destructive'\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!\",\n        className,\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-item\"\n      data-variant={variant}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      checked={checked}\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-slot=\"dropdown-menu-checkbox-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return <DropdownMenuPrimitive.RadioGroup data-slot=\"dropdown-menu-radio-group\" {...props} />\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-slot=\"dropdown-menu-radio-item\"\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      className={cn('px-2 py-1.5 font-barlow font-medium text-sm data-inset:pl-8', className)}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-label\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      className={cn('-mx-1 my-1 h-px bg-border', className)}\n      data-slot=\"dropdown-menu-separator\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}\n      data-slot=\"dropdown-menu-shortcut\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      className={cn(\n        \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-inset:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-sub-trigger\"\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className,\n      )}\n      data-slot=\"dropdown-menu-sub-content\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/error-boundary.tsx",
    "content": "'use client'\n\nimport React, { Component, type ErrorInfo, type ReactNode } from 'react'\n\ninterface Props {\n  children?: ReactNode\n  fallback?: ReactNode\n}\n\ninterface State {\n  hasError: boolean\n  error: Error | null\n}\n\nexport class ErrorBoundary extends Component<Props, State> {\n  public state: State = {\n    hasError: false,\n    error: null,\n  }\n\n  public static getDerivedStateFromError(error: Error): State {\n    return { hasError: true, error }\n  }\n\n  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    console.error('Uncaught error:', error, errorInfo)\n  }\n\n  public render() {\n    if (this.state.hasError) {\n      if (this.props.fallback) {\n        return this.props.fallback\n      }\n      return (\n        <div className=\"flex h-screen w-screen flex-col items-center justify-center bg-[#1b1c1f] p-4 text-white\">\n          <h2 className=\"mb-4 font-bold text-red-400 text-xl\">Something went wrong</h2>\n          <pre className=\"max-w-full overflow-auto rounded bg-black/30 p-4 text-gray-300 text-sm\">\n            {this.state.error?.message}\n          </pre>\n          <button\n            className=\"mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700\"\n            onClick={() => this.setState({ hasError: false, error: null })}\n          >\n            Try again\n          </button>\n        </div>\n      )\n    }\n\n    return this.props.children\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/input.tsx",
    "content": "import type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Input({ className, type, ...props }: React.ComponentProps<'input'>) {\n  return (\n    <input\n      className={cn(\n        'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',\n        'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',\n        'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',\n        className,\n      )}\n      data-slot=\"input\"\n      type={type}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/number-input.tsx",
    "content": "'use client'\n\nimport NumberFlow from '@number-flow/react'\nimport { useScene } from '@pascal-app/core'\nimport { useCallback, useRef, useState } from 'react'\n\ninterface NumberInputProps {\n  label: string\n  value: number\n  onChange: (value: number) => void\n  min?: number\n  max?: number\n  precision?: number\n  step?: number\n  className?: string\n}\n\nexport function NumberInput({\n  label,\n  value,\n  onChange,\n  min,\n  max,\n  precision = 2,\n  step = 0.1,\n  className = '',\n}: NumberInputProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [inputValue, setInputValue] = useState(value.toFixed(precision))\n  const startXRef = useRef(0)\n  const startValueRef = useRef(0)\n  const labelRef = useRef<HTMLDivElement>(null)\n\n  const clamp = useCallback(\n    (val: number) => {\n      if (min !== undefined && val < min) return min\n      if (max !== undefined && val > max) return max\n      return val\n    },\n    [min, max],\n  )\n\n  const handleLabelMouseDown = useCallback(\n    (e: React.MouseEvent) => {\n      if (isEditing) return\n      e.preventDefault()\n      setIsDragging(true)\n      startXRef.current = e.clientX\n      startValueRef.current = value\n\n      // Pause history tracking during drag\n      useScene.temporal.getState().pause()\n\n      let finalValue = value\n\n      const handleMouseMove = (moveEvent: MouseEvent) => {\n        const deltaX = moveEvent.clientX - startXRef.current\n\n        // Determine step size based on modifier keys\n        let dragStep = step // Default from prop\n        if (moveEvent.shiftKey) {\n          dragStep = step * 10 // Coarse\n        } else if (moveEvent.altKey) {\n          dragStep = step * 0.1 // Fine\n        }\n\n        const deltaValue = deltaX * dragStep\n        const newValue = clamp(startValueRef.current + deltaValue)\n        const newFinalValue = Number.parseFloat(newValue.toFixed(precision))\n\n        // Only call onChange if value actually changed (avoid extra processing on tiny moves)\n        if (newFinalValue !== finalValue) {\n          finalValue = newFinalValue\n          onChange(finalValue)\n        }\n      }\n\n      const handleMouseUp = () => {\n        setIsDragging(false)\n        document.removeEventListener('mousemove', handleMouseMove)\n        document.removeEventListener('mouseup', handleMouseUp)\n\n        // Reset to initial value while still paused (no history entry)\n        // Then resume and apply final value (creates single history entry)\n        if (finalValue !== startValueRef.current) {\n          onChange(startValueRef.current)\n          useScene.temporal.getState().resume()\n          onChange(finalValue)\n        } else {\n          useScene.temporal.getState().resume()\n        }\n      }\n\n      document.addEventListener('mousemove', handleMouseMove)\n      document.addEventListener('mouseup', handleMouseUp)\n    },\n    [isEditing, value, onChange, clamp, precision, step],\n  )\n\n  const handleValueClick = useCallback(() => {\n    setIsEditing(true)\n    setInputValue(value.toFixed(precision))\n  }, [value, precision])\n\n  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    setInputValue(e.target.value)\n  }, [])\n\n  const handleInputBlur = useCallback(() => {\n    const numValue = Number.parseFloat(inputValue)\n    if (!Number.isNaN(numValue)) {\n      onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))\n    }\n    setIsEditing(false)\n  }, [inputValue, onChange, clamp, precision])\n\n  const handleInputKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === 'Enter') {\n        const numValue = Number.parseFloat(inputValue)\n        if (!Number.isNaN(numValue)) {\n          onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))\n        }\n        setIsEditing(false)\n      } else if (e.key === 'Escape') {\n        setInputValue(value.toFixed(precision))\n        setIsEditing(false)\n      }\n    },\n    [inputValue, onChange, value, clamp, precision],\n  )\n\n  return (\n    <div className={`${className} group/input relative`}>\n      <div\n        className={`pointer-events-none absolute inset-y-0 left-0 bg-primary/10 transition-all duration-75 dark:bg-primary/20 ${isDragging ? 'opacity-100' : 'opacity-0'}`}\n        style={{\n          width: `${Math.min(100, Math.max(0, ((value - (min ?? Math.min(0, value))) / ((max ?? Math.max(10, value)) - (min ?? Math.min(0, value)))) * 100))}%`,\n          borderTopRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',\n          borderBottomRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',\n          borderTopLeftRadius: '0.5rem',\n          borderBottomLeftRadius: '0.5rem',\n        }}\n      />\n      <div\n        className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}\n      >\n        <div\n          className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${\n            isDragging\n              ? 'cursor-ew-resize text-foreground'\n              : 'hover:cursor-ew-resize hover:text-foreground'\n          } transition-colors`}\n          onMouseDown={handleLabelMouseDown}\n          ref={labelRef}\n        >\n          {label}\n        </div>\n        {isEditing ? (\n          <input\n            autoFocus\n            className=\"z-10 min-w-0 flex-1 bg-transparent px-2 py-1.5 text-right font-medium font-mono text-foreground text-sm outline-none placeholder:text-muted-foreground/50\"\n            onBlur={handleInputBlur}\n            onChange={handleInputChange}\n            onKeyDown={handleInputKeyDown}\n            size={1}\n            type=\"text\"\n            value={inputValue}\n          />\n        ) : (\n          <div\n            className={\n              'z-10 min-w-0 flex-1 cursor-text truncate px-2 py-1.5 text-right font-medium font-mono text-foreground text-sm tabular-nums tracking-tight transition-colors hover:bg-black/5 dark:hover:bg-white/5'\n            }\n            onClick={handleValueClick}\n          >\n            <NumberFlow\n              format={{ minimumFractionDigits: precision, maximumFractionDigits: precision }}\n              value={Number(value.toFixed(precision))}\n            />\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/opacity-control.tsx",
    "content": "'use client'\n\nimport { Eye, EyeOff } from 'lucide-react'\nimport { useState } from 'react'\nimport { Button } from '../../../components/ui/primitives/button'\nimport { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/primitives/popover'\nimport { Slider } from '../../../components/ui/primitives/slider'\nimport { cn } from '../../../lib/utils'\n\ninterface OpacityControlProps {\n  visible?: boolean\n  opacity?: number\n  onVisibilityToggle: () => void\n  onOpacityChange: (opacity: number) => void\n  className?: string\n}\n\nexport function OpacityControl({\n  visible = true,\n  opacity = 100,\n  onVisibilityToggle,\n  onOpacityChange,\n  className,\n}: OpacityControlProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const actualOpacity = opacity ?? 100\n  const isHidden = visible === false || actualOpacity === 0\n\n  return (\n    <Popover onOpenChange={setIsOpen} open={isOpen}>\n      <div className={cn('flex items-center gap-1', className)}>\n        {!isHidden && actualOpacity < 100 && (\n          <span className=\"text-muted-foreground text-xs\">{actualOpacity}%</span>\n        )}\n        <PopoverTrigger asChild>\n          <Button\n            className={cn(\n              'h-5 w-5 p-0 transition-opacity',\n              isHidden ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100',\n            )}\n            onClick={(e) => {\n              e.stopPropagation()\n              // If clicking the button (not opening popover), toggle visibility\n              if (!isOpen) {\n                onVisibilityToggle()\n              }\n            }}\n            size=\"sm\"\n            variant=\"ghost\"\n          >\n            {isHidden ? <EyeOff className=\"h-3 w-3\" /> : <Eye className=\"h-3 w-3\" />}\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"end\"\n          className=\"w-48 p-3\"\n          onClick={(e) => e.stopPropagation()}\n          side=\"right\"\n        >\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <span className=\"font-medium text-sm\">Opacity</span>\n              <span className=\"text-muted-foreground text-xs\">{actualOpacity}%</span>\n            </div>\n            <Slider\n              max={100}\n              min={0}\n              onValueChange={(values: number[]) => {\n                if (values[0] !== undefined) onOpacityChange(values[0])\n              }}\n              step={1}\n              value={[actualOpacity]}\n            />\n          </div>\n        </PopoverContent>\n      </div>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/popover.tsx",
    "content": "'use client'\n\nimport * as PopoverPrimitive from '@radix-ui/react-popover'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        align={align}\n        className={cn(\n          'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',\n          className,\n        )}\n        data-slot=\"popover-content\"\n        sideOffset={sideOffset}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  )\n}\n\nfunction PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/separator.tsx",
    "content": "'use client'\n\nimport * as SeparatorPrimitive from '@radix-ui/react-separator'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Separator({\n  className,\n  orientation = 'horizontal',\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      className={cn(\n        'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',\n        className,\n      )}\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/sheet.tsx",
    "content": "'use client'\n\nimport * as SheetPrimitive from '@radix-ui/react-dialog'\nimport { XIcon } from 'lucide-react'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />\n}\n\nfunction SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />\n}\n\nfunction SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />\n}\n\nfunction SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      className={cn(\n        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',\n        className,\n      )}\n      data-slot=\"sheet-overlay\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = 'right',\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: 'top' | 'right' | 'bottom' | 'left'\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        className={cn(\n          'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500',\n          side === 'right' &&\n            'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n          side === 'left' &&\n            'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n          side === 'top' &&\n            'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n          side === 'bottom' &&\n            'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n          className,\n        )}\n        data-slot=\"sheet-content\"\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  )\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-1.5 p-4', className)}\n      data-slot=\"sheet-header\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('mt-auto flex flex-col gap-2 p-4', className)}\n      data-slot=\"sheet-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      className={cn('font-semibold text-foreground', className)}\n      data-slot=\"sheet-title\"\n      {...props}\n    />\n  )\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      className={cn('text-muted-foreground text-sm', className)}\n      data-slot=\"sheet-description\"\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/shortcut-token.tsx",
    "content": "import { Icon } from '@iconify/react'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nconst MOUSE_SHORTCUTS = {\n  Click: {\n    icon: 'ph:mouse-left-click-fill',\n    label: 'Left click',\n  },\n  'Left click': {\n    icon: 'ph:mouse-left-click-fill',\n    label: 'Left click',\n  },\n  'Middle click': {\n    icon: 'qlementine-icons:mouse-middle-button-16',\n    label: 'Middle click',\n  },\n  'Right click': {\n    icon: 'ph:mouse-right-click-fill',\n    label: 'Right click',\n  },\n} as const\n\ntype ShortcutTokenProps = React.ComponentProps<'kbd'> & {\n  value: string\n  displayValue?: string\n}\n\nfunction ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {\n  const mouseShortcut =\n    value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null\n\n  return (\n    <kbd\n      aria-label={mouseShortcut?.label ?? displayValue ?? value}\n      className={cn(\n        'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',\n        mouseShortcut && 'justify-center px-1.5',\n        className,\n      )}\n      title={mouseShortcut?.label ?? value}\n      {...props}\n    >\n      {mouseShortcut ? (\n        <>\n          <Icon\n            aria-hidden=\"true\"\n            className=\"shrink-0\"\n            color=\"currentColor\"\n            height={14}\n            icon={mouseShortcut.icon}\n            width={14}\n          />\n          <span className=\"sr-only\">{mouseShortcut.label}</span>\n        </>\n      ) : (\n        (displayValue ?? value)\n      )}\n    </kbd>\n  )\n}\n\nexport { ShortcutToken }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/sidebar.tsx",
    "content": "'use client'\n\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { PanelLeftIcon } from 'lucide-react'\nimport * as React from 'react'\nimport { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\nimport { Button } from './../../../components/ui/primitives/button'\nimport { Input } from './../../../components/ui/primitives/input'\nimport { Separator } from './../../../components/ui/primitives/separator'\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from './../../../components/ui/primitives/sheet'\nimport { Skeleton } from './../../../components/ui/primitives/skeleton'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from './../../../components/ui/primitives/tooltip'\nimport { useIsMobile } from './../../../hooks/use-mobile'\nimport { cn } from './../../../lib/utils'\n\nconst SIDEBAR_COOKIE_NAME = 'sidebar_state'\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = '18rem'\nconst SIDEBAR_WIDTH_MOBILE = '18rem'\nconst SIDEBAR_WIDTH_ICON = '3rem'\nconst SIDEBAR_KEYBOARD_SHORTCUT = 'b'\n\nconst SIDEBAR_COLLAPSE_THRESHOLD = 220\nconst SIDEBAR_MAX_WIDTH = 800\n\ntype SidebarStore = {\n  width: number\n  setWidth: (width: number) => void\n  isCollapsed: boolean\n  setIsCollapsed: (collapsed: boolean) => void\n  isDragging: boolean\n  setIsDragging: (isDragging: boolean) => void\n}\n\nexport const useSidebarStore = create<SidebarStore>()(\n  persist(\n    (set) => ({\n      width: 288, // 18rem = 288px\n      setWidth: (width) => {\n        if (width < SIDEBAR_COLLAPSE_THRESHOLD) {\n          set({ isCollapsed: true })\n        } else {\n          set({ width: Math.min(width, SIDEBAR_MAX_WIDTH), isCollapsed: false })\n        }\n      },\n      isCollapsed: false,\n      setIsCollapsed: (collapsed) => set({ isCollapsed: collapsed }),\n      isDragging: false,\n      setIsDragging: (isDragging) => set({ isDragging }),\n    }),\n    {\n      name: 'sidebar-preferences',\n      partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }),\n    },\n  ),\n)\n\ntype SidebarContextProps = {\n  state: 'expanded' | 'collapsed'\n  open: boolean\n  setOpen: (open: boolean) => void\n  openMobile: boolean\n  setOpenMobile: (open: boolean) => void\n  isMobile: boolean\n  toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null)\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext)\n  if (!context) {\n    throw new Error('useSidebar must be used within a SidebarProvider.')\n  }\n\n  return context\n}\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  defaultOpen?: boolean\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n}) {\n  const isMobile = useIsMobile()\n  const [openMobile, setOpenMobile] = React.useState(false)\n  const sidebarWidth = useSidebarStore((state) => state.width)\n  const isDragging = useSidebarStore((state) => state.isDragging)\n\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen)\n  const open = openProp ?? _open\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === 'function' ? value(open) : value\n      if (setOpenProp) {\n        setOpenProp(openState)\n      } else {\n        _setOpen(openState)\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n    },\n    [setOpenProp, open],\n  )\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(\n    () => (isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)),\n    [isMobile, setOpen],\n  )\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault()\n        toggleSidebar()\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [toggleSidebar])\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? 'expanded' : 'collapsed'\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n    }),\n    [state, open, setOpen, isMobile, openMobile, toggleSidebar],\n  )\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <TooltipProvider delayDuration={0}>\n        <div\n          className={cn(\n            'group/sidebar-wrapper pointer-events-none flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',\n            className,\n          )}\n          data-dragging={isDragging}\n          data-slot=\"sidebar-wrapper\"\n          style={\n            {\n              '--sidebar-width': `${sidebarWidth}px`,\n              '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as React.CSSProperties\n          }\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  )\n}\n\nfunction SidebarResizer({ side }: { side: 'left' | 'right' }) {\n  const setWidth = useSidebarStore((state) => state.setWidth)\n  const setIsDragging = useSidebarStore((state) => state.setIsDragging)\n  const isResizing = React.useRef(false)\n\n  const handlePointerDown = (e: React.PointerEvent) => {\n    isResizing.current = true\n    setIsDragging(true)\n    document.body.style.cursor = 'col-resize'\n    document.body.style.userSelect = 'none'\n  }\n\n  React.useEffect(() => {\n    const handlePointerMove = (e: PointerEvent) => {\n      if (!isResizing.current) return\n      const newWidth = side === 'left' ? e.clientX : window.innerWidth - e.clientX\n      setWidth(Math.max(288, Math.min(newWidth, 800)))\n    }\n\n    const handlePointerUp = () => {\n      isResizing.current = false\n      setIsDragging(false)\n      document.body.style.cursor = ''\n      document.body.style.userSelect = ''\n    }\n\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n    }\n  }, [setWidth, side, setIsDragging])\n\n  return (\n    <div\n      className={cn(\n        'absolute top-0 bottom-0 z-50 w-2 cursor-col-resize transition-colors hover:bg-primary/50',\n        side === 'left' ? '-right-1' : '-left-1',\n      )}\n      onPointerDown={handlePointerDown}\n    />\n  )\n}\n\nfunction Sidebar({\n  side = 'left',\n  variant = 'sidebar',\n  collapsible = 'offcanvas',\n  className,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  side?: 'left' | 'right'\n  variant?: 'sidebar' | 'floating' | 'inset'\n  collapsible?: 'offcanvas' | 'icon' | 'none'\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n  if (collapsible === 'none') {\n    return (\n      <div\n        className={cn(\n          'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',\n          className,\n        )}\n        data-slot=\"sidebar\"\n        {...props}\n      >\n        {children}\n      </div>\n    )\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>\n        <SheetContent\n          className=\"w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n          data-mobile=\"true\"\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar\"\n          side={side}\n          style={\n            {\n              '--sidebar-width': SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    )\n  }\n\n  return (\n    <div\n      className=\"group peer hidden text-sidebar-foreground md:block\"\n      data-collapsible={state === 'collapsed' ? collapsible : ''}\n      data-side={side}\n      data-slot=\"sidebar\"\n      data-state={state}\n      data-variant={variant}\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        className={cn(\n          'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',\n          'group-data-[dragging=true]/sidebar-wrapper:transition-none',\n          'group-data-[collapsible=offcanvas]:w-0',\n          'group-data-[side=right]:rotate-180',\n          variant === 'floating' || variant === 'inset'\n            ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'\n            : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',\n        )}\n        data-slot=\"sidebar-gap\"\n      />\n      <div\n        className={cn(\n          'pointer-events-auto fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',\n          'group-data-[dragging=true]/sidebar-wrapper:transition-none',\n          side === 'left'\n            ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'\n            : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',\n          // Adjust the padding for floating and inset variants.\n          variant === 'floating' || variant === 'inset'\n            ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'\n            : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',\n          className,\n        )}\n        data-slot=\"sidebar-container\"\n        {...props}\n      >\n        <div\n          className=\"pointer-events-auto relative flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm\"\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n        >\n          {children}\n          <SidebarResizer side={side} />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <Button\n      className={cn('size-7', className)}\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      onClick={(event) => {\n        onClick?.(event)\n        toggleSidebar()\n      }}\n      size=\"icon\"\n      variant=\"ghost\"\n      {...props}\n    >\n      <PanelLeftIcon />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  )\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <button\n      aria-label=\"Toggle Sidebar\"\n      className={cn(\n        'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',\n        'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',\n        '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',\n        'group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full',\n        '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',\n        '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',\n        className,\n      )}\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      onClick={toggleSidebar}\n      tabIndex={-1}\n      title=\"Toggle Sidebar\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {\n  return (\n    <main\n      className={cn(\n        'relative flex w-full flex-1 flex-col bg-background',\n        'md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',\n        className,\n      )}\n      data-slot=\"sidebar-inset\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      className={cn('h-8 w-full bg-background shadow-none', className)}\n      data-sidebar=\"input\"\n      data-slot=\"sidebar-input\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-2 p-2', className)}\n      data-sidebar=\"header\"\n      data-slot=\"sidebar-header\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('flex flex-col gap-2 p-2', className)}\n      data-sidebar=\"footer\"\n      data-slot=\"sidebar-footer\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      className={cn('mx-2 w-auto bg-sidebar-border', className)}\n      data-sidebar=\"separator\"\n      data-slot=\"sidebar-separator\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn(\n        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',\n        className,\n      )}\n      data-sidebar=\"content\"\n      data-slot=\"sidebar-content\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('relative flex w-full min-w-0 flex-col p-2', className)}\n      data-sidebar=\"group\"\n      data-slot=\"sidebar-group\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ref,\n  ...props\n}: React.ComponentProps<'div'> & { asChild?: boolean }) {\n  if (asChild) {\n    return (\n      <Slot\n        className={cn(\n          'flex h-8 shrink-0 items-center rounded-md px-2 font-barlow font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n          'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',\n          className,\n        )}\n        data-sidebar=\"group-label\"\n        data-slot=\"sidebar-group-label\"\n        ref={ref as never}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <div\n      className={cn(\n        'flex h-8 shrink-0 items-center rounded-md px-2 font-barlow font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',\n        className,\n      )}\n      data-sidebar=\"group-label\"\n      data-slot=\"sidebar-group-label\"\n      ref={ref}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ref,\n  ...props\n}: React.ComponentProps<'button'> & { asChild?: boolean }) {\n  const classes = cn(\n    'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n    'after:absolute after:-inset-2 md:after:hidden',\n    'group-data-[collapsible=icon]:hidden',\n    className,\n  )\n\n  if (asChild) {\n    return (\n      <Slot\n        className={classes}\n        data-sidebar=\"group-action\"\n        data-slot=\"sidebar-group-action\"\n        ref={ref as never}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <button\n      className={classes}\n      data-sidebar=\"group-action\"\n      data-slot=\"sidebar-group-action\"\n      ref={ref}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('w-full text-sm', className)}\n      data-sidebar=\"group-content\"\n      data-slot=\"sidebar-group-content\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      className={cn('flex w-full min-w-0 flex-col gap-1', className)}\n      data-sidebar=\"menu\"\n      data-slot=\"sidebar-menu\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      className={cn('group/menu-item relative', className)}\n      data-sidebar=\"menu-item\"\n      data-slot=\"sidebar-menu-item\"\n      {...props}\n    />\n  )\n}\n\nconst sidebarMenuButtonVariants = cva(\n  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left font-barlow text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',\n        outline:\n          'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',\n      },\n      size: {\n        default: 'h-8 text-sm',\n        sm: 'h-7 text-xs',\n        lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n)\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = 'default',\n  size = 'default',\n  tooltip,\n  className,\n  ref,\n  ...props\n}: React.ComponentProps<'button'> & {\n  asChild?: boolean\n  isActive?: boolean\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const { isMobile, state } = useSidebar()\n  const classes = cn(sidebarMenuButtonVariants({ variant, size }), className)\n\n  const button = asChild ? (\n    <Slot\n      className={classes}\n      data-active={isActive}\n      data-sidebar=\"menu-button\"\n      data-size={size}\n      data-slot=\"sidebar-menu-button\"\n      ref={ref as never}\n      {...props}\n    />\n  ) : (\n    <button\n      className={classes}\n      data-active={isActive}\n      data-sidebar=\"menu-button\"\n      data-size={size}\n      data-slot=\"sidebar-menu-button\"\n      ref={ref}\n      {...props}\n    />\n  )\n\n  if (!tooltip) {\n    return button\n  }\n\n  if (typeof tooltip === 'string') {\n    tooltip = {\n      children: tooltip,\n    }\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{button}</TooltipTrigger>\n      <TooltipContent\n        align=\"center\"\n        hidden={state !== 'collapsed' || isMobile}\n        side=\"right\"\n        {...tooltip}\n      />\n    </Tooltip>\n  )\n}\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ref,\n  ...props\n}: React.ComponentProps<'button'> & {\n  asChild?: boolean\n  showOnHover?: boolean\n}) {\n  const classes = cn(\n    'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',\n    'after:absolute after:-inset-2 md:after:hidden',\n    'peer-data-[size=sm]/menu-button:top-1',\n    'peer-data-[size=default]/menu-button:top-1.5',\n    'peer-data-[size=lg]/menu-button:top-2.5',\n    'group-data-[collapsible=icon]:hidden',\n    showOnHover &&\n      'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',\n    className,\n  )\n\n  if (asChild) {\n    return (\n      <Slot\n        className={classes}\n        data-sidebar=\"menu-action\"\n        data-slot=\"sidebar-menu-action\"\n        ref={ref as never}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <button\n      className={classes}\n      data-sidebar=\"menu-action\"\n      data-slot=\"sidebar-menu-action\"\n      ref={ref}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn(\n        'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums',\n        'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',\n        'peer-data-[size=sm]/menu-button:top-1',\n        'peer-data-[size=default]/menu-button:top-1.5',\n        'peer-data-[size=lg]/menu-button:top-2.5',\n        'group-data-[collapsible=icon]:hidden',\n        className,\n      )}\n      data-sidebar=\"menu-badge\"\n      data-slot=\"sidebar-menu-badge\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<'div'> & {\n  showIcon?: boolean\n}) {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, [])\n\n  return (\n    <div\n      className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}\n      data-sidebar=\"menu-skeleton\"\n      data-slot=\"sidebar-menu-skeleton\"\n      {...props}\n    >\n      {showIcon && <Skeleton className=\"size-4 rounded-md\" data-sidebar=\"menu-skeleton-icon\" />}\n      <Skeleton\n        className=\"h-4 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            '--skeleton-width': width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  )\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      className={cn(\n        'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5',\n        'group-data-[collapsible=icon]:hidden',\n        className,\n      )}\n      data-sidebar=\"menu-sub\"\n      data-slot=\"sidebar-menu-sub\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      className={cn('group/menu-sub-item relative', className)}\n      data-sidebar=\"menu-sub-item\"\n      data-slot=\"sidebar-menu-sub-item\"\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = 'md',\n  isActive = false,\n  className,\n  ref,\n  ...props\n}: React.ComponentProps<'a'> & {\n  asChild?: boolean\n  size?: 'sm' | 'md'\n  isActive?: boolean\n}) {\n  const classes = cn(\n    'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 font-barlow text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',\n    'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',\n    size === 'sm' && 'text-xs',\n    size === 'md' && 'text-sm',\n    'group-data-[collapsible=icon]:hidden',\n    className,\n  )\n\n  if (asChild) {\n    return (\n      <Slot\n        className={classes}\n        data-active={isActive}\n        data-sidebar=\"menu-sub-button\"\n        data-size={size}\n        data-slot=\"sidebar-menu-sub-button\"\n        ref={ref as never}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <a\n      className={classes}\n      data-active={isActive}\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-slot=\"sidebar-menu-sub-button\"\n      ref={ref}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/skeleton.tsx",
    "content": "import { cn } from '../../../lib/utils'\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      className={cn('animate-pulse rounded-md bg-accent', className)}\n      data-slot=\"skeleton\"\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/slider.tsx",
    "content": "'use client'\n\nimport * as SliderPrimitive from '@radix-ui/react-slider'\nimport * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction Slider({\n  className,\n  defaultValue,\n  value,\n  min = 0,\n  max = 100,\n  ...props\n}: React.ComponentProps<typeof SliderPrimitive.Root>) {\n  const _values = React.useMemo(\n    () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),\n    [value, defaultValue, min, max],\n  )\n\n  return (\n    <SliderPrimitive.Root\n      className={cn(\n        'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',\n        className,\n      )}\n      data-slot=\"slider\"\n      defaultValue={defaultValue}\n      max={max}\n      min={min}\n      value={value}\n      {...props}\n    >\n      <SliderPrimitive.Track\n        className={cn(\n          'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',\n        )}\n        data-slot=\"slider-track\"\n      >\n        <SliderPrimitive.Range\n          className={cn(\n            'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',\n          )}\n          data-slot=\"slider-range\"\n        />\n      </SliderPrimitive.Track>\n      {Array.from({ length: _values.length }, (_, index) => (\n        <SliderPrimitive.Thumb\n          className=\"block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50\"\n          data-slot=\"slider-thumb\"\n          key={index}\n        />\n      ))}\n    </SliderPrimitive.Root>\n  )\n}\n\nexport { Slider }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/switch.tsx",
    "content": "'use client'\n\nimport * as SwitchPrimitives from '@radix-ui/react-switch'\nimport * as React from 'react'\n\nimport { cn } from './../../../lib/utils'\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n      className,\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',\n      )}\n    />\n  </SwitchPrimitives.Root>\n))\nSwitch.displayName = SwitchPrimitives.Root.displayName\n\nexport { Switch }\n"
  },
  {
    "path": "packages/editor/src/components/ui/primitives/tooltip.tsx",
    "content": "'use client'\n\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip'\nimport type * as React from 'react'\n\nimport { cn } from '../../../lib/utils'\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        className={cn(\n          'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 font-barlow text-background text-xs data-[state=closed]:animate-out',\n          className,\n        )}\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "packages/editor/src/components/ui/scene-loader.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { cn } from '../../lib/utils'\n\nconst LOADERS = [\n  'pascal-loader-1',\n  'pascal-loader-2',\n  'pascal-loader-3',\n  'pascal-loader-4',\n  'pascal-loader-5',\n]\n\ninterface SceneLoaderProps {\n  className?: string\n  fullScreen?: boolean\n}\n\nexport function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {\n  const [loaderClass, setLoaderClass] = useState<string | null>(null)\n\n  useEffect(() => {\n    // Pick a random loader on mount\n    setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)\n  }, [])\n\n  if (!loaderClass) return null\n\n  return (\n    <div\n      className={cn(\n        'z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300',\n        fullScreen ? 'fixed inset-0' : 'absolute inset-0',\n        className,\n      )}\n    >\n      <div className={cn(loaderClass, 'text-foreground opacity-80')} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/app-sidebar.tsx",
    "content": "'use client'\n\nimport { type ReactNode, useEffect, useState } from 'react'\nimport { CommandPalette } from './../../../components/ui/command-palette'\nimport { EditorCommands } from './../../../components/ui/command-palette/editor-commands'\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarHeader,\n  useSidebarStore,\n} from './../../../components/ui/primitives/sidebar'\nimport { cn } from './../../../lib/utils'\nimport { IconRail, type PanelId } from './icon-rail'\nimport { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'\nimport { SitePanel, type SitePanelProps } from './panels/site-panel'\n\ninterface AppSidebarProps {\n  appMenuButton?: ReactNode\n  sidebarTop?: ReactNode\n  settingsPanelProps?: SettingsPanelProps\n  sitePanelProps?: SitePanelProps\n}\n\nexport function AppSidebar({\n  appMenuButton,\n  sidebarTop,\n  settingsPanelProps,\n  sitePanelProps,\n}: AppSidebarProps) {\n  const [activePanel, setActivePanel] = useState<PanelId>('site')\n\n  useEffect(() => {\n    // Widen default sidebar (288px → 432px) for better project title visibility\n    const store = useSidebarStore.getState()\n    if (store.width <= 288) {\n      store.setWidth(432)\n    }\n  }, [])\n\n  const renderPanelContent = () => {\n    switch (activePanel) {\n      case 'site':\n        return <SitePanel {...sitePanelProps} />\n      case 'settings':\n        return <SettingsPanel {...settingsPanelProps} />\n      default:\n        return null\n    }\n  }\n\n  return (\n    <>\n      <Sidebar className={cn('dark text-white')} variant=\"floating\">\n        <div className=\"flex h-full\">\n          {/* Icon Rail */}\n          <IconRail\n            activePanel={activePanel}\n            appMenuButton={appMenuButton}\n            onPanelChange={setActivePanel}\n          />\n\n          {/* Panel Content */}\n          <div className=\"flex flex-1 flex-col overflow-hidden\">\n            {sidebarTop && (\n              <SidebarHeader className=\"relative flex-col items-start justify-center gap-1 border-border/50 border-b px-3 py-3\">\n                {sidebarTop}\n              </SidebarHeader>\n            )}\n\n            <SidebarContent className={cn('no-scrollbar flex flex-1 flex-col overflow-hidden')}>\n              {renderPanelContent()}\n            </SidebarContent>\n          </div>\n        </div>\n      </Sidebar>\n      <EditorCommands />\n      <CommandPalette />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/icon-rail.tsx",
    "content": "'use client'\n\nimport { useViewer } from '@pascal-app/viewer'\nimport { Moon, Ruler, Sun } from 'lucide-react'\nimport { motion } from 'motion/react'\nimport { type ReactNode, useEffect, useState } from 'react'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from './../../../components/ui/primitives/tooltip'\nimport { cn } from './../../../lib/utils'\n\nexport type PanelId = 'site' | 'settings'\n\ninterface IconRailProps {\n  activePanel: PanelId\n  onPanelChange: (panel: PanelId) => void\n  appMenuButton?: ReactNode\n  className?: string\n}\n\nconst panels: { id: PanelId; iconSrc: string; label: string }[] = [\n  { id: 'site', iconSrc: '/icons/level.png', label: 'Site' },\n  { id: 'settings', iconSrc: '/icons/settings.png', label: 'Settings' },\n]\n\nexport function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) {\n  const theme = useViewer((state) => state.theme)\n  const setTheme = useViewer((state) => state.setTheme)\n  const unit = useViewer((state) => state.unit)\n  const setUnit = useViewer((state) => state.setUnit)\n  const [mounted, setMounted] = useState(false)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  return (\n    <div\n      className={cn(\n        'flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2',\n        className,\n      )}\n    >\n      {/* App menu slot */}\n      {appMenuButton}\n\n      {/* Divider */}\n      <div className=\"mb-1 h-px w-8 bg-border/50\" />\n\n      {panels.map((panel) => {\n        const isActive = activePanel === panel.id\n        return (\n          <Tooltip key={panel.id}>\n            <TooltipTrigger asChild>\n              <button\n                className={cn(\n                  'flex h-9 w-9 items-center justify-center rounded-lg transition-all',\n                  isActive ? 'bg-accent' : 'hover:bg-accent',\n                )}\n                onClick={() => onPanelChange(panel.id)}\n                type=\"button\"\n              >\n                <img\n                  alt={panel.label}\n                  className={cn(\n                    'h-6 w-6 object-contain transition-all',\n                    !isActive && 'opacity-50 saturate-0',\n                  )}\n                  src={panel.iconSrc}\n                />\n              </button>\n            </TooltipTrigger>\n            <TooltipContent side=\"right\">{panel.label}</TooltipContent>\n          </Tooltip>\n        )\n      })}\n\n      {/* Spacer */}\n      <div className=\"flex-1\" />\n\n      {/* Unit Toggle */}\n      {mounted && (\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <button\n              className=\"mb-1 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent\"\n              onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}\n              type=\"button\"\n            >\n              <div className=\"flex h-full w-full flex-col items-center justify-center gap-0.5 font-medium text-[10px] leading-none\">\n                {unit === 'metric' ? 'm' : 'ft'}\n              </div>\n            </button>\n          </TooltipTrigger>\n          <TooltipContent side=\"right\">Toggle units (metric/imperial)</TooltipContent>\n        </Tooltip>\n      )}\n\n      {/* Theme Toggle */}\n      {mounted && (\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <button\n              className=\"mb-2 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent\"\n              onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}\n              type=\"button\"\n            >\n              <motion.div\n                animate={{ rotate: 0, opacity: 1 }}\n                initial={{ rotate: -90, opacity: 0 }}\n                key={theme}\n                transition={{ duration: 0.25, ease: 'easeOut' }}\n              >\n                {theme === 'dark' ? <Sun className=\"h-4 w-4\" /> : <Moon className=\"h-4 w-4\" />}\n              </motion.div>\n            </button>\n          </TooltipTrigger>\n          <TooltipContent side=\"right\">Toggle theme</TooltipContent>\n        </Tooltip>\n      )}\n    </div>\n  )\n}\n\nexport { panels }\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx",
    "content": "import { Volume2, VolumeX } from 'lucide-react'\nimport { Button } from '../../../../../components/ui/primitives/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '../../../../../components/ui/primitives/dialog'\nimport { Slider } from '../../../../../components/ui/slider'\nimport useAudio from '../../../../../store/use-audio'\n\nexport function AudioSettingsDialog() {\n  const {\n    masterVolume,\n    sfxVolume,\n    radioVolume,\n    muted,\n    setMasterVolume,\n    setSfxVolume,\n    setRadioVolume,\n    toggleMute,\n  } = useAudio()\n\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button className=\"w-full justify-start gap-2\" variant=\"outline\">\n          {muted ? <VolumeX className=\"size-4\" /> : <Volume2 className=\"size-4\" />}\n          Audio Settings\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <DialogTitle>Audio Settings</DialogTitle>\n          <DialogDescription>Adjust volume levels and mute settings</DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-6 py-4\">\n          {/* Master Volume */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <label className=\"font-medium text-sm\">Master Volume</label>\n              <span className=\"text-muted-foreground text-sm\">{masterVolume}%</span>\n            </div>\n            <Slider\n              disabled={muted}\n              max={100}\n              onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}\n              step={1}\n              value={[masterVolume]}\n            />\n          </div>\n\n          {/* Radio Volume */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <label className=\"font-medium text-sm\">Radio Volume</label>\n              <span className=\"text-muted-foreground text-sm\">{radioVolume}%</span>\n            </div>\n            <Slider\n              disabled={muted}\n              max={100}\n              onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}\n              step={1}\n              value={[radioVolume]}\n            />\n          </div>\n\n          {/* SFX Volume */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <label className=\"font-medium text-sm\">Sound Effects</label>\n              <span className=\"text-muted-foreground text-sm\">{sfxVolume}%</span>\n            </div>\n            <Slider\n              disabled={muted}\n              max={100}\n              onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}\n              step={1}\n              value={[sfxVolume]}\n            />\n          </div>\n\n          {/* Mute Toggle */}\n          <div className=\"border-t pt-4\">\n            <Button\n              className=\"w-full justify-start gap-2\"\n              onClick={toggleMute}\n              variant={muted ? 'default' : 'outline'}\n            >\n              {muted ? <VolumeX className=\"size-4\" /> : <Volume2 className=\"size-4\" />}\n              {muted ? 'Unmute All Sounds' : 'Mute All Sounds'}\n            </Button>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx",
    "content": "import { emitter, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { TreeView, VisualJson } from '@visual-json/react'\nimport { Camera, Download, Save, Trash2, Upload } from 'lucide-react'\nimport {\n  type KeyboardEvent,\n  type SyntheticEvent,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\nimport { Button } from './../../../../../components/ui/primitives/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogTitle,\n  DialogTrigger,\n} from './../../../../../components/ui/primitives/dialog'\nimport { Switch } from './../../../../../components/ui/primitives/switch'\nimport useEditor from './../../../../../store/use-editor'\nimport { AudioSettingsDialog } from './audio-settings-dialog'\nimport { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'\n\ntype SceneNode = Record<string, unknown> & {\n  id?: unknown\n  type?: unknown\n  name?: unknown\n  parentId?: unknown\n  children?: unknown\n}\n\ntype SceneGraphNode = {\n  id: string\n  type: string\n  name: string | null\n  parentId: string | null\n  children: SceneGraphNode[]\n  missing?: true\n  cycle?: true\n}\n\ntype SceneGraphValue = {\n  roots: SceneGraphNode[]\n  detachedNodes?: SceneGraphNode[]\n}\n\nconst isSceneNode = (value: unknown): value is SceneNode => {\n  return (\n    typeof value === 'object' &&\n    value !== null &&\n    'id' in value &&\n    typeof (value as { id: unknown }).id === 'string'\n  )\n}\n\nconst getChildIdsFromNode = (node: SceneNode): string[] => {\n  if (!Array.isArray(node.children)) {\n    return []\n  }\n\n  const childIds = new Set<string>()\n\n  for (const child of node.children) {\n    if (typeof child === 'string') {\n      childIds.add(child)\n      continue\n    }\n\n    if (isSceneNode(child)) {\n      childIds.add(child.id as string)\n    }\n  }\n\n  return Array.from(childIds)\n}\n\nconst buildSceneGraphValue = (\n  nodes: Record<string, SceneNode>,\n  rootNodeIds: string[],\n): SceneGraphValue => {\n  const childIdsByParent = new Map<string, Set<string>>()\n\n  for (const [id, node] of Object.entries(nodes)) {\n    const childIds = getChildIdsFromNode(node)\n    if (childIds.length > 0) {\n      childIdsByParent.set(id, new Set(childIds))\n    }\n  }\n\n  for (const [id, node] of Object.entries(nodes)) {\n    if (typeof node.parentId !== 'string') {\n      continue\n    }\n\n    const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>()\n    siblings.add(id)\n    childIdsByParent.set(node.parentId, siblings)\n  }\n\n  const visited = new Set<string>()\n\n  const buildNode = (id: string, path: Set<string>): SceneGraphNode => {\n    const node = nodes[id]\n    if (!node) {\n      return {\n        id,\n        type: 'missing',\n        name: null,\n        parentId: null,\n        missing: true,\n        children: [],\n      }\n    }\n\n    const nodeType = typeof node.type === 'string' ? node.type : 'unknown'\n    const nodeName = typeof node.name === 'string' ? node.name : null\n    const parentId = typeof node.parentId === 'string' ? node.parentId : null\n\n    if (path.has(id)) {\n      return {\n        id,\n        type: nodeType,\n        name: nodeName,\n        parentId,\n        cycle: true,\n        children: [],\n      }\n    }\n\n    visited.add(id)\n    const nextPath = new Set(path)\n    nextPath.add(id)\n\n    const childIds = Array.from(childIdsByParent.get(id) ?? [])\n    return {\n      id,\n      type: nodeType,\n      name: nodeName,\n      parentId,\n      children: childIds.map((childId) => buildNode(childId, nextPath)),\n    }\n  }\n\n  const roots = rootNodeIds.map((id) => buildNode(id, new Set()))\n  const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id))\n\n  if (detachedNodeIds.length === 0) {\n    return { roots }\n  }\n\n  return {\n    roots,\n    detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())),\n  }\n}\n\nexport interface ProjectVisibility {\n  isPrivate: boolean\n  showScansPublic: boolean\n  showGuidesPublic: boolean\n}\n\nexport interface SettingsPanelProps {\n  projectId?: string\n  projectVisibility?: ProjectVisibility\n  onVisibilityChange?: (\n    field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',\n    value: boolean,\n  ) => Promise<void>\n}\n\nexport function SettingsPanel({\n  projectId,\n  projectVisibility,\n  onVisibilityChange,\n}: SettingsPanelProps = {}) {\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const nodes = useScene((state) => state.nodes)\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n  const setScene = useScene((state) => state.setScene)\n  const clearScene = useScene((state) => state.clearScene)\n  const resetSelection = useViewer((state) => state.resetSelection)\n  const exportScene = useViewer((state) => state.exportScene)\n  const showGrid = useViewer((state) => state.showGrid)\n  const setPhase = useEditor((state) => state.setPhase)\n  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)\n  const sceneGraphValue = useMemo(\n    () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),\n    [nodes, rootNodeIds],\n  )\n  const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => {\n    event.preventDefault()\n    event.stopPropagation()\n  }, [])\n  const blockSceneGraphDeletion = useCallback((event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === 'Delete' || event.key === 'Backspace') {\n      event.preventDefault()\n      event.stopPropagation()\n    }\n  }, [])\n\n  const isLocalProject = false // Props-based; only show cloud sections when projectId provided\n\n  const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {\n    if (exportScene) {\n      await exportScene(format)\n    }\n  }\n\n  const handleSaveBuild = () => {\n    const sceneData = { nodes, rootNodeIds }\n    const json = JSON.stringify(sceneData, null, 2)\n    const blob = new Blob([json], { type: 'application/json' })\n    const url = URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    const date = new Date().toISOString().split('T')[0]\n    link.download = `layout_${date}.json`\n    link.click()\n    URL.revokeObjectURL(url)\n  }\n\n  const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0]\n    if (!file) return\n\n    const reader = new FileReader()\n    reader.onload = (event) => {\n      try {\n        const data = JSON.parse(event.target?.result as string)\n        if (data.nodes && data.rootNodeIds) {\n          setScene(data.nodes, data.rootNodeIds)\n          resetSelection()\n          setPhase('site')\n        }\n      } catch (err) {\n        console.error('Failed to load build:', err)\n      }\n    }\n    reader.readAsText(file)\n\n    // Reset input so the same file can be loaded again\n    e.target.value = ''\n  }\n\n  const handleResetToDefault = () => {\n    clearScene()\n    resetSelection()\n    setPhase('site')\n  }\n\n  const handleGenerateThumbnail = () => {\n    if (!projectId) return\n    setIsGeneratingThumbnail(true)\n    emitter.emit('camera-controls:generate-thumbnail', { projectId })\n    setTimeout(() => setIsGeneratingThumbnail(false), 3000)\n  }\n\n  const handleVisibilityChange = async (\n    field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',\n    value: boolean,\n  ) => {\n    await onVisibilityChange?.(field, value)\n  }\n\n  return (\n    <div className=\"flex flex-col gap-6 p-3\">\n      {/* Visibility Section (only for cloud projects) */}\n      {projectId && !isLocalProject && (\n        <div className=\"space-y-3\">\n          <label className=\"font-medium text-muted-foreground text-xs uppercase\">Visibility</label>\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <div className=\"font-medium text-sm\">Public</div>\n              <div className=\"text-muted-foreground text-xs\">\n                {projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view\n              </div>\n            </div>\n            <Switch\n              checked={!(projectVisibility?.isPrivate ?? false)}\n              onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}\n            />\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <div className=\"font-medium text-sm\">Show 3D Scans</div>\n              <div className=\"text-muted-foreground text-xs\">Visible to public viewers</div>\n            </div>\n            <Switch\n              checked={projectVisibility?.showScansPublic ?? true}\n              onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}\n            />\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <div className=\"font-medium text-sm\">Show Floorplans</div>\n              <div className=\"text-muted-foreground text-xs\">Visible to public viewers</div>\n            </div>\n            <Switch\n              checked={projectVisibility?.showGuidesPublic ?? true}\n              onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}\n            />\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <div className=\"font-medium text-sm\">Show Grid</div>\n              <div className=\"text-muted-foreground text-xs\">Visible only in the editor</div>\n            </div>\n            <Switch\n              checked={showGrid}\n              onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* Export Section */}\n      <div className=\"space-y-2\">\n        <label className=\"font-medium text-muted-foreground text-xs uppercase\">Export</label>\n        <Button className=\"w-full justify-start gap-2\" onClick={() => handleExport('glb')} variant=\"outline\">\n          <Download className=\"size-4\" />\n          Export as GLB\n        </Button>\n        <Button className=\"w-full justify-start gap-2\" onClick={() => handleExport('stl')} variant=\"outline\">\n          <Download className=\"size-4\" />\n          Export as STL\n        </Button>\n        <Button className=\"w-full justify-start gap-2\" onClick={() => handleExport('obj')} variant=\"outline\">\n          <Download className=\"size-4\" />\n          Export as OBJ\n        </Button>\n      </div>\n\n      {/* Thumbnail Section (only for cloud projects) */}\n      {projectId && !isLocalProject && (\n        <div className=\"space-y-2\">\n          <label className=\"font-medium text-muted-foreground text-xs uppercase\">Thumbnail</label>\n          <Button\n            className=\"w-full justify-start gap-2\"\n            disabled={isGeneratingThumbnail}\n            onClick={handleGenerateThumbnail}\n            variant=\"outline\"\n          >\n            <Camera className=\"size-4\" />\n            {isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}\n          </Button>\n        </div>\n      )}\n\n      {/* Save/Load Section */}\n      <div className=\"space-y-2\">\n        <label className=\"font-medium text-muted-foreground text-xs uppercase\">Save & Load</label>\n\n        <Button className=\"w-full justify-start gap-2\" onClick={handleSaveBuild} variant=\"outline\">\n          <Save className=\"size-4\" />\n          Save Build\n        </Button>\n\n        <Button\n          className=\"w-full justify-start gap-2\"\n          onClick={() => fileInputRef.current?.click()}\n          variant=\"outline\"\n        >\n          <Upload className=\"size-4\" />\n          Load Build\n        </Button>\n\n        <input\n          accept=\"application/json\"\n          className=\"hidden\"\n          onChange={handleFileLoad}\n          ref={fileInputRef}\n          type=\"file\"\n        />\n      </div>\n\n      {/* Audio Section */}\n      <div className=\"space-y-2\">\n        <label className=\"font-medium text-muted-foreground text-xs uppercase\">Audio</label>\n        <AudioSettingsDialog />\n      </div>\n\n      {/* Keyboard Section */}\n      <div className=\"space-y-2\">\n        <label className=\"font-medium text-muted-foreground text-xs uppercase\">Keyboard</label>\n        <KeyboardShortcutsDialog />\n      </div>\n\n      {/* Scene Graph */}\n      <div className=\"space-y-1\">\n        <label className=\"font-medium text-muted-foreground text-xs uppercase\">Scene Graph</label>\n        <Dialog>\n          <DialogTrigger asChild>\n            <Button className=\"h-auto justify-start p-0 text-sm\" variant=\"link\">\n              Explore scene graph\n            </Button>\n          </DialogTrigger>\n          <DialogContent className=\"h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl\">\n            <DialogTitle className=\"sr-only\">Scene Graph</DialogTitle>\n            <div\n              className=\"flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto\"\n              onContextMenuCapture={blockSceneGraphMutations}\n              onDragStartCapture={blockSceneGraphMutations}\n              onDropCapture={blockSceneGraphMutations}\n              onKeyDownCapture={blockSceneGraphDeletion}\n            >\n              <VisualJson value={sceneGraphValue}>\n                <TreeView showCounts />\n              </VisualJson>\n            </div>\n          </DialogContent>\n        </Dialog>\n      </div>\n\n      {/* Danger Zone */}\n      <div className=\"space-y-2\">\n        <label className=\"font-medium text-destructive text-xs uppercase\">Danger Zone</label>\n\n        <Button\n          className=\"w-full justify-start gap-2\"\n          onClick={handleResetToDefault}\n          variant=\"destructive\"\n        >\n          <Trash2 className=\"size-4\" />\n          Clear & Start New\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx",
    "content": "import { Keyboard } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport { Button } from './../../../../../components/ui/primitives/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from './../../../../../components/ui/primitives/dialog'\nimport { ShortcutToken } from './../../../../../components/ui/primitives/shortcut-token'\n\ntype Shortcut = {\n  keys: string[]\n  action: string\n  note?: string\n}\n\ntype ShortcutCategory = {\n  title: string\n  shortcuts: Shortcut[]\n}\n\nconst KEY_DISPLAY_MAP: Record<string, string> = {\n  'Arrow Up': '↑',\n  'Arrow Down': '↓',\n  Esc: '⎋',\n  Shift: '⇧',\n  Space: '␣',\n}\n\nconst SHORTCUT_CATEGORIES: ShortcutCategory[] = [\n  {\n    title: 'Editor Navigation',\n    shortcuts: [\n      { keys: ['1'], action: 'Switch to Site phase' },\n      { keys: ['2'], action: 'Switch to Structure phase' },\n      { keys: ['3'], action: 'Switch to Furnish phase' },\n      { keys: ['S'], action: 'Switch to Structure layer' },\n      { keys: ['F'], action: 'Switch to Furnish layer' },\n      { keys: ['Z'], action: 'Switch to Zones layer' },\n      {\n        keys: ['Cmd/Ctrl', 'Arrow Up'],\n        action: 'Select next level in the active building',\n      },\n      {\n        keys: ['Cmd/Ctrl', 'Arrow Down'],\n        action: 'Select previous level in the active building',\n      },\n      { keys: ['Cmd/Ctrl', 'B'], action: 'Toggle sidebar' },\n    ],\n  },\n  {\n    title: 'Modes & History',\n    shortcuts: [\n      { keys: ['V'], action: 'Switch to Select mode' },\n      { keys: ['B'], action: 'Switch to Build mode' },\n      {\n        keys: ['Esc'],\n        action: 'Cancel the active tool and return to Select mode',\n      },\n      { keys: ['Delete / Backspace'], action: 'Delete selected objects' },\n      { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },\n      { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' },\n    ],\n  },\n  {\n    title: 'Selection',\n    shortcuts: [\n      {\n        keys: ['Cmd/Ctrl', 'Left click'],\n        action: 'Add or remove an object from multi-selection',\n        note: 'Works while in Select mode.',\n      },\n    ],\n  },\n  {\n    title: 'Drawing Tools',\n    shortcuts: [\n      {\n        keys: ['Shift'],\n        action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',\n        note: 'Hold while drawing.',\n      },\n    ],\n  },\n  {\n    title: 'Item Placement',\n    shortcuts: [\n      { keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },\n      { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },\n      {\n        keys: ['Shift'],\n        action: 'Temporarily bypass placement validation constraints',\n        note: 'Hold while placing.',\n      },\n    ],\n  },\n  {\n    title: 'Camera',\n    shortcuts: [\n      {\n        keys: ['Middle click'],\n        action: 'Pan camera',\n        note: 'Drag with the middle mouse button, or hold Space while dragging with the left mouse button.',\n      },\n      {\n        keys: ['Right click'],\n        action: 'Orbit camera',\n        note: 'Drag with the right mouse button.',\n      },\n    ],\n  },\n]\n\nfunction getDisplayKey(key: string, isMac: boolean): string {\n  if (key === 'Cmd/Ctrl') return isMac ? '⌘' : 'Ctrl'\n  if (key === 'Delete / Backspace') return isMac ? '⌫' : 'Backspace'\n  return KEY_DISPLAY_MAP[key] ?? key\n}\n\nfunction ShortcutKeys({ keys }: { keys: string[] }) {\n  const [isMac, setIsMac] = useState(true)\n\n  useEffect(() => {\n    setIsMac(navigator.platform.toUpperCase().indexOf('MAC') >= 0)\n  }, [])\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-1\">\n      {keys.map((key, index) => (\n        <div className=\"flex items-center gap-1\" key={`${key}-${index}`}>\n          {index > 0 ? <span className=\"text-[10px] text-muted-foreground\">+</span> : null}\n          <ShortcutToken displayValue={getDisplayKey(key, isMac)} value={key} />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nexport function KeyboardShortcutsDialog() {\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button className=\"w-full justify-start gap-2\" variant=\"outline\">\n          <Keyboard className=\"size-4\" />\n          Keyboard Shortcuts\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl\">\n        <DialogHeader className=\"shrink-0 border-b px-6 py-4\">\n          <DialogTitle>Keyboard Shortcuts</DialogTitle>\n          <DialogDescription>\n            Shortcuts are context-aware and depend on the current phase or tool.\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"flex-1 space-y-5 overflow-y-auto px-6 py-4\">\n          {SHORTCUT_CATEGORIES.map((category) => (\n            <section className=\"space-y-2\" key={category.title}>\n              <h3 className=\"font-medium text-sm\">{category.title}</h3>\n              <div className=\"overflow-hidden rounded-md border border-border/80\">\n                {category.shortcuts.map((shortcut, index) => (\n                  <div\n                    className=\"grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2\"\n                    key={`${category.title}-${shortcut.action}`}\n                  >\n                    <ShortcutKeys keys={shortcut.keys} />\n                    <div>\n                      <p className=\"text-sm\">{shortcut.action}</p>\n                      {shortcut.note ? (\n                        <p className=\"text-muted-foreground text-xs\">{shortcut.note}</p>\n                      ) : null}\n                    </div>\n                    {index < category.shortcuts.length - 1 ? (\n                      <div className=\"col-span-2 border-border/60 border-b\" />\n                    ) : null}\n                  </div>\n                ))}\n              </div>\n            </section>\n          ))}\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx",
    "content": "import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Building2, Plus } from 'lucide-react'\nimport { useState } from 'react'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from './../../../../../components/ui/primitives/tooltip'\nimport { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface BuildingTreeNodeProps {\n  node: BuildingNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {\n  const [expanded, setExpanded] = useState(true)\n  const createNode = useScene((state) => state.createNode)\n  const isSelected = useViewer((state) => state.selection.buildingId === node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const handleClick = () => {\n    setSelection({ buildingId: node.id })\n  }\n\n  const handleAddLevel = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const newLevel = LevelNode.parse({\n      level: node.children.length,\n      children: [],\n      parentId: node.id,\n    })\n    createNode(newLevel, node.id)\n  }\n\n  return (\n    <TreeNodeWrapper\n      actions={\n        <div className=\"flex items-center gap-0.5\">\n          <TreeNodeActions node={node} />\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <button\n                className=\"flex h-5 w-5 items-center justify-center rounded hover:bg-primary-foreground/20\"\n                onClick={handleAddLevel}\n              >\n                <Plus className=\"h-3 w-3\" />\n              </button>\n            </TooltipTrigger>\n            <TooltipContent side=\"right\">Add new level</TooltipContent>\n          </Tooltip>\n        </div>\n      }\n      depth={depth}\n      expanded={expanded}\n      hasChildren={node.children.length > 0}\n      icon={<Building2 className=\"h-3.5 w-3.5\" />}\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      label={node.name || 'Building'}\n      onClick={handleClick}\n      onDoubleClick={() => focusTreeNode(node.id)}\n      onToggle={() => setExpanded(!expanded)}\n    >\n      {node.children.map((childId, index) => (\n        <TreeNode\n          depth={depth + 1}\n          isLast={index === node.children.length - 1}\n          key={childId}\n          nodeId={childId}\n        />\n      ))}\n    </TreeNodeWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx",
    "content": "import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useEffect, useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface CeilingTreeNodeProps {\n  node: CeilingNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {\n  const [expanded, setExpanded] = useState(false)\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  useEffect(() => {\n    if (selectedIds.length === 0) return\n    const nodes = useScene.getState().nodes\n    let isDescendant = false\n    for (const id of selectedIds) {\n      let current = nodes[id as AnyNodeId]\n      while (current?.parentId) {\n        if (current.parentId === node.id) {\n          isDescendant = true\n          break\n        }\n        current = nodes[current.parentId as AnyNodeId]\n      }\n      if (isDescendant) break\n    }\n    if (isDescendant) {\n      setExpanded(true)\n    }\n  }, [selectedIds, node.id])\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'furnish') {\n      useEditor.getState().setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  // Calculate approximate area from polygon\n  const area = calculatePolygonArea(node.polygon).toFixed(1)\n  const defaultName = `Ceiling (${area}m²)`\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={expanded}\n      hasChildren={node.children.length > 0}\n      icon={\n        <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/ceiling.png\" width={14} />\n      }\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onToggle={() => setExpanded(!expanded)}\n    >\n      {node.children.map((childId, index) => (\n        <TreeNode\n          depth={depth + 1}\n          isLast={index === node.children.length - 1}\n          key={childId}\n          nodeId={childId}\n        />\n      ))}\n    </TreeNodeWrapper>\n  )\n}\n\n/**\n * Calculate the area of a polygon using the shoelace formula\n */\nfunction calculatePolygonArea(polygon: Array<[number, number]>): number {\n  if (polygon.length < 3) return 0\n\n  let area = 0\n  const n = polygon.length\n\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const pi = polygon[i]\n    const pj = polygon[j]\n    if (pi && pj) {\n      area += pi[0] * pj[1]\n      area -= pj[0] * pi[1]\n    }\n  }\n\n  return Math.abs(area) / 2\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx",
    "content": "'use client'\n\nimport type { DoorNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface DoorTreeNodeProps {\n  node: DoorNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  const defaultName = 'Door'\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={false}\n      hasChildren={false}\n      icon={\n        <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/door.png\" width={14} />\n      }\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={(e: React.MouseEvent) => {\n        e.stopPropagation()\n        const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n        if (!handled && useEditor.getState().phase === 'furnish') {\n          useEditor.getState().setPhase('structure')\n        }\n      }}\n      onDoubleClick={() => focusTreeNode(node.id)}\n      onMouseEnter={() => setHoveredId(node.id)}\n      onMouseLeave={() => setHoveredId(null)}\n      onToggle={() => {}}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx",
    "content": "import {\n  type AnyNode,\n  type AnyNodeId,\n  type BuildingNode,\n  emitter,\n  type GuideNode,\n  LevelNode,\n  type ScanNode,\n  type SiteNode,\n  useScene,\n  type ZoneNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport {\n  Camera,\n  ChevronDown,\n  Loader2,\n  MoreHorizontal,\n  Pencil,\n  Pentagon,\n  Plus,\n  Trash2,\n  X,\n} from 'lucide-react'\nimport { AnimatePresence, LayoutGroup, motion } from 'motion/react'\nimport { useEffect, useRef, useState } from 'react'\nimport { ColorDot } from './../../../../../components/ui/primitives/color-dot'\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from './../../../../../components/ui/primitives/popover'\nimport { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'\nimport { cn } from './../../../../../lib/utils'\nimport useEditor from './../../../../../store/use-editor'\nimport { useUploadStore } from '../../../../../store/use-upload'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, TreeNode } from './tree-node'\nimport { TreeNodeDragProvider } from './tree-node-drag'\n\n// ============================================================================\n// PROPERTY LINE SECTION\n// ============================================================================\n\nfunction calculatePerimeter(points: Array<[number, number]>): number {\n  if (points.length < 2) return 0\n  let perimeter = 0\n  for (let i = 0; i < points.length; i++) {\n    const [x1, z1] = points[i]!\n    const [x2, z2] = points[(i + 1) % points.length]!\n    perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2)\n  }\n  return perimeter\n}\n\nfunction calculatePolygonArea(polygon: Array<[number, number]>): number {\n  if (polygon.length < 3) return 0\n  let area = 0\n  const n = polygon.length\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const [currentX, currentY] = polygon[i]!\n    const [nextX, nextY] = polygon[j]!\n    area += currentX * nextY\n    area -= nextX * currentY\n  }\n  return Math.abs(area) / 2\n}\n\nfunction useSiteNode(): SiteNode | null {\n  const siteId = useScene((state) => {\n    for (const id of state.rootNodeIds) {\n      if (state.nodes[id]?.type === 'site') return id\n    }\n    return null\n  })\n  return useScene((state) =>\n    siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null,\n  )\n}\n\nfunction PropertyLineSection() {\n  const siteNode = useSiteNode()\n  const updateNode = useScene((state) => state.updateNode)\n  const mode = useEditor((state) => state.mode)\n  const setMode = useEditor((state) => state.setMode)\n\n  if (!siteNode) return null\n\n  const points = siteNode.polygon?.points ?? []\n  const area = calculatePolygonArea(points)\n  const perimeter = calculatePerimeter(points)\n  const isEditing = mode === 'edit'\n\n  const handleToggleEdit = () => {\n    setMode(isEditing ? 'select' : 'edit')\n  }\n\n  const handlePointChange = (index: number, axis: 0 | 1, value: number) => {\n    const newPoints = [...points.map((p) => [...p] as [number, number])]\n    newPoints[index]![axis] = value\n    updateNode(siteNode.id, {\n      polygon: { type: 'polygon' as const, points: newPoints },\n    })\n  }\n\n  const handleAddPoint = () => {\n    const lastPoint = points[points.length - 1]\n    const firstPoint = points[0]\n    if (!(lastPoint && firstPoint)) return\n\n    const newPoint: [number, number] = [\n      (lastPoint[0] + firstPoint[0]) / 2,\n      (lastPoint[1] + firstPoint[1]) / 2,\n    ]\n    const newPoints = [...points, newPoint]\n    updateNode(siteNode.id, {\n      polygon: { type: 'polygon' as const, points: newPoints },\n    })\n  }\n\n  const handleDeletePoint = (index: number) => {\n    if (points.length <= 3) return\n    const newPoints = points.filter((_, i) => i !== index)\n    updateNode(siteNode.id, {\n      polygon: { type: 'polygon' as const, points: newPoints },\n    })\n  }\n\n  return (\n    <div className=\"relative border-border/50 border-b\">\n      {/* Vertical tree line */}\n      <div className=\"absolute top-0 bottom-0 left-[21px] w-px bg-border/50\" />\n\n      {/* Header */}\n      <div className=\"relative flex items-center justify-between py-2 pr-3 pl-10\">\n        {/* Horizontal branch line */}\n        <div className=\"absolute top-1/2 left-[21px] h-px w-4 bg-border/50\" />\n\n        <div className=\"flex items-center gap-2\">\n          <Pentagon className=\"h-4 w-4 text-muted-foreground\" />\n          <span className=\"font-medium text-sm\">Property Line</span>\n        </div>\n        <button\n          className={cn(\n            'flex h-6 w-6 cursor-pointer items-center justify-center rounded transition-colors',\n            isEditing\n              ? 'bg-orange-500/20 text-orange-400'\n              : 'text-muted-foreground hover:bg-accent',\n          )}\n          onClick={handleToggleEdit}\n        >\n          <Pencil className=\"h-3.5 w-3.5\" />\n        </button>\n      </div>\n\n      {/* Measurements */}\n      <div className=\"relative flex gap-3 pr-3 pb-2 pl-10\">\n        <div className=\"text-muted-foreground text-xs\">\n          Area: <span className=\"text-foreground\">{area.toFixed(1)} m²</span>\n        </div>\n        <div className=\"text-muted-foreground text-xs\">\n          Perimeter: <span className=\"text-foreground\">{perimeter.toFixed(1)} m</span>\n        </div>\n      </div>\n\n      {/* Vertex list (shown when editing) */}\n      {isEditing && (\n        <div className=\"relative pr-3 pb-2 pl-10\">\n          <div className=\"flex flex-col gap-1\">\n            {points.map((point, index) => (\n              <div className=\"flex items-center gap-1.5 text-xs\" key={index}>\n                <span className=\"w-4 shrink-0 text-right text-muted-foreground\">{index + 1}</span>\n                <label className=\"shrink-0 text-muted-foreground\">X</label>\n                <input\n                  className=\"w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none\"\n                  onChange={(e) =>\n                    handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0)\n                  }\n                  step={0.5}\n                  type=\"number\"\n                  value={point[0]}\n                />\n                <label className=\"shrink-0 text-muted-foreground\">Z</label>\n                <input\n                  className=\"w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none\"\n                  onChange={(e) =>\n                    handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0)\n                  }\n                  step={0.5}\n                  type=\"number\"\n                  value={point[1]}\n                />\n                <button\n                  className={cn(\n                    'flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded',\n                    points.length > 3\n                      ? 'text-muted-foreground hover:bg-red-500/20 hover:text-red-400'\n                      : 'cursor-not-allowed text-muted-foreground/30',\n                  )}\n                  disabled={points.length <= 3}\n                  onClick={() => handleDeletePoint(index)}\n                >\n                  <Trash2 className=\"h-3 w-3\" />\n                </button>\n              </div>\n            ))}\n          </div>\n          <button\n            className=\"mt-1.5 flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-accent/50 hover:text-foreground\"\n            onClick={handleAddPoint}\n          >\n            <Plus className=\"h-3 w-3\" />\n            Add point\n          </button>\n        </div>\n      )}\n    </div>\n  )\n}\n\n// ============================================================================\n// SITE PHASE VIEW - Property line + building buttons\n// ============================================================================\n\nfunction CameraPopover({\n  nodeId,\n  hasCamera,\n  open,\n  onOpenChange,\n  buttonClassName,\n}: {\n  nodeId: AnyNodeId\n  hasCamera: boolean\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  buttonClassName?: string\n}) {\n  const updateNode = useScene((state) => state.updateNode)\n  return (\n    <Popover onOpenChange={onOpenChange} open={open}>\n      <PopoverTrigger asChild>\n        <button\n          className={cn(\n            'relative flex h-6 w-6 cursor-pointer items-center justify-center rounded',\n            buttonClassName,\n          )}\n          onClick={(e) => e.stopPropagation()}\n          title=\"Camera snapshot\"\n        >\n          <Camera className=\"h-3.5 w-3.5\" />\n          {hasCamera && (\n            <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n          )}\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        className=\"w-auto p-1\"\n        onClick={(e) => e.stopPropagation()}\n        side=\"right\"\n      >\n        <div className=\"flex flex-col gap-0.5\">\n          {hasCamera && (\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n              onClick={(e) => {\n                e.stopPropagation()\n                emitter.emit('camera-controls:view', { nodeId })\n                onOpenChange(false)\n              }}\n            >\n              <Camera className=\"h-3.5 w-3.5\" />\n              View snapshot\n            </button>\n          )}\n          <button\n            className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n            onClick={(e) => {\n              e.stopPropagation()\n              emitter.emit('camera-controls:capture', { nodeId })\n              onOpenChange(false)\n            }}\n          >\n            <Camera className=\"h-3.5 w-3.5\" />\n            {hasCamera ? 'Update snapshot' : 'Take snapshot'}\n          </button>\n          {hasCamera && (\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n              onClick={(e) => {\n                e.stopPropagation()\n                updateNode(nodeId, { camera: undefined })\n                onOpenChange(false)\n              }}\n            >\n              <Trash2 className=\"h-3.5 w-3.5\" />\n              Clear snapshot\n            </button>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nfunction ReferenceItem({\n  refNode,\n  isLastRow,\n  setSelectedReferenceId,\n  handleDelete,\n}: {\n  refNode: ScanNode | GuideNode\n  isLastRow: boolean\n  setSelectedReferenceId: (id: string) => void\n  handleDelete: (id: string, e: React.MouseEvent) => void\n}) {\n  const [isEditing, setIsEditing] = useState(false)\n  const handleSelect = () => {\n    setSelectedReferenceId(refNode.id)\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(refNode.id as AnyNodeId)\n  }\n\n  return (\n    <div\n      className=\"group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30\"\n      onClick={handleSelect}\n      onDoubleClick={handleDoubleClick}\n    >\n      <div\n        className={cn(\n          'pointer-events-none absolute z-10 w-px bg-border/50',\n          isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',\n        )}\n        style={{ left: 45 }}\n      />\n      <div\n        className=\"pointer-events-none absolute top-1/2 z-10 h-px bg-border/50\"\n        style={{ left: 45, width: 8 }}\n      />\n\n      <div className=\"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground\">\n        {refNode.type === 'scan' ? (\n          <img\n            alt=\"Scan\"\n            className=\"h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100\"\n            src=\"/icons/mesh.png\"\n          />\n        ) : (\n          <img\n            alt=\"Guide\"\n            className=\"h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100\"\n            src=\"/icons/floorplan.png\"\n          />\n        )}\n        <InlineRenameInput\n          defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}\n          isEditing={isEditing}\n          node={refNode}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      </div>\n\n      <button\n        className=\"z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10\"\n        onClick={(e) => handleDelete(refNode.id, e)}\n        title=\"Delete\"\n      >\n        <Trash2 className=\"h-3 w-3\" />\n      </button>\n    </div>\n  )\n}\n\nconst MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB\n\ninterface LevelReferencesProps {\n  levelId: string\n  isLastLevel?: boolean\n  projectId?: string\n  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void\n  onDeleteAsset?: (projectId: string, url: string) => void\n}\n\nfunction LevelReferences({\n  levelId,\n  isLastLevel,\n  projectId,\n  onUploadAsset,\n  onDeleteAsset,\n}: LevelReferencesProps) {\n  const nodes = useScene((s) => s.nodes)\n  const deleteNode = useScene((s) => s.deleteNode)\n  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)\n  const uploadState = useUploadStore((s) => s.uploads[levelId])\n  const clearUpload = useUploadStore((s) => s.clearUpload)\n\n  const uploading =\n    uploadState?.status === 'preparing' ||\n    uploadState?.status === 'uploading' ||\n    uploadState?.status === 'confirming'\n  const uploadingType = uploadState?.assetType ?? null\n  const uploadError = uploadState?.error ?? null\n  const progress = uploadState?.progress ?? 0\n\n  const scanInputRef = useRef<HTMLInputElement>(null)\n\n  const references = Object.values(nodes).filter(\n    (node): node is ScanNode | GuideNode =>\n      (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,\n  )\n\n  const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0]\n    if (!file) return\n    e.target.value = ''\n\n    if (!projectId) {\n      useUploadStore.getState().startUpload(levelId, 'scan', file.name)\n      useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')\n      return\n    }\n\n    if (file.size > MAX_FILE_SIZE) {\n      useUploadStore.getState().startUpload(levelId, 'scan', file.name)\n      useUploadStore\n        .getState()\n        .setError(\n          levelId,\n          `File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 200 MB.`,\n        )\n      return\n    }\n\n    // Auto-detect type based on file extension/mime type\n    const isScan =\n      file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')\n    const isImage = file.type.startsWith('image/')\n\n    if (!(isScan || isImage)) {\n      useUploadStore.getState().startUpload(levelId, 'scan', file.name)\n      useUploadStore\n        .getState()\n        .setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')\n      return\n    }\n\n    const type = isScan ? 'scan' : 'guide'\n\n    clearUpload(levelId)\n    onUploadAsset?.(projectId, levelId, file, type)\n  }\n\n  const handleDelete = async (nodeId: string, e: React.MouseEvent) => {\n    e.stopPropagation()\n    const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined\n\n    if (\n      projectId &&\n      refNode?.url &&\n      (refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))\n    ) {\n      onDeleteAsset?.(projectId, refNode.url)\n    }\n    deleteNode(nodeId as AnyNodeId)\n  }\n\n  const rows = [\n    { type: 'upload' as const },\n    ...references.map((ref) => ({ type: 'ref' as const, data: ref })),\n  ]\n\n  return (\n    <div className=\"relative flex flex-col\">\n      {!isLastLevel && (\n        <div\n          className=\"pointer-events-none absolute top-0 bottom-0 z-10 w-px bg-border/50\"\n          style={{ left: 21 }}\n        />\n      )}\n\n      {rows.map((row, i) => {\n        const isLastRow = i === rows.length - 1\n\n        if (row.type === 'upload') {\n          return (\n            <div className=\"group/ref relative border-border/50 border-b\" key=\"upload\">\n              <div\n                className={cn(\n                  'pointer-events-none absolute z-10 w-px bg-border/50',\n                  isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',\n                )}\n                style={{ left: 45 }}\n              />\n              <div\n                className=\"pointer-events-none absolute top-1/2 z-10 h-px bg-border/50\"\n                style={{ left: 45, width: 8 }}\n              />\n\n              <button\n                className=\"flex h-8 w-full cursor-pointer select-none items-center gap-2 py-0 pr-2 pl-[60px] text-left text-muted-foreground text-xs transition-colors hover:bg-accent/30 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n                disabled={uploading}\n                onClick={() => scanInputRef.current?.click()}\n              >\n                {uploading ? (\n                  <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n                ) : (\n                  <Plus className=\"h-3.5 w-3.5\" />\n                )}\n                {uploading ? `Uploading ${uploadingType}... ${progress}%` : 'Upload scan/floorplan'}\n              </button>\n\n              <input\n                accept=\".glb,.gltf,image/jpeg,image/png,image/webp,image/gif\"\n                className=\"hidden\"\n                onChange={handleAddAsset}\n                ref={scanInputRef}\n                type=\"file\"\n              />\n            </div>\n          )\n        }\n\n        const ref = row.data as ScanNode | GuideNode\n        return (\n          <ReferenceItem\n            handleDelete={handleDelete}\n            isLastRow={isLastRow}\n            key={ref.id}\n            refNode={ref}\n            setSelectedReferenceId={setSelectedReferenceId}\n          />\n        )\n      })}\n\n      {uploadError && (\n        <div className=\"relative flex min-h-8 select-none items-center border-border/50 border-b bg-destructive/5 py-1 pr-2 pl-[60px] text-[10px] text-destructive\">\n          <div\n            className=\"pointer-events-none absolute top-0 bottom-0 z-10 w-px bg-border/50\"\n            style={{ left: 45 }}\n          />\n          {uploadError}\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction LevelItem({\n  level,\n  selectedLevelId,\n  setSelection,\n  updateNode,\n  isLast,\n  projectId,\n  onUploadAsset,\n  onDeleteAsset,\n}: {\n  level: LevelNode\n  selectedLevelId: string | null\n  setSelection: (selection: any) => void\n  updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void\n  isLast?: boolean\n  projectId?: string\n  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void\n  onDeleteAsset?: (projectId: string, url: string) => void\n}) {\n  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)\n  const [isEditing, setIsEditing] = useState(false)\n  const itemRef = useRef<HTMLDivElement>(null)\n  const isSelected = selectedLevelId === level.id\n  const canDeleteLevel = level.level !== 0\n  const [isExpanded, setIsExpanded] = useState(isSelected)\n\n  useEffect(() => {\n    setIsExpanded(isSelected)\n  }, [isSelected])\n\n  useEffect(() => {\n    if (isSelected && itemRef.current) {\n      itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })\n    }\n  }, [isSelected])\n\n  const handleSelect = () => {\n    setSelection({ levelId: level.id })\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(level.id)\n  }\n\n  return (\n    <div className=\"relative flex flex-col\">\n      <div\n        className={cn(\n          'group/level relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 transition-all duration-200',\n          isSelected\n            ? 'bg-accent/50 text-foreground'\n            : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n        )}\n        onClick={handleSelect}\n        onDoubleClick={handleDoubleClick}\n        ref={itemRef}\n      >\n        {/* Vertical tree line */}\n        <div\n          className={cn(\n            'pointer-events-none absolute left-[21px] z-10 w-px bg-border/50',\n            isLast && !isExpanded ? 'top-0 bottom-1/2' : 'top-0 bottom-0',\n          )}\n        />\n        {/* Horizontal branch line */}\n        <div className=\"pointer-events-none absolute top-1/2 left-[21px] z-10 h-px w-[11px] bg-border/50\" />\n        <div\n          className={cn(\n            'pointer-events-none absolute top-[10px] left-[32px] z-10 h-[12px] w-4 transition-colors duration-200',\n            isSelected ? 'bg-accent/50' : 'bg-background group-hover/level:bg-accent/30',\n          )}\n        />\n        {/* Line down to children */}\n        {isExpanded && (\n          <div className=\"pointer-events-none absolute top-[16px] bottom-0 left-[45px] z-10 w-px bg-border/50\" />\n        )}\n\n        <div className=\"relative z-20 flex h-8 items-center pr-1 pl-[28px]\">\n          <button\n            className=\"z-20 flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center bg-inherit\"\n            onClick={(e) => {\n              e.stopPropagation()\n              if (isSelected) {\n                setIsExpanded(!isExpanded)\n              } else {\n                setSelection({ levelId: level.id })\n              }\n            }}\n          >\n            {isExpanded ? (\n              <ChevronDown className=\"h-3 w-3 text-muted-foreground\" />\n            ) : (\n              <ChevronDown className=\"h-3 w-3 -rotate-90 text-muted-foreground\" />\n            )}\n          </button>\n        </div>\n\n        <div className=\"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm\">\n          <img\n            alt=\"Level\"\n            className={cn(\n              'h-4 w-4 shrink-0 object-contain transition-all duration-200',\n              !isSelected && 'opacity-60 grayscale',\n            )}\n            src=\"/icons/level.png\"\n          />\n          <InlineRenameInput\n            defaultName={`Level ${level.level}`}\n            isEditing={isEditing}\n            node={level}\n            onStartEditing={() => setIsEditing(true)}\n            onStopEditing={() => setIsEditing(false)}\n          />\n        </div>\n        {/* Camera snapshot button */}\n        <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>\n          <PopoverTrigger asChild>\n            <button\n              className={cn(\n                'relative mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/level:opacity-100',\n                selectedLevelId === level.id\n                  ? 'hover:bg-black/5 dark:hover:bg-white/10'\n                  : 'text-muted-foreground hover:bg-accent hover:text-foreground',\n              )}\n              onClick={(e) => e.stopPropagation()}\n              title=\"Camera snapshot\"\n            >\n              <Camera className=\"h-3.5 w-3.5\" />\n              {level.camera && (\n                <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n              )}\n            </button>\n          </PopoverTrigger>\n          <PopoverContent\n            align=\"start\"\n            className=\"w-auto p-1\"\n            onClick={(e) => e.stopPropagation()}\n            side=\"right\"\n          >\n            <div className=\"flex flex-col gap-0.5\">\n              {level.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    emitter.emit('camera-controls:view', { nodeId: level.id })\n                    setCameraPopoverOpen(false)\n                  }}\n                >\n                  <Camera className=\"h-3.5 w-3.5\" />\n                  View snapshot\n                </button>\n              )}\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  emitter.emit('camera-controls:capture', { nodeId: level.id })\n                  setCameraPopoverOpen(false)\n                }}\n              >\n                <Camera className=\"h-3.5 w-3.5\" />\n                {level.camera ? 'Update snapshot' : 'Take snapshot'}\n              </button>\n              {level.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    updateNode(level.id, { camera: undefined })\n                    setCameraPopoverOpen(false)\n                  }}\n                >\n                  <Trash2 className=\"h-3.5 w-3.5\" />\n                  Clear snapshot\n                </button>\n              )}\n            </div>\n          </PopoverContent>\n        </Popover>\n        <Popover>\n          <PopoverTrigger asChild>\n            <button\n              className={cn(\n                'mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/level:opacity-100',\n                selectedLevelId === level.id\n                  ? 'hover:bg-black/5 dark:hover:bg-white/10'\n                  : 'text-muted-foreground hover:bg-accent hover:text-foreground',\n              )}\n              onClick={(e) => e.stopPropagation()}\n            >\n              <MoreHorizontal className=\"h-3.5 w-3.5\" />\n            </button>\n          </PopoverTrigger>\n          <PopoverContent align=\"start\" className=\"w-40 p-1\" side=\"right\">\n            <button\n              className=\"flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50\"\n              disabled={!canDeleteLevel}\n              onClick={() => deleteLevelWithFallbackSelection(level.id)}\n              title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}\n            >\n              <Trash2 className=\"h-3.5 w-3.5\" />\n              Delete\n            </button>\n          </PopoverContent>\n        </Popover>\n      </div>\n      <AnimatePresence initial={false}>\n        {isExpanded && (\n          <motion.div\n            animate={{ height: 'auto', opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            transition={{ type: 'spring', bounce: 0, duration: 0.3 }}\n          >\n            <LevelReferences\n              isLastLevel={isLast}\n              levelId={level.id}\n              onDeleteAsset={onDeleteAsset}\n              onUploadAsset={onUploadAsset}\n              projectId={projectId}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nfunction LevelsSection({\n  projectId,\n  onUploadAsset,\n  onDeleteAsset,\n}: {\n  projectId?: string\n  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void\n  onDeleteAsset?: (projectId: string, url: string) => void\n} = {}) {\n  const nodes = useScene((state) => state.nodes)\n  const createNode = useScene((state) => state.createNode)\n  const updateNode = useScene((state) => state.updateNode)\n  const selectedBuildingId = useViewer((state) => state.selection.buildingId)\n  const selectedLevelId = useViewer((state) => state.selection.levelId)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null\n\n  if (!building) return null\n\n  const levels = building.children\n    .map((id) => nodes[id])\n    .filter((node): node is LevelNode => node?.type === 'level')\n\n  const handleAddLevel = () => {\n    const newLevel = LevelNode.parse({\n      level: levels.length,\n      children: [],\n      parentId: building.id,\n    })\n    createNode(newLevel, building.id)\n    setSelection({ levelId: newLevel.id })\n  }\n\n  return (\n    <div className=\"relative flex flex-col\">\n      {/* Level buttons */}\n      <div className=\"flex min-h-0 flex-1 flex-col\">\n        <button\n          className=\"relative flex h-8 cursor-pointer select-none items-center gap-2 border-border/50 border-b py-0 pl-0 text-muted-foreground text-sm transition-all duration-200 hover:bg-accent/30 hover:text-foreground\"\n          onClick={handleAddLevel}\n        >\n          {/* Vertical tree line */}\n          <div className=\"pointer-events-none absolute top-0 bottom-0 left-[21px] w-px bg-border/50\" />\n          {/* Horizontal branch line */}\n          <div className=\"pointer-events-none absolute top-1/2 left-[21px] z-10 h-px w-[11px] bg-border/50\" />\n\n          <div className=\"relative z-10 flex items-center pr-1 pl-[38px]\">\n            <Plus className=\"h-3.5 w-3.5\" />\n          </div>\n          <span className=\"truncate\">Add level</span>\n        </button>\n        {levels.length === 0 && (\n          <div className=\"relative flex h-8 select-none items-center border-border/50 border-b py-0 pr-2 pl-[38px] text-muted-foreground text-xs\">\n            {/* Vertical tree line */}\n            <div className=\"pointer-events-none absolute top-0 bottom-1/2 left-[21px] w-px bg-border/50\" />\n            {/* Horizontal branch line */}\n            <div className=\"pointer-events-none absolute top-1/2 left-[21px] h-px w-[11px] bg-border/50\" />\n            No levels yet\n          </div>\n        )}\n        {[...levels].reverse().map((level, index) => (\n          <LevelItem\n            isLast={index === levels.length - 1}\n            key={level.id}\n            level={level}\n            onDeleteAsset={onDeleteAsset}\n            onUploadAsset={onUploadAsset}\n            projectId={projectId}\n            selectedLevelId={selectedLevelId}\n            setSelection={setSelection}\n            updateNode={updateNode}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nfunction LayerToggle() {\n  const structureLayer = useEditor((state) => state.structureLayer)\n  const setStructureLayer = useEditor((state) => state.setStructureLayer)\n  const phase = useEditor((state) => state.phase)\n  const setPhase = useEditor((state) => state.setPhase)\n\n  const activeTab =\n    phase === 'structure' && structureLayer === 'elements'\n      ? 'structure'\n      : phase === 'furnish'\n        ? 'furnish'\n        : phase === 'structure' && structureLayer === 'zones'\n          ? 'zones'\n          : 'none'\n\n  return (\n    <div className=\"relative flex items-center gap-1 border-border/50 border-b bg-[#2C2C2E] p-1\">\n      <button\n        className={cn(\n          'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',\n          activeTab === 'structure'\n            ? 'text-foreground'\n            : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',\n        )}\n        onClick={() => {\n          setPhase('structure')\n          setStructureLayer('elements')\n        }}\n      >\n        {activeTab === 'structure' && (\n          <motion.div\n            className=\"absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50\"\n            layoutId=\"layerToggleActiveBg\"\n            transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}\n          />\n        )}\n        <div className=\"relative z-10 flex flex-col items-center\">\n          <img\n            alt=\"Structure\"\n            className={cn(\n              'mb-1 h-6 w-6 transition-all',\n              activeTab !== 'structure' && 'opacity-50 grayscale',\n            )}\n            src=\"/icons/room.png\"\n          />\n          Structure\n        </div>\n        <div className=\"absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md\">\n          <span className=\"block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none\">\n            S\n          </span>\n        </div>\n      </button>\n\n      <button\n        className={cn(\n          'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',\n          activeTab === 'furnish'\n            ? 'text-foreground'\n            : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',\n        )}\n        onClick={() => {\n          setPhase('furnish')\n        }}\n      >\n        {activeTab === 'furnish' && (\n          <motion.div\n            className=\"absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50\"\n            layoutId=\"layerToggleActiveBg\"\n            transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}\n          />\n        )}\n        <div className=\"relative z-10 flex flex-col items-center\">\n          <img\n            alt=\"Furnish\"\n            className={cn(\n              'mb-1 h-6 w-6 transition-all',\n              activeTab !== 'furnish' && 'opacity-50 grayscale',\n            )}\n            src=\"/icons/couch.png\"\n          />\n          Furnish\n        </div>\n        <div className=\"absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md\">\n          <span className=\"block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none\">\n            F\n          </span>\n        </div>\n      </button>\n\n      <button\n        className={cn(\n          'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',\n          activeTab === 'zones'\n            ? 'text-foreground'\n            : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',\n        )}\n        onClick={() => {\n          setPhase('structure')\n          setStructureLayer('zones')\n        }}\n      >\n        {activeTab === 'zones' && (\n          <motion.div\n            className=\"absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50\"\n            layoutId=\"layerToggleActiveBg\"\n            transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}\n          />\n        )}\n        <div className=\"relative z-10 flex flex-col items-center\">\n          <img\n            alt=\"Zones\"\n            className={cn(\n              'mb-1 h-6 w-6 transition-all',\n              activeTab !== 'zones' && 'opacity-50 grayscale',\n            )}\n            src=\"/icons/kitchen.png\"\n          />\n          Zones\n        </div>\n        <div className=\"absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md\">\n          <span className=\"block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none\">\n            Z\n          </span>\n        </div>\n      </button>\n    </div>\n  )\n}\n\nfunction ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)\n  const deleteNode = useScene((state) => state.deleteNode)\n  const updateNode = useScene((state) => state.updateNode)\n  const selectedZoneId = useViewer((state) => state.selection.zoneId)\n  const hoveredId = useViewer((state) => state.hoveredId)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n  const setPhase = useEditor((state) => state.setPhase)\n  const setMode = useEditor((state) => state.setMode)\n\n  const isSelected = selectedZoneId === zone.id\n  const isHovered = hoveredId === zone.id\n\n  const itemRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    if (isSelected && itemRef.current) {\n      itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })\n    }\n  }, [isSelected])\n\n  const area = calculatePolygonArea(zone.polygon).toFixed(1)\n  const defaultName = `Zone (${area}m²)`\n\n  const handleClick = () => {\n    setSelection({ zoneId: zone.id })\n    setPhase('structure')\n    setMode('select')\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(zone.id)\n  }\n\n  const handleDelete = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    deleteNode(zone.id)\n    if (isSelected) {\n      setSelection({ zoneId: null })\n    }\n  }\n\n  const handleColorChange = (color: string) => {\n    updateNode(zone.id, { color })\n  }\n\n  return (\n    <div\n      className={cn(\n        'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b px-3 text-sm transition-all duration-200',\n        isSelected\n          ? 'bg-accent/50 text-foreground'\n          : isHovered\n            ? 'bg-accent/30 text-foreground'\n            : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n      )}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={() => setHoveredId(zone.id)}\n      onMouseLeave={() => setHoveredId(null)}\n      ref={itemRef}\n    >\n      {/* Vertical tree line */}\n      <div\n        className={cn(\n          'pointer-events-none absolute w-px bg-border/50',\n          isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',\n        )}\n        style={{ left: 8 }}\n      />\n      {/* Horizontal branch line */}\n      <div\n        className=\"pointer-events-none absolute top-1/2 h-px bg-border/50\"\n        style={{ left: 8, width: 4 }}\n      />\n\n      <span className={cn('mr-2', !isSelected && 'opacity-40')}>\n        <ColorDot color={zone.color} onChange={handleColorChange} />\n      </span>\n      <div className=\"min-w-0 flex-1 pr-1\">\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={zone}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      </div>\n      <div className=\"flex items-center gap-0.5\">\n        {/* Camera snapshot button */}\n        <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>\n          <PopoverTrigger asChild>\n            <button\n              className=\"relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10\"\n              onClick={(e) => e.stopPropagation()}\n              title=\"Camera snapshot\"\n            >\n              <Camera className=\"h-3 w-3\" />\n              {zone.camera && (\n                <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n              )}\n            </button>\n          </PopoverTrigger>\n          <PopoverContent\n            align=\"start\"\n            className=\"w-auto p-1\"\n            onClick={(e) => e.stopPropagation()}\n            side=\"right\"\n          >\n            <div className=\"flex flex-col gap-0.5\">\n              {zone.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    emitter.emit('camera-controls:view', { nodeId: zone.id })\n                    setCameraPopoverOpen(false)\n                  }}\n                >\n                  <Camera className=\"h-3.5 w-3.5\" />\n                  View snapshot\n                </button>\n              )}\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  emitter.emit('camera-controls:capture', { nodeId: zone.id })\n                  setCameraPopoverOpen(false)\n                }}\n              >\n                <Camera className=\"h-3.5 w-3.5\" />\n                {zone.camera ? 'Update snapshot' : 'Take snapshot'}\n              </button>\n              {zone.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    updateNode(zone.id, { camera: undefined })\n                    setCameraPopoverOpen(false)\n                  }}\n                >\n                  <Trash2 className=\"h-3.5 w-3.5\" />\n                  Clear snapshot\n                </button>\n              )}\n            </div>\n          </PopoverContent>\n        </Popover>\n        <button\n          className=\"flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10\"\n          onClick={handleDelete}\n        >\n          <Trash2 className=\"h-3 w-3\" />\n        </button>\n      </div>\n    </div>\n  )\n}\n\nfunction MultiSelectionBadge() {\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  if (selectedIds.length <= 1) return null\n\n  return (\n    <div className=\"pointer-events-none sticky top-4 z-50 flex h-0 w-full justify-center overflow-visible\">\n      <div className=\"pointer-events-auto flex items-center gap-2.5 rounded-full border border-primary/20 bg-primary px-0.5 py-4 pl-2 font-medium text-primary-foreground text-xs shadow-black/10 shadow-lg backdrop-blur-md\">\n        <span>{selectedIds.length} objects selected</span>\n        <button\n          className=\"cursor-pointer rounded-full p-1.5 transition-colors hover:bg-primary-foreground/20\"\n          onClick={() => setSelection({ selectedIds: [] })}\n          title=\"Clear selection\"\n        >\n          <X className=\"h-4 w-4\" />\n        </button>\n      </div>\n    </div>\n  )\n}\n\nfunction ContentSection() {\n  const nodes = useScene((state) => state.nodes)\n  const selectedLevelId = useViewer((state) => state.selection.levelId)\n  const structureLayer = useEditor((state) => state.structureLayer)\n  const phase = useEditor((state) => state.phase)\n  const setPhase = useEditor((state) => state.setPhase)\n  const setMode = useEditor((state) => state.setMode)\n  const setTool = useEditor((state) => state.setTool)\n\n  const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null\n\n  if (!level) {\n    return (\n      <div className=\"px-3 py-4 text-muted-foreground text-sm\">Select a level to view content</div>\n    )\n  }\n\n  if (structureLayer === 'zones') {\n    // Show zones for this level\n    const levelZones = Object.values(nodes).filter(\n      (node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,\n    )\n\n    const handleAddZone = () => {\n      setPhase('structure')\n      setMode('build')\n      setTool('zone')\n    }\n\n    if (levelZones.length === 0) {\n      return (\n        <div className=\"px-3 py-4 text-muted-foreground text-sm\">\n          No zones on this level.{' '}\n          <button className=\"cursor-pointer text-primary hover:underline\" onClick={handleAddZone}>\n            Add one\n          </button>\n        </div>\n      )\n    }\n\n    return (\n      <div className=\"flex flex-col\">\n        {levelZones.map((zone, index) => (\n          <ZoneItem isLast={index === levelZones.length - 1} key={zone.id} zone={zone} />\n        ))}\n      </div>\n    )\n  }\n\n  // Filter elements based on phase\n  const elementChildren = level.children.filter((childId) => {\n    const childNode = nodes[childId]\n    if (!childNode || childNode.type === 'zone') return false\n\n    // We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode\n    // This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible\n    // and selectable in both modes, ensuring seamless transition in the tree view.\n    return true\n  })\n\n  if (elementChildren.length === 0) {\n    return <div className=\"px-3 py-4 text-muted-foreground text-sm\">No elements on this level</div>\n  }\n\n  return (\n    <TreeNodeDragProvider>\n      <div className=\"flex flex-col\">\n        {elementChildren.map((childId, index) => (\n          <TreeNode\n            depth={0}\n            isLast={index === elementChildren.length - 1}\n            key={childId}\n            nodeId={childId}\n          />\n        ))}\n      </div>\n    </TreeNodeDragProvider>\n  )\n}\n\nfunction BuildingItem({\n  building,\n  isBuildingActive,\n  buildingCameraOpen,\n  setBuildingCameraOpen,\n  projectId,\n  onUploadAsset,\n  onDeleteAsset,\n}: {\n  building: BuildingNode\n  isBuildingActive: boolean\n  buildingCameraOpen: string | null\n  setBuildingCameraOpen: (id: string | null) => void\n  projectId?: string\n  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void\n  onDeleteAsset?: (projectId: string, url: string) => void\n}) {\n  const setSelection = useViewer((state) => state.setSelection)\n  const phase = useEditor((state) => state.phase)\n  const setPhase = useEditor((state) => state.setPhase)\n  const updateNode = useScene((state) => state.updateNode)\n  const itemRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    if (isBuildingActive && itemRef.current) {\n      itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })\n    }\n  }, [isBuildingActive])\n\n  const handleSelect = () => {\n    setSelection({ buildingId: building.id })\n    if (phase === 'site') {\n      setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(building.id)\n  }\n\n  return (\n    <motion.div\n      className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}\n      layout\n      transition={{ type: 'spring', bounce: 0, duration: 0.4 }}\n    >\n      <motion.div\n        className={cn(\n          'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',\n          isBuildingActive\n            ? 'bg-accent/50 text-foreground'\n            : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n        )}\n        layout=\"position\"\n        onClick={handleSelect}\n        onDoubleClick={handleDoubleClick}\n        ref={itemRef}\n      >\n        <div className=\"flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3\">\n          <img\n            alt=\"Building\"\n            className={cn(\n              'h-5 w-5 object-contain transition-all',\n              !isBuildingActive && 'opacity-60 grayscale',\n            )}\n            src=\"/icons/building.png\"\n          />\n          <span className=\"truncate font-medium text-sm\">{building.name || 'Building'}</span>\n        </div>\n        <Popover\n          onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}\n          open={buildingCameraOpen === building.id}\n        >\n          <PopoverTrigger asChild>\n            <button\n              className={cn(\n                'relative mr-1.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/building:opacity-100',\n                isBuildingActive\n                  ? 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10'\n                  : 'text-muted-foreground hover:bg-accent hover:text-foreground',\n              )}\n              onClick={(e) => e.stopPropagation()}\n              title=\"Camera snapshot\"\n            >\n              <Camera className=\"h-4 w-4\" />\n              {building.camera && (\n                <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n              )}\n            </button>\n          </PopoverTrigger>\n          <PopoverContent\n            align=\"start\"\n            className=\"w-auto p-1\"\n            onClick={(e) => e.stopPropagation()}\n            side=\"right\"\n          >\n            <div className=\"flex flex-col gap-0.5\">\n              {building.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    emitter.emit('camera-controls:view', { nodeId: building.id })\n                    setBuildingCameraOpen(null)\n                  }}\n                >\n                  <Camera className=\"h-3.5 w-3.5\" />\n                  View snapshot\n                </button>\n              )}\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  emitter.emit('camera-controls:capture', { nodeId: building.id })\n                  setBuildingCameraOpen(null)\n                }}\n              >\n                <Camera className=\"h-3.5 w-3.5\" />\n                {building.camera ? 'Update snapshot' : 'Take snapshot'}\n              </button>\n              {building.camera && (\n                <button\n                  className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    updateNode(building.id, { camera: undefined })\n                    setBuildingCameraOpen(null)\n                  }}\n                >\n                  <Trash2 className=\"h-3.5 w-3.5\" />\n                  Clear snapshot\n                </button>\n              )}\n            </div>\n          </PopoverContent>\n        </Popover>\n      </motion.div>\n\n      {/* Tools and content for the active building */}\n      <AnimatePresence initial={false}>\n        {isBuildingActive && (\n          <motion.div\n            animate={{ opacity: 1, flex: '1 1 0%' }}\n            className=\"flex w-full flex-col overflow-hidden\"\n            exit={{ opacity: 0, flex: '0 0 0px' }}\n            initial={{ opacity: 0, flex: 0 }}\n            transition={{ type: 'spring', bounce: 0, duration: 0.4 }}\n          >\n            <div className=\"flex min-h-0 w-full flex-1 flex-col\">\n              <div className=\"flex shrink-0 flex-col\">\n                <LevelsSection\n                  onDeleteAsset={onDeleteAsset}\n                  onUploadAsset={onUploadAsset}\n                  projectId={projectId}\n                />\n                <LayerToggle />\n              </div>\n              <div className=\"subtle-scrollbar relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden\">\n                <MultiSelectionBadge />\n                <ContentSection />\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  )\n}\n\nexport interface SitePanelProps {\n  projectId?: string\n  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void\n  onDeleteAsset?: (projectId: string, url: string) => void\n}\n\nexport function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {\n  const nodes = useScene((state) => state.nodes)\n  const rootNodeIds = useScene((state) => state.rootNodeIds)\n  const updateNode = useScene((state) => state.updateNode)\n  const selectedBuildingId = useViewer((state) => state.selection.buildingId)\n  const setSelection = useViewer((state) => state.setSelection)\n  const phase = useEditor((state) => state.phase)\n  const setPhase = useEditor((state) => state.setPhase)\n\n  const [siteCameraOpen, setSiteCameraOpen] = useState(false)\n  const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)\n\n  const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null\n  const buildings = (siteNode?.type === 'site' ? siteNode.children : [])\n    .map((child) => {\n      const id = typeof child === 'string' ? child : child.id\n      return nodes[id] as BuildingNode | undefined\n    })\n    .filter((node): node is BuildingNode => node?.type === 'building')\n\n  return (\n    <LayoutGroup>\n      <div className=\"flex h-full flex-col\">\n        {/* Site Header */}\n        {siteNode && (\n          <motion.div\n            className={cn(\n              'flex shrink-0 cursor-pointer items-center justify-between border-border/50 border-b px-3 py-3 transition-colors',\n              phase === 'site'\n                ? 'bg-accent/50 text-foreground'\n                : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n            )}\n            layout=\"position\"\n            onClick={() => setPhase('site')}\n          >\n            <div className=\"flex items-center gap-2\">\n              <img\n                alt=\"Site\"\n                className={cn(\n                  'h-5 w-5 object-contain transition-all',\n                  phase !== 'site' && 'opacity-60 grayscale',\n                )}\n                src=\"/icons/site.png\"\n              />\n              <span className=\"font-medium text-sm\">{siteNode.name || 'Site'}</span>\n            </div>\n            <CameraPopover\n              buttonClassName={cn(\n                'transition-colors',\n                phase === 'site' ? 'hover:bg-black/5 dark:hover:bg-white/10' : 'hover:bg-accent',\n              )}\n              hasCamera={!!siteNode.camera}\n              nodeId={siteNode.id as AnyNodeId}\n              onOpenChange={setSiteCameraOpen}\n              open={siteCameraOpen}\n            />\n          </motion.div>\n        )}\n\n        <motion.div\n          className={cn('flex min-h-0 flex-1 flex-col', phase === 'site' && 'overflow-y-auto')}\n          layout\n        >\n          {/* When phase is site, show property line immediately under site header */}\n          <AnimatePresence initial={false}>\n            {phase === 'site' && (\n              <motion.div\n                animate={{ height: 'auto', opacity: 1 }}\n                className=\"shrink-0 overflow-hidden\"\n                exit={{ height: 0, opacity: 0 }}\n                initial={{ height: 0, opacity: 0 }}\n                layout=\"position\"\n                transition={{ type: 'spring', bounce: 0, duration: 0.4 }}\n              >\n                <PropertyLineSection />\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          {/* Buildings List */}\n          {buildings.length === 0 ? (\n            <motion.div className=\"px-3 py-4 text-muted-foreground text-sm\" layout=\"position\">\n              No buildings yet\n            </motion.div>\n          ) : (\n            <motion.div className=\"flex min-h-0 flex-1 flex-col\" layout>\n              {buildings.map((building) => {\n                const isBuildingActive =\n                  (phase === 'structure' || phase === 'furnish') &&\n                  selectedBuildingId === building.id\n\n                return (\n                  <BuildingItem\n                    building={building}\n                    buildingCameraOpen={buildingCameraOpen}\n                    isBuildingActive={isBuildingActive}\n                    key={building.id}\n                    onDeleteAsset={onDeleteAsset}\n                    onUploadAsset={onUploadAsset}\n                    projectId={projectId}\n                    setBuildingCameraOpen={setBuildingCameraOpen}\n                  />\n                )\n              })}\n            </motion.div>\n          )}\n        </motion.div>\n      </div>\n    </LayoutGroup>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx",
    "content": "import { type AnyNode, useScene } from '@pascal-app/core'\nimport { Pencil } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from './../../../../../lib/utils'\n\ninterface InlineRenameInputProps {\n  node: AnyNode\n  isEditing: boolean\n  onStopEditing: () => void\n  defaultName: string\n  className?: string\n  onStartEditing?: () => void\n}\n\nexport function InlineRenameInput({\n  node,\n  isEditing,\n  onStopEditing,\n  defaultName,\n  className,\n  onStartEditing,\n}: InlineRenameInputProps) {\n  const updateNode = useScene((s) => s.updateNode)\n  const [value, setValue] = useState(node.name || '')\n  const inputRef = useRef<HTMLInputElement>(null)\n  const inputSize = Math.max((value || defaultName).length, 1)\n\n  useEffect(() => {\n    if (isEditing) {\n      setValue(node.name || '')\n      // Focus and select all text after a short delay\n      setTimeout(() => {\n        if (inputRef.current) {\n          inputRef.current.focus()\n          inputRef.current.select()\n        }\n      }, 0)\n    }\n  }, [isEditing, node.name])\n\n  const handleSave = useCallback(() => {\n    const trimmed = value.trim()\n    if (trimmed !== node.name) {\n      updateNode(node.id, { name: trimmed || undefined })\n    }\n    onStopEditing()\n  }, [value, node.id, node.name, updateNode, onStopEditing])\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      handleSave()\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      onStopEditing()\n    }\n  }\n\n  if (!isEditing) {\n    return (\n      <div className=\"group/rename flex h-5 min-w-0 items-center gap-1\">\n        <span className={cn('truncate border-transparent border-b', className)}>\n          {node.name || defaultName}\n        </span>\n        {onStartEditing && (\n          <button\n            className=\"shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/rename:opacity-100\"\n            onClick={(e) => {\n              e.stopPropagation()\n              onStartEditing()\n            }}\n          >\n            <Pencil className=\"h-3 w-3\" />\n          </button>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <input\n      className={cn(\n        'm-0 h-5 min-w-[1ch] max-w-full flex-none rounded-none border-primary/50 border-b bg-transparent px-0 py-0 text-foreground text-sm outline-none focus:border-primary',\n        className,\n      )}\n      onBlur={handleSave}\n      onChange={(e) => setValue(e.target.value)}\n      onClick={(e) => e.stopPropagation()}\n      onDoubleClick={(e) => e.stopPropagation()}\n      onKeyDown={handleKeyDown}\n      placeholder={defaultName}\n      ref={inputRef}\n      size={inputSize}\n      type=\"text\"\n      value={value}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx",
    "content": "import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useEffect, useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\nconst CATEGORY_ICONS: Record<string, string> = {\n  door: '/icons/door.png',\n  window: '/icons/window.png',\n  furniture: '/icons/couch.png',\n  appliance: '/icons/appliance.png',\n  kitchen: '/icons/kitchen.png',\n  bathroom: '/icons/bathroom.png',\n  outdoor: '/icons/tree.png',\n}\n\ninterface ItemTreeNodeProps {\n  node: ItemNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [expanded, setExpanded] = useState(true)\n  const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png'\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  useEffect(() => {\n    if (selectedIds.length === 0) return\n    const nodes = useScene.getState().nodes\n    let isDescendant = false\n    for (const id of selectedIds) {\n      let current = nodes[id as AnyNodeId]\n      while (current?.parentId) {\n        if (current.parentId === node.id) {\n          isDescendant = true\n          break\n        }\n        current = nodes[current.parentId as AnyNodeId]\n      }\n      if (isDescendant) break\n    }\n    if (isDescendant) {\n      setExpanded(true)\n    }\n  }, [selectedIds, node.id])\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'structure') {\n      useEditor.getState().setPhase('furnish')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  const defaultName = node.asset.name || 'Item'\n  const hasChildren = node.children && node.children.length > 0\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={expanded}\n      hasChildren={hasChildren}\n      icon={<Image alt=\"\" className=\"object-contain\" height={14} src={iconSrc} width={14} />}\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onToggle={() => setExpanded(!expanded)}\n    >\n      {hasChildren &&\n        node.children.map((childId, index) => (\n          <TreeNode\n            depth={depth + 1}\n            isLast={index === node.children.length - 1}\n            key={childId}\n            nodeId={childId}\n          />\n        ))}\n    </TreeNodeWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx",
    "content": "import type { LevelNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Layers } from 'lucide-react'\nimport { useState } from 'react'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface LevelTreeNodeProps {\n  node: LevelNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {\n  const [expanded, setExpanded] = useState(true)\n  const [isEditing, setIsEditing] = useState(false)\n  const isSelected = useViewer((state) => state.selection.levelId === node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const handleClick = () => {\n    setSelection({ levelId: node.id })\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const defaultName = `Level ${node.level}`\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={expanded}\n      hasChildren={node.children.length > 0}\n      icon={<Layers className=\"h-3.5 w-3.5\" />}\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onToggle={() => setExpanded(!expanded)}\n    >\n      {node.children.map((childId, index) => (\n        <TreeNode\n          depth={depth + 1}\n          isLast={index === node.children.length - 1}\n          key={childId}\n          nodeId={childId}\n        />\n      ))}\n    </TreeNodeWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx",
    "content": "import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { AnimatePresence } from 'motion/react'\nimport Image from 'next/image'\nimport { useCallback, useEffect, useState } from 'react'\nimport useEditor from '../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\nimport { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'\n\ninterface RoofTreeNodeProps {\n  node: RoofNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [expanded, setExpanded] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n  const nodes = useScene((state) => state.nodes)\n  const { drag, dropTarget } = useTreeNodeDrag()\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'furnish') {\n      useEditor.getState().setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  const segments = (node.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)\n    .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')\n\n  const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))\n\n  useEffect(() => {\n    if (isSelected || hasSelectedChild) {\n      setExpanded(true)\n    }\n  }, [isSelected, hasSelectedChild])\n\n  // Auto-expand when a segment is being dragged over this roof\n  const isDropTarget = drag !== null && dropTarget?.parentId === node.id\n  useEffect(() => {\n    if (isDropTarget && !expanded) {\n      setExpanded(true)\n    }\n  }, [isDropTarget, expanded])\n\n  const segmentCount = segments.length\n  const defaultName = `Roof (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`\n\n  // Hide the dragged segment from every roof while dragging\n  const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments\n\n  const isValidDropTarget = drag !== null && drag.nodeId !== node.id\n\n  return (\n    <div data-drop-target={node.id}>\n      <TreeNodeWrapper\n        actions={<TreeNodeActions node={node} />}\n        depth={depth}\n        expanded={expanded}\n        hasChildren={segments.length > 0}\n        icon={\n          <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/roof.png\" width={14} />\n        }\n        isDropTarget={isValidDropTarget && isDropTarget}\n        isHovered={isHovered || isDropTarget}\n        isLast={isLast && !expanded}\n        isSelected={isSelected}\n        isVisible={node.visible !== false}\n        label={\n          <InlineRenameInput\n            defaultName={defaultName}\n            isEditing={isEditing}\n            node={node}\n            onStartEditing={() => setIsEditing(true)}\n            onStopEditing={() => setIsEditing(false)}\n          />\n        }\n        nodeId={node.id}\n        onClick={handleClick}\n        onDoubleClick={handleDoubleClick}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        onToggle={() => setExpanded(!expanded)}\n      >\n        {visibleSegments.map((seg, i) => {\n          const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i\n          const showIndicatorAfter =\n            isDropTarget &&\n            i === visibleSegments.length - 1 &&\n            dropTarget?.insertIndex !== undefined &&\n            dropTarget.insertIndex > i\n\n          return (\n            <div key={seg.id}>\n              <AnimatePresence>\n                {showIndicatorBefore && <DropIndicatorLine key=\"indicator-before\" />}\n              </AnimatePresence>\n              <RoofSegmentTreeNode\n                depth={depth + 1}\n                isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}\n                node={seg}\n              />\n              <AnimatePresence>\n                {showIndicatorAfter && <DropIndicatorLine key=\"indicator-after\" />}\n              </AnimatePresence>\n            </div>\n          )\n        })}\n        <AnimatePresence>\n          {isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}\n        </AnimatePresence>\n      </TreeNodeWrapper>\n    </div>\n  )\n}\n\nfunction RoofSegmentTreeNode({\n  node,\n  depth,\n  isLast,\n}: {\n  node: RoofSegmentNode\n  depth: number\n  isLast?: boolean\n}) {\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n  const { startDrag, isDragging } = useTreeNodeDrag()\n\n  const handleClick = (e: React.MouseEvent) => {\n    if (isDragging) return\n    e.stopPropagation()\n    handleTreeSelection(e, node.id, selectedIds, setSelection)\n  }\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      if (e.button !== 0) return\n      const label = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}×${node.depth.toFixed(1)}m)`\n      startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)\n    },\n    [node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag],\n  )\n\n  const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`\n\n  return (\n    <div data-drop-child={node.id}>\n      <TreeNodeWrapper\n        actions={<TreeNodeActions node={node} />}\n        depth={depth}\n        expanded={false}\n        hasChildren={false}\n        icon={\n          <Image\n            alt=\"\"\n            className=\"object-contain opacity-60\"\n            height={14}\n            src=\"/icons/roof.png\"\n            width={14}\n          />\n        }\n        isDraggable\n        isHovered={isHovered}\n        isLast={isLast}\n        isSelected={isSelected}\n        isVisible={node.visible !== false}\n        label={\n          <InlineRenameInput\n            defaultName={defaultName}\n            isEditing={isEditing}\n            node={node}\n            onStartEditing={() => setIsEditing(true)}\n            onStopEditing={() => setIsEditing(false)}\n          />\n        }\n        nodeId={node.id}\n        onClick={handleClick}\n        onDoubleClick={() => focusTreeNode(node.id)}\n        onMouseEnter={() => setHoveredId(node.id)}\n        onMouseLeave={() => setHoveredId(null)}\n        onPointerDown={handlePointerDown}\n        onToggle={() => {}}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx",
    "content": "import type { SlabNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface SlabTreeNodeProps {\n  node: SlabNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'furnish') {\n      useEditor.getState().setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  // Calculate approximate area from polygon\n  const area = calculatePolygonArea(node.polygon).toFixed(1)\n  const defaultName = `Slab (${area}m²)`\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={false}\n      hasChildren={false}\n      icon={\n        <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/floor.png\" width={14} />\n      }\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onToggle={() => {}}\n    />\n  )\n}\n\n/**\n * Calculate the area of a polygon using the shoelace formula\n */\nfunction calculatePolygonArea(polygon: Array<[number, number]>): number {\n  if (polygon.length < 3) return 0\n\n  let area = 0\n  const n = polygon.length\n\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const pi = polygon[i]\n    const pj = polygon[j]\n    if (pi && pj) {\n      area += pi[0] * pj[1]\n      area -= pj[0] * pi[1]\n    }\n  }\n\n  return Math.abs(area) / 2\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx",
    "content": "import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { AnimatePresence } from 'motion/react'\nimport Image from 'next/image'\nimport { useCallback, useEffect, useState } from 'react'\nimport useEditor from '../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\nimport { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'\n\ninterface StairTreeNodeProps {\n  node: StairNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [expanded, setExpanded] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n  const nodes = useScene((state) => state.nodes)\n  const { drag, dropTarget } = useTreeNodeDrag()\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'furnish') {\n      useEditor.getState().setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  const segments = (node.children ?? [])\n    .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)\n    .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')\n\n  const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))\n\n  useEffect(() => {\n    if (isSelected || hasSelectedChild) {\n      setExpanded(true)\n    }\n  }, [isSelected, hasSelectedChild])\n\n  // Auto-expand when a segment is being dragged over this stair\n  const isDropTarget = drag !== null && dropTarget?.parentId === node.id\n  useEffect(() => {\n    if (isDropTarget && !expanded) {\n      setExpanded(true)\n    }\n  }, [isDropTarget, expanded])\n\n  const segmentCount = segments.length\n  const defaultName = `Staircase (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`\n\n  // Hide the dragged segment from every stair while dragging\n  const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments\n\n  const isValidDropTarget = drag !== null && drag.nodeId !== node.id\n\n  return (\n    <div data-drop-target={node.id}>\n      <TreeNodeWrapper\n        actions={<TreeNodeActions node={node} />}\n        depth={depth}\n        expanded={expanded}\n        hasChildren={segments.length > 0}\n        icon={\n          <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/stairs.png\" width={14} />\n        }\n        isDropTarget={isValidDropTarget && isDropTarget}\n        isHovered={isHovered || isDropTarget}\n        isLast={isLast && !expanded}\n        isSelected={isSelected}\n        isVisible={node.visible !== false}\n        label={\n          <InlineRenameInput\n            defaultName={defaultName}\n            isEditing={isEditing}\n            node={node}\n            onStartEditing={() => setIsEditing(true)}\n            onStopEditing={() => setIsEditing(false)}\n          />\n        }\n        nodeId={node.id}\n        onClick={handleClick}\n        onDoubleClick={handleDoubleClick}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        onToggle={() => setExpanded(!expanded)}\n      >\n        {visibleSegments.map((seg, i) => {\n          const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i\n          const showIndicatorAfter =\n            isDropTarget &&\n            i === visibleSegments.length - 1 &&\n            dropTarget?.insertIndex !== undefined &&\n            dropTarget.insertIndex > i\n\n          return (\n            <div key={seg.id}>\n              <AnimatePresence>\n                {showIndicatorBefore && <DropIndicatorLine key=\"indicator-before\" />}\n              </AnimatePresence>\n              <StairSegmentTreeNode\n                depth={depth + 1}\n                isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}\n                node={seg}\n              />\n              <AnimatePresence>\n                {showIndicatorAfter && <DropIndicatorLine key=\"indicator-after\" />}\n              </AnimatePresence>\n            </div>\n          )\n        })}\n        <AnimatePresence>\n          {isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}\n        </AnimatePresence>\n      </TreeNodeWrapper>\n    </div>\n  )\n}\n\nfunction StairSegmentTreeNode({\n  node,\n  depth,\n  isLast,\n}: {\n  node: StairSegmentNode\n  depth: number\n  isLast?: boolean\n}) {\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n  const { startDrag, isDragging } = useTreeNodeDrag()\n\n  const handleClick = (e: React.MouseEvent) => {\n    if (isDragging) return\n    e.stopPropagation()\n    handleTreeSelection(e, node.id, selectedIds, setSelection)\n  }\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      if (e.button !== 0) return\n      const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'\n      const label = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`\n      startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)\n    },\n    [node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],\n  )\n\n  const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'\n  const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`\n\n  return (\n    <div data-drop-child={node.id}>\n      <TreeNodeWrapper\n        actions={<TreeNodeActions node={node} />}\n        depth={depth}\n        expanded={false}\n        hasChildren={false}\n        icon={\n          <Image\n            alt=\"\"\n            className=\"object-contain opacity-60\"\n            height={14}\n            src=\"/icons/stairs.png\"\n            width={14}\n          />\n        }\n        isDraggable\n        isHovered={isHovered}\n        isLast={isLast}\n        isSelected={isSelected}\n        isVisible={node.visible !== false}\n        label={\n          <InlineRenameInput\n            defaultName={defaultName}\n            isEditing={isEditing}\n            node={node}\n            onStartEditing={() => setIsEditing(true)}\n            onStopEditing={() => setIsEditing(false)}\n          />\n        }\n        nodeId={node.id}\n        onClick={handleClick}\n        onDoubleClick={() => focusTreeNode(node.id)}\n        onMouseEnter={() => setHoveredId(node.id)}\n        onMouseLeave={() => setHoveredId(null)}\n        onPointerDown={handlePointerDown}\n        onToggle={() => {}}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx",
    "content": "import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'\nimport { useState } from 'react'\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from './../../../../../components/ui/primitives/popover'\n\ninterface TreeNodeActionsProps {\n  node: AnyNode\n}\n\nexport function TreeNodeActions({ node }: TreeNodeActionsProps) {\n  const [open, setOpen] = useState(false)\n  const updateNode = useScene((state) => state.updateNode)\n  const updateNodes = useScene((state) => state.updateNodes)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const hasCamera = !!node.camera\n  const isVisible = node.visible !== false\n\n  const toggleVisibility = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const newVisibility = !isVisible\n    if (selectedIds?.includes(node.id)) {\n      updateNodes(\n        selectedIds.map((id) => ({\n          id: id as AnyNodeId,\n          data: { visible: newVisibility },\n        })),\n      )\n    } else {\n      updateNode(node.id, { visible: newVisibility })\n    }\n  }\n\n  const handleCaptureCamera = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    emitter.emit('camera-controls:capture', { nodeId: node.id })\n    setOpen(false)\n  }\n  const handleViewCamera = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    emitter.emit('camera-controls:view', { nodeId: node.id })\n    setOpen(false)\n  }\n\n  const handleClearCamera = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    updateNode(node.id, { camera: undefined })\n    setOpen(false)\n  }\n\n  return (\n    <div className=\"flex items-center gap-0.5\">\n      <button\n        className=\"flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10\"\n        onClick={toggleVisibility}\n        title={isVisible ? 'Hide' : 'Show'}\n      >\n        {isVisible ? <Eye className=\"h-3 w-3\" /> : <EyeOff className=\"h-3 w-3 opacity-50\" />}\n      </button>\n\n      <Popover onOpenChange={setOpen} open={open}>\n        <PopoverTrigger asChild>\n          <button\n            className=\"relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10\"\n            onClick={(e) => e.stopPropagation()}\n            title=\"Camera snapshot\"\n          >\n            <Camera className=\"h-3 w-3\" />\n            {hasCamera && (\n              <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n            )}\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"start\"\n          className=\"w-auto p-1\"\n          onClick={(e) => e.stopPropagation()}\n          side=\"right\"\n        >\n          <div className=\"flex flex-col gap-0.5\">\n            {hasCamera && (\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                onClick={handleViewCamera}\n              >\n                <Camera className=\"h-3.5 w-3.5\" />\n                View snapshot\n              </button>\n            )}\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n              onClick={handleCaptureCamera}\n            >\n              <Camera className=\"h-3.5 w-3.5\" />\n              {hasCamera ? 'Update snapshot' : 'Take snapshot'}\n            </button>\n            {hasCamera && (\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n                onClick={handleClearCamera}\n              >\n                <Trash2 className=\"h-3.5 w-3.5\" />\n                Clear snapshot\n              </button>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx",
    "content": "'use client'\n\nimport { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'\nimport { motion } from 'motion/react'\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from 'react'\nimport { createPortal } from 'react-dom'\n\n// ---------------------------------------------------------------------------\n// Reparenting rules\n// ---------------------------------------------------------------------------\n\n// Maps a draggable node type to the parent types it can be dropped into.\nconst REPARENT_TARGETS: Record<string, string[]> = {\n  'roof-segment': ['roof'],\n}\n\n// Container types that should be auto-removed when all children are moved out.\nconst REMOVE_WHEN_EMPTY = new Set(['roof'])\n\nexport function canDrag(node: AnyNode): boolean {\n  return node.type in REPARENT_TARGETS\n}\n\nexport function canDrop(draggedType: string, targetType: string): boolean {\n  return REPARENT_TARGETS[draggedType]?.includes(targetType) ?? false\n}\n\n// ---------------------------------------------------------------------------\n// Coordinate preservation\n// ---------------------------------------------------------------------------\n\ntype Transform = {\n  position: [number, number, number]\n  rotation: number\n}\n\nfunction getTransform(node: AnyNode): Transform {\n  const pos =\n    'position' in node && Array.isArray(node.position)\n      ? (node.position as [number, number, number])\n      : ([0, 0, 0] as [number, number, number])\n  const rot = 'rotation' in node && typeof node.rotation === 'number' ? node.rotation : 0\n  return { position: pos, rotation: rot }\n}\n\n/**\n * Compute new local position + rotation so the child stays at the same\n * absolute grid position when moved from oldParent to newParent.\n */\nfunction computeReparentTransform(\n  child: Transform,\n  oldParent: Transform,\n  newParent: Transform,\n): Transform {\n  // child → world: world = parentPos + rotateY(childPos, parentRot)\n  const cosOld = Math.cos(oldParent.rotation)\n  const sinOld = Math.sin(oldParent.rotation)\n  const absX = oldParent.position[0] + child.position[0] * cosOld + child.position[2] * sinOld\n  const absY = oldParent.position[1] + child.position[1]\n  const absZ = oldParent.position[2] - child.position[0] * sinOld + child.position[2] * cosOld\n\n  // world → newParent local: rotateY_inverse(world - newParentPos, newParentRot)\n  const dx = absX - newParent.position[0]\n  const dy = absY - newParent.position[1]\n  const dz = absZ - newParent.position[2]\n  const cosNew = Math.cos(-newParent.rotation)\n  const sinNew = Math.sin(-newParent.rotation)\n\n  return {\n    position: [dx * cosNew + dz * sinNew, dy, -dx * sinNew + dz * cosNew],\n    rotation: oldParent.rotation + child.rotation - newParent.rotation,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype DragState = {\n  nodeId: string\n  nodeType: string\n  sourceParentId: string\n  label: string\n  pointerX: number\n  pointerY: number\n} | null\n\ntype DropTarget = {\n  parentId: string\n  insertIndex: number\n} | null\n\ntype TreeNodeDragContextValue = {\n  drag: DragState\n  dropTarget: DropTarget\n  startDrag: (\n    nodeId: string,\n    nodeType: string,\n    sourceParentId: string,\n    label: string,\n    x: number,\n    y: number,\n  ) => void\n  isDragging: boolean\n}\n\nconst TreeNodeDragContext = createContext<TreeNodeDragContextValue>({\n  drag: null,\n  dropTarget: null,\n  startDrag: () => {},\n  isDragging: false,\n})\n\nexport const useTreeNodeDrag = () => useContext(TreeNodeDragContext)\n\n// ---------------------------------------------------------------------------\n// Provider\n// ---------------------------------------------------------------------------\n\nconst DRAG_THRESHOLD = 4\n\nexport function TreeNodeDragProvider({ children }: { children: ReactNode }) {\n  const [drag, setDrag] = useState<DragState>(null)\n  const [dropTarget, setDropTarget] = useState<DropTarget>(null)\n  const pendingRef = useRef<{\n    nodeId: string\n    nodeType: string\n    sourceParentId: string\n    label: string\n    startX: number\n    startY: number\n  } | null>(null)\n\n  const commitDrop = useCallback(() => {\n    if (!(drag && dropTarget)) return\n\n    const state = useScene.getState()\n\n    if (dropTarget.parentId === drag.sourceParentId) {\n      // --- Reorder within same parent ---\n      const parent = state.nodes[dropTarget.parentId as AnyNodeId]\n      if (parent && 'children' in parent && Array.isArray(parent.children)) {\n        const currentChildren = [...parent.children] as string[]\n        const fromIndex = currentChildren.indexOf(drag.nodeId)\n        if (fromIndex === -1) return\n        currentChildren.splice(fromIndex, 1)\n        const toIndex = Math.min(dropTarget.insertIndex, currentChildren.length)\n        currentChildren.splice(toIndex, 0, drag.nodeId)\n        state.updateNode(dropTarget.parentId as AnyNodeId, { children: currentChildren } as any)\n      }\n    } else {\n      // --- Reparent to different parent, preserving world position ---\n      const node = state.nodes[drag.nodeId as AnyNodeId]\n      const oldParent = state.nodes[drag.sourceParentId as AnyNodeId]\n      const newParent = state.nodes[dropTarget.parentId as AnyNodeId]\n      if (!(node && oldParent && newParent)) return\n\n      const newLocal = computeReparentTransform(\n        getTransform(node),\n        getTransform(oldParent),\n        getTransform(newParent),\n      )\n\n      state.updateNode(\n        drag.nodeId as AnyNodeId,\n        {\n          parentId: dropTarget.parentId,\n          position: newLocal.position,\n          rotation: newLocal.rotation,\n        } as any,\n      )\n\n      // Place at the correct index within the new parent's children\n      const updatedParent = state.nodes[dropTarget.parentId as AnyNodeId]\n      if (updatedParent && 'children' in updatedParent && Array.isArray(updatedParent.children)) {\n        const children = [...updatedParent.children] as string[]\n        const idx = children.indexOf(drag.nodeId)\n        if (idx !== -1) {\n          children.splice(idx, 1)\n          const toIndex = Math.min(dropTarget.insertIndex, children.length)\n          children.splice(toIndex, 0, drag.nodeId)\n          state.updateNode(dropTarget.parentId as AnyNodeId, { children } as any)\n        }\n      }\n\n      // Lifecycle: remove old parent if it's now empty and in REMOVE_WHEN_EMPTY\n      const staleParent = state.nodes[drag.sourceParentId as AnyNodeId]\n      if (\n        staleParent &&\n        REMOVE_WHEN_EMPTY.has(staleParent.type) &&\n        'children' in staleParent &&\n        Array.isArray(staleParent.children) &&\n        staleParent.children.length === 0\n      ) {\n        state.deleteNode(drag.sourceParentId as AnyNodeId)\n      }\n    }\n  }, [drag, dropTarget])\n\n  const startDrag = useCallback(\n    (\n      nodeId: string,\n      nodeType: string,\n      sourceParentId: string,\n      label: string,\n      x: number,\n      y: number,\n    ) => {\n      pendingRef.current = { nodeId, nodeType, sourceParentId, label, startX: x, startY: y }\n    },\n    [],\n  )\n\n  useEffect(() => {\n    const handlePointerMove = (e: PointerEvent) => {\n      if (pendingRef.current && !drag) {\n        const dx = e.clientX - pendingRef.current.startX\n        const dy = e.clientY - pendingRef.current.startY\n        if (Math.abs(dx) + Math.abs(dy) >= DRAG_THRESHOLD) {\n          const p = pendingRef.current\n          setDrag({\n            nodeId: p.nodeId,\n            nodeType: p.nodeType,\n            sourceParentId: p.sourceParentId,\n            label: p.label,\n            pointerX: e.clientX,\n            pointerY: e.clientY,\n          })\n        }\n        return\n      }\n\n      if (!drag) return\n\n      setDrag((prev) => (prev ? { ...prev, pointerX: e.clientX, pointerY: e.clientY } : null))\n\n      // Hit-test for drop targets\n      const els = document.elementsFromPoint(e.clientX, e.clientY)\n      let foundTarget: DropTarget = null\n\n      for (const el of els) {\n        const targetEl = (el as HTMLElement).closest?.('[data-drop-target]') as HTMLElement | null\n        if (!targetEl) continue\n\n        const parentId = targetEl.dataset.dropTarget!\n\n        // Validate this is a legal drop\n        const targetNode = useScene.getState().nodes[parentId as AnyNodeId]\n        if (!(targetNode && canDrop(drag.nodeType, targetNode.type))) continue\n\n        // Find child rows to determine insert index\n        const childRows = targetEl.querySelectorAll<HTMLElement>('[data-drop-child]')\n        let insertIndex = childRows.length\n\n        for (let i = 0; i < childRows.length; i++) {\n          const row = childRows[i]!\n          const rect = row.getBoundingClientRect()\n          const midY = rect.top + rect.height / 2\n          if (e.clientY < midY) {\n            insertIndex = i\n            break\n          }\n        }\n\n        foundTarget = { parentId, insertIndex }\n        break\n      }\n\n      setDropTarget(foundTarget)\n    }\n\n    const handlePointerUp = () => {\n      if (drag) {\n        commitDrop()\n      }\n      pendingRef.current = null\n      setDrag(null)\n      setDropTarget(null)\n    }\n\n    window.addEventListener('pointermove', handlePointerMove)\n    window.addEventListener('pointerup', handlePointerUp)\n    return () => {\n      window.removeEventListener('pointermove', handlePointerMove)\n      window.removeEventListener('pointerup', handlePointerUp)\n    }\n  }, [drag, commitDrop])\n\n  const isDragging = drag !== null\n\n  return (\n    <TreeNodeDragContext.Provider value={{ drag, dropTarget, startDrag, isDragging }}>\n      {isDragging && <style>{'* { cursor: grabbing !important; }'}</style>}\n      {children}\n      {drag && <FloatingPreview drag={drag} />}\n    </TreeNodeDragContext.Provider>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Floating preview (portal)\n// ---------------------------------------------------------------------------\n\nfunction FloatingPreview({ drag }: { drag: NonNullable<DragState> }) {\n  return createPortal(\n    <div\n      className=\"pointer-events-none fixed z-[200] flex items-center gap-1.5 rounded-lg border border-accent bg-background/95 px-2.5 py-1.5 font-medium text-foreground text-xs shadow-xl backdrop-blur-sm\"\n      style={{\n        left: drag.pointerX + 12,\n        top: drag.pointerY - 14,\n      }}\n    >\n      <span className=\"opacity-60\">↕</span>\n      {drag.label}\n    </div>,\n    document.body,\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Drop indicator line\n// ---------------------------------------------------------------------------\n\nexport function DropIndicatorLine() {\n  return (\n    <motion.div\n      animate={{ height: 2, opacity: 1 }}\n      className=\"pointer-events-none mx-3 rounded-full bg-blue-500\"\n      exit={{ height: 0, opacity: 0 }}\n      initial={{ height: 0, opacity: 0 }}\n      transition={{ type: 'spring', bounce: 0.3, duration: 0.25 }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx",
    "content": "import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'\nimport { ChevronRight } from 'lucide-react'\nimport { AnimatePresence, motion } from 'motion/react'\nimport { forwardRef, useEffect, useRef } from 'react'\n\nexport function handleTreeSelection(\n  e: React.MouseEvent,\n  nodeId: string,\n  selectedIds: string[],\n  setSelection: (s: any) => void,\n) {\n  if (e.metaKey || e.ctrlKey) {\n    if (selectedIds.includes(nodeId)) {\n      setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) })\n    } else {\n      setSelection({ selectedIds: [...selectedIds, nodeId] })\n    }\n    return true\n  }\n\n  if (e.shiftKey && selectedIds.length > 0) {\n    const lastSelectedId = selectedIds[selectedIds.length - 1]\n    if (lastSelectedId) {\n      const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'))\n      const nodeIds = nodes.map((n) => n.getAttribute('data-treenode-id') as string)\n\n      const startIndex = nodeIds.indexOf(lastSelectedId)\n      const endIndex = nodeIds.indexOf(nodeId)\n\n      if (startIndex !== -1 && endIndex !== -1) {\n        const start = Math.min(startIndex, endIndex)\n        const end = Math.max(startIndex, endIndex)\n        const range = nodeIds.slice(start, end + 1)\n\n        // We can keep the previous selections that were outside the range if we want,\n        // but standard file system shift-click replaces the selection with the range.\n        setSelection({ selectedIds: range })\n        return true\n      }\n    }\n    // Fallback: if range selection fails (e.g. node not visible in tree), just add to selection\n    if (!selectedIds.includes(nodeId)) {\n      setSelection({ selectedIds: [...selectedIds, nodeId] })\n      return true\n    }\n  }\n\n  setSelection({ selectedIds: [nodeId] })\n  return false\n}\n\nexport function focusTreeNode(nodeId: AnyNodeId) {\n  emitter.emit('camera-controls:focus', { nodeId })\n}\n\nimport { cn } from '../../../../../lib/utils'\nimport { BuildingTreeNode } from './building-tree-node'\nimport { CeilingTreeNode } from './ceiling-tree-node'\nimport { DoorTreeNode } from './door-tree-node'\nimport { ItemTreeNode } from './item-tree-node'\nimport { LevelTreeNode } from './level-tree-node'\nimport { RoofTreeNode } from './roof-tree-node'\nimport { SlabTreeNode } from './slab-tree-node'\nimport { StairTreeNode } from './stair-tree-node'\nimport { WallTreeNode } from './wall-tree-node'\nimport { WindowTreeNode } from './window-tree-node'\nimport { ZoneTreeNode } from './zone-tree-node'\n\ninterface TreeNodeProps {\n  nodeId: AnyNodeId\n  depth?: number\n  isLast?: boolean\n}\n\nexport function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {\n  const node = useScene((state) => state.nodes[nodeId])\n\n  if (!node) return null\n\n  switch (node.type) {\n    case 'building':\n      return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'ceiling':\n      return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'level':\n      return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'slab':\n      return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'wall':\n      return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'roof':\n      return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'stair':\n      return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'item':\n      return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'door':\n      return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'window':\n      return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />\n    case 'zone':\n      return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />\n    default:\n      return null\n  }\n}\n\ninterface TreeNodeWrapperProps {\n  nodeId?: string\n  icon: React.ReactNode\n  label: React.ReactNode\n  depth: number\n  hasChildren: boolean\n  expanded: boolean\n  onToggle: () => void\n  onClick: (e: React.MouseEvent) => void\n  onDoubleClick?: () => void\n  onMouseEnter?: () => void\n  onMouseLeave?: () => void\n  onPointerDown?: (e: React.PointerEvent) => void\n  actions?: React.ReactNode\n  children?: React.ReactNode\n  isSelected?: boolean\n  isHovered?: boolean\n  isVisible?: boolean\n  isLast?: boolean\n  isDraggable?: boolean\n  isDropTarget?: boolean\n}\n\nexport const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(\n  function TreeNodeWrapper(\n    {\n      nodeId,\n      icon,\n      label,\n      depth,\n      hasChildren,\n      expanded,\n      onToggle,\n      onClick,\n      onDoubleClick,\n      onMouseEnter,\n      onMouseLeave,\n      onPointerDown,\n      actions,\n      children,\n      isSelected,\n      isHovered,\n      isVisible = true,\n      isLast,\n      isDraggable,\n      isDropTarget,\n    },\n    ref,\n  ) {\n    const rowRef = useRef<HTMLDivElement>(null)\n\n    useEffect(() => {\n      if (isSelected && rowRef.current) {\n        rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })\n      }\n    }, [isSelected])\n\n    return (\n      <div data-treenode-id={nodeId} ref={ref}>\n        <div\n          className={cn(\n            'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b text-sm transition-all duration-200',\n            isSelected\n              ? 'border-r-3 border-r-white bg-accent/50 text-foreground'\n              : isDropTarget\n                ? 'bg-blue-500/15 text-foreground ring-1 ring-blue-500/40 ring-inset'\n                : isHovered\n                  ? 'bg-accent/30 text-foreground'\n                  : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\n            !isVisible && 'opacity-50',\n            isDraggable && 'cursor-grab active:cursor-grabbing',\n          )}\n          onClick={onClick}\n          onDoubleClick={onDoubleClick}\n          onMouseEnter={onMouseEnter}\n          onMouseLeave={onMouseLeave}\n          onPointerDown={onPointerDown}\n          ref={rowRef}\n          style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}\n        >\n          {/* Vertical tree line */}\n          <div\n            className={cn(\n              'pointer-events-none absolute w-px bg-border/50',\n              isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',\n            )}\n            style={{ left: (depth - 1) * 12 + 20 }}\n          />\n          {/* Horizontal branch line */}\n          <div\n            className=\"pointer-events-none absolute top-1/2 h-px bg-border/50\"\n            style={{ left: (depth - 1) * 12 + 20, width: 4 }}\n          />\n          {/* Line down to children */}\n          {hasChildren && expanded && (\n            <div\n              className=\"pointer-events-none absolute top-1/2 bottom-0 w-px bg-border/50\"\n              style={{ left: depth * 12 + 20 }}\n            />\n          )}\n\n          <button\n            className=\"z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit\"\n            onClick={(e) => {\n              e.stopPropagation()\n              onToggle()\n            }}\n          >\n            {hasChildren ? (\n              <motion.div\n                animate={{ rotate: expanded ? 90 : 0 }}\n                initial={false}\n                transition={{ duration: 0.2 }}\n              >\n                <ChevronRight className=\"h-3 w-3\" />\n              </motion.div>\n            ) : null}\n          </button>\n          <div className=\"flex min-w-0 flex-1 items-center gap-1.5\">\n            <span\n              className={cn(\n                'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',\n                !isSelected && 'opacity-60 grayscale',\n              )}\n            >\n              {icon}\n            </span>\n            <div\n              className={cn(\n                'min-w-0 flex-1 truncate',\n                !isVisible && 'text-muted-foreground line-through',\n              )}\n            >\n              {label}\n            </div>\n          </div>\n          {actions && (\n            <div\n              className={cn(\n                'pr-1 opacity-0 transition-opacity duration-200 group-hover/row:opacity-100',\n                !isVisible && 'opacity-100',\n              )}\n            >\n              {actions}\n            </div>\n          )}\n        </div>\n        <AnimatePresence initial={false}>\n          {expanded && children && (\n            <motion.div\n              animate={{ height: 'auto', opacity: 1 }}\n              className=\"overflow-hidden\"\n              exit={{ height: 0, opacity: 0 }}\n              initial={{ height: 0, opacity: 0 }}\n              transition={{ type: 'spring', bounce: 0, duration: 0.3 }}\n            >\n              {children}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx",
    "content": "import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useEffect, useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface WallTreeNodeProps {\n  node: WallNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {\n  const [expanded, setExpanded] = useState(false)\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  useEffect(() => {\n    if (selectedIds.length === 0) return\n    const nodes = useScene.getState().nodes\n    let isDescendant = false\n    for (const id of selectedIds) {\n      let current = nodes[id as AnyNodeId]\n      while (current?.parentId) {\n        if (current.parentId === node.id) {\n          isDescendant = true\n          break\n        }\n        current = nodes[current.parentId as AnyNodeId]\n      }\n      if (isDescendant) break\n    }\n    if (isDescendant) {\n      setExpanded(true)\n    }\n  }, [selectedIds, node.id])\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n    if (!handled && useEditor.getState().phase === 'furnish') {\n      useEditor.getState().setPhase('structure')\n    }\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  const defaultName = 'Wall'\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={expanded}\n      hasChildren={node.children.length > 0}\n      icon={\n        <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/wall.png\" width={14} />\n      }\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onToggle={() => setExpanded(!expanded)}\n    >\n      {node.children.map((childId, index) => (\n        <TreeNode\n          depth={depth + 1}\n          isLast={index === node.children.length - 1}\n          key={childId}\n          nodeId={childId}\n        />\n      ))}\n    </TreeNodeWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx",
    "content": "'use client'\n\nimport type { WindowNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport Image from 'next/image'\nimport { useState } from 'react'\nimport useEditor from './../../../../../store/use-editor'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface WindowTreeNodeProps {\n  node: WindowNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const selectedIds = useViewer((state) => state.selection.selectedIds)\n  const isSelected = selectedIds.includes(node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  const defaultName = 'Window'\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={false}\n      hasChildren={false}\n      icon={\n        <Image alt=\"\" className=\"object-contain\" height={14} src=\"/icons/window.png\" width={14} />\n      }\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      isVisible={node.visible !== false}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      nodeId={node.id}\n      onClick={(e: React.MouseEvent) => {\n        e.stopPropagation()\n        const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)\n        if (!handled && useEditor.getState().phase === 'furnish') {\n          useEditor.getState().setPhase('structure')\n        }\n      }}\n      onDoubleClick={() => focusTreeNode(node.id)}\n      onMouseEnter={() => setHoveredId(node.id)}\n      onMouseLeave={() => setHoveredId(null)}\n      onToggle={() => {}}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx",
    "content": "import { useScene, type ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useState } from 'react'\nimport { ColorDot } from './../../../../../components/ui/primitives/color-dot'\nimport { InlineRenameInput } from './inline-rename-input'\nimport { focusTreeNode, TreeNodeWrapper } from './tree-node'\nimport { TreeNodeActions } from './tree-node-actions'\n\ninterface ZoneTreeNodeProps {\n  node: ZoneNode\n  depth: number\n  isLast?: boolean\n}\n\nexport function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const updateNode = useScene((state) => state.updateNode)\n  const isSelected = useViewer((state) => state.selection.zoneId === node.id)\n  const isHovered = useViewer((state) => state.hoveredId === node.id)\n  const setSelection = useViewer((state) => state.setSelection)\n  const setHoveredId = useViewer((state) => state.setHoveredId)\n\n  const handleClick = () => {\n    setSelection({ zoneId: node.id })\n  }\n\n  const handleDoubleClick = () => {\n    focusTreeNode(node.id)\n  }\n\n  const handleMouseEnter = () => {\n    setHoveredId(node.id)\n  }\n\n  const handleMouseLeave = () => {\n    setHoveredId(null)\n  }\n\n  // Calculate approximate area from polygon\n  const area = calculatePolygonArea(node.polygon).toFixed(1)\n  const defaultName = `Zone (${area}m²)`\n\n  return (\n    <TreeNodeWrapper\n      actions={<TreeNodeActions node={node} />}\n      depth={depth}\n      expanded={false}\n      hasChildren={false}\n      icon={<ColorDot color={node.color} onChange={(color) => updateNode(node.id, { color })} />}\n      isHovered={isHovered}\n      isLast={isLast}\n      isSelected={isSelected}\n      label={\n        <InlineRenameInput\n          defaultName={defaultName}\n          isEditing={isEditing}\n          node={node}\n          onStartEditing={() => setIsEditing(true)}\n          onStopEditing={() => setIsEditing(false)}\n        />\n      }\n      onClick={handleClick}\n      onDoubleClick={handleDoubleClick}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onToggle={() => {}}\n    />\n  )\n}\n\n/**\n * Calculate the area of a polygon using the shoelace formula\n */\nfunction calculatePolygonArea(polygon: Array<[number, number]>): number {\n  if (polygon.length < 3) return 0\n\n  let area = 0\n  const n = polygon.length\n\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n\n    const pi = polygon[i]\n    const pj = polygon[j]\n    if (pi && pj) {\n      area += pi[0] * pj[1]\n      area -= pj[0] * pi[1]\n    }\n  }\n\n  return Math.abs(area) / 2\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx",
    "content": "import { emitter, useScene, type ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { Camera, Hexagon, Trash2 } from 'lucide-react'\nimport { useState } from 'react'\nimport { ColorDot } from './../../../../../components/ui/primitives/color-dot'\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from './../../../../../components/ui/primitives/popover'\nimport { cn } from './../../../../../lib/utils'\nimport useEditor from './../../../../../store/use-editor'\n\nfunction ZoneItem({ zone }: { zone: ZoneNode }) {\n  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)\n  const deleteNode = useScene((state) => state.deleteNode)\n  const updateNode = useScene((state) => state.updateNode)\n  const selectedZoneId = useViewer((state) => state.selection.zoneId)\n  const setSelection = useViewer((state) => state.setSelection)\n\n  const isSelected = selectedZoneId === zone.id\n\n  const handleClick = () => {\n    setSelection({ zoneId: zone.id })\n  }\n\n  const handleDelete = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    deleteNode(zone.id)\n    if (isSelected) {\n      setSelection({ zoneId: null })\n    }\n  }\n\n  const handleColorChange = (color: string) => {\n    updateNode(zone.id, { color })\n  }\n\n  return (\n    <div\n      className={cn(\n        'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',\n        isSelected\n          ? 'border-neutral-200/60 bg-white text-foreground shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'\n          : 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',\n      )}\n      onClick={handleClick}\n    >\n      <span className=\"mr-2\">\n        <ColorDot color={zone.color} onChange={handleColorChange} />\n      </span>\n      <Hexagon className=\"mr-1.5 h-3.5 w-3.5 shrink-0\" />\n      <span className=\"flex-1 truncate\">{zone.name}</span>\n      {/* Camera snapshot button */}\n      <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>\n        <PopoverTrigger asChild>\n          <button\n            className=\"relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10\"\n            onClick={(e) => e.stopPropagation()}\n            title=\"Camera snapshot\"\n          >\n            <Camera className=\"h-3 w-3\" />\n            {zone.camera && (\n              <span className=\"absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary\" />\n            )}\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"start\"\n          className=\"w-auto p-1\"\n          onClick={(e) => e.stopPropagation()}\n          side=\"right\"\n        >\n          <div className=\"flex flex-col gap-0.5\">\n            {zone.camera && (\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  emitter.emit('camera-controls:view', { nodeId: zone.id })\n                  setCameraPopoverOpen(false)\n                }}\n              >\n                <Camera className=\"h-3.5 w-3.5\" />\n                View snapshot\n              </button>\n            )}\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent\"\n              onClick={(e) => {\n                e.stopPropagation()\n                emitter.emit('camera-controls:capture', { nodeId: zone.id })\n                setCameraPopoverOpen(false)\n              }}\n            >\n              <Camera className=\"h-3.5 w-3.5\" />\n              {zone.camera ? 'Update snapshot' : 'Take snapshot'}\n            </button>\n            {zone.camera && (\n              <button\n                className=\"flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  updateNode(zone.id, { camera: undefined })\n                  setCameraPopoverOpen(false)\n                }}\n              >\n                <Trash2 className=\"h-3.5 w-3.5\" />\n                Clear snapshot\n              </button>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n      <button\n        className=\"flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10\"\n        onClick={handleDelete}\n      >\n        <Trash2 className=\"h-3 w-3\" />\n      </button>\n    </div>\n  )\n}\n\nexport function ZonePanel() {\n  const nodes = useScene((state) => state.nodes)\n  const currentLevelId = useViewer((state) => state.selection.levelId)\n  const setPhase = useEditor((state) => state.setPhase)\n  const setMode = useEditor((state) => state.setMode)\n  const setTool = useEditor((state) => state.setTool)\n\n  // Filter nodes to get zones for the current level\n  const levelZones = Object.values(nodes).filter(\n    (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,\n  )\n\n  const handleAddZone = () => {\n    if (currentLevelId) {\n      setPhase('structure')\n      setMode('build')\n      setTool('zone')\n    }\n  }\n\n  if (!currentLevelId) {\n    return (\n      <div className=\"px-3 py-4 text-muted-foreground text-sm\">\n        Select a level to view and create zones\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"py-1\">\n      {levelZones.length === 0 ? (\n        <div className=\"px-3 py-4 text-muted-foreground text-sm\">\n          No zones on this level.{' '}\n          <button className=\"cursor-pointer text-primary hover:underline\" onClick={handleAddZone}>\n            Add one\n          </button>\n        </div>\n      ) : (\n        levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/sidebar/tab-bar.tsx",
    "content": "'use client'\n\nimport { cn } from './../../../lib/utils'\n\nexport type SidebarTab = {\n  id: string\n  label: string\n}\n\ninterface TabBarProps {\n  tabs: SidebarTab[]\n  activeTab: string\n  onTabChange: (id: string) => void\n}\n\nexport function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {\n  return (\n    <div className=\"flex h-10 shrink-0 items-center gap-0.5 border-border/50 border-b px-2\">\n      {tabs.map((tab) => {\n        const isActive = activeTab === tab.id\n        return (\n          <button\n            className={cn(\n              'relative h-7 rounded-md px-3 font-medium text-sm transition-colors',\n              isActive\n                ? 'bg-accent text-foreground'\n                : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',\n            )}\n            key={tab.id}\n            onClick={() => onTabChange(tab.id)}\n            type=\"button\"\n          >\n            {tab.label}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/slider-demo.tsx",
    "content": "import NumberFlow from '@number-flow/react'\nimport { useState } from 'react'\n\nimport { Slider } from './../../components/ui/slider'\n\nexport function SliderDemo() {\n  const [value, setValue] = useState<number[]>([28.1])\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-[#ededed] px-8\">\n      <section className=\"w-full max-w-lg\">\n        <div className=\"mb-2 flex items-end justify-between\">\n          <h2 className=\"font-semibold text-black text-xl tracking-tight\">Temperature</h2>\n          <NumberFlow\n            className=\"font-medium text-black/45 text-xl\"\n            format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}\n            suffix=\"%\"\n            value={value[0] ?? 50}\n          />\n        </div>\n\n        <Slider\n          aria-label=\"Temperature\"\n          max={100}\n          min={0}\n          onValueChange={setValue}\n          step={0.1}\n          value={value}\n          variant=\"temperature\"\n        />\n      </section>\n    </div>\n  )\n}\n\nexport default SliderDemo\n"
  },
  {
    "path": "packages/editor/src/components/ui/slider.tsx",
    "content": "import * as SliderPrimitive from '@radix-ui/react-slider'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport type * as React from 'react'\n\nimport { cn } from './../../lib/utils'\n\nconst sliderVariants = cva(\n  'relative flex w-full touch-none select-none items-center overflow-hidden',\n  {\n    variants: {\n      variant: {\n        default: '',\n        temperature: `\n          h-16\n          [&_[data-slot=slider-track]]:h-14\n          [&_[data-slot=slider-track]]:rounded-xl\n          [&_[data-slot=slider-track]]:border\n          [&_[data-slot=slider-track]]:border-neutral-300\n          [&_[data-slot=slider-track]]:bg-white/50\n          [&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]\n          [&_[data-slot=slider-track]]:ring-1\n          [&_[data-slot=slider-track]]:ring-white\n          [&_[data-slot=slider-track]]:ring-inset\n          [&_[data-slot=slider-range]]:inset-y-0.5\n          [&_[data-slot=slider-range]]:h-auto\n          [&_[data-slot=slider-range]]:ml-0.5\n          [&_[data-slot=slider-range]]:mr-0.5\n          [&_[data-slot=slider-range]]:overflow-hidden\n          [&_[data-slot=slider-range]]:rounded-lg\n          [&_[data-slot=slider-range]]:border\n          [&_[data-slot=slider-range]]:border-neutral-300\n          [&_[data-slot=slider-range]]:bg-white\n          [&_[data-slot=slider-range]]:shadow-xs\n          [&_[data-slot=slider-thumb]]:h-7\n          [&_[data-slot=slider-thumb]]:w-[3px]\n          [&_[data-slot=slider-thumb]]:rounded-xl\n          [&_[data-slot=slider-thumb]]:border-0\n          [&_[data-slot=slider-thumb]]:bg-neutral-100\n          [&_[data-slot=slider-thumb]]:shadow-none\n          [&_[data-slot=slider-thumb]]:cursor-ew-resize\n          [&_[data-slot=slider-thumb]]:[transform:translateX(-8px)]\n          [&_[data-slot=slider-thumb]]:ring-0\n          [&_[data-slot=slider-thumb]]:hover:ring-0\n          [&_[data-slot=slider-thumb]]:focus-visible:ring-0\n        `,\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  },\n)\n\ntype SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &\n  VariantProps<typeof sliderVariants>\n\nfunction Slider({ variant, className, ...props }: SliderProps) {\n  return (\n    <SliderPrimitive.Root\n      className={cn(sliderVariants({ variant }), className)}\n      data-slot=\"slider\"\n      {...props}\n    >\n      <SliderPrimitive.Track\n        className=\"relative h-3 w-full grow overflow-hidden rounded-full bg-muted\"\n        data-slot=\"slider-track\"\n      >\n        <SliderPrimitive.Range className=\"absolute h-full bg-primary\" data-slot=\"slider-range\" />\n      </SliderPrimitive.Track>\n      <SliderPrimitive.Thumb\n        className={cn(\n          'block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50',\n          'transition-[color,box-shadow] hover:ring-4 focus-visible:outline-none focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50',\n        )}\n        data-slot=\"slider-thumb\"\n      />\n    </SliderPrimitive.Root>\n  )\n}\n\nexport { Slider }\n"
  },
  {
    "path": "packages/editor/src/components/ui/viewer-toolbar.tsx",
    "content": "'use client'\n\nimport { Icon as IconifyIcon } from '@iconify/react'\nimport { useViewer } from '@pascal-app/viewer'\nimport { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { cn } from '../../lib/utils'\nimport useEditor from '../../store/use-editor'\nimport type { ViewMode } from '../../store/use-editor'\nimport { useSidebarStore } from './primitives/sidebar'\nimport { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'\n\n// ── Shared styles ───────────────────────────────────────────────────────────\n\n/** Container for a group of buttons — no padding, overflow-hidden clips children flush. */\nconst TOOLBAR_CONTAINER =\n  'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'\n\n/** Ghost button inside a container — flush edges, no individual border/radius. */\nconst TOOLBAR_BTN =\n  'flex items-center justify-center w-8 text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'\n\n// ── View mode segmented control ─────────────────────────────────────────────\n\nconst VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [\n  {\n    id: '3d',\n    label: '3D',\n    icon: <img alt=\"\" className=\"h-3.5 w-3.5 object-contain\" src=\"/icons/building.png\" />,\n  },\n  {\n    id: '2d',\n    label: '2D',\n    icon: <img alt=\"\" className=\"h-3.5 w-3.5 object-contain\" src=\"/icons/blueprint.png\" />,\n  },\n  {\n    id: 'split',\n    label: 'Split',\n    icon: <Columns2 className=\"h-3 w-3\" />,\n  },\n]\n\nfunction ViewModeControl() {\n  const viewMode = useEditor((s) => s.viewMode)\n  const setViewMode = useEditor((s) => s.setViewMode)\n\n  return (\n    <div className={TOOLBAR_CONTAINER}>\n      {VIEW_MODES.map((mode) => {\n        const isActive = viewMode === mode.id\n        return (\n          <button\n            className={cn(\n              'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',\n              isActive\n                ? 'bg-white/10 text-foreground'\n                : 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',\n            )}\n            key={mode.id}\n            onClick={() => setViewMode(mode.id)}\n            type=\"button\"\n          >\n            {mode.icon}\n            <span>{mode.label}</span>\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\n// ── Collapse sidebar button ─────────────────────────────────────────────────\n\nfunction CollapseSidebarButton() {\n  const isCollapsed = useSidebarStore((s) => s.isCollapsed)\n  const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)\n\n  const toggle = useCallback(() => {\n    setIsCollapsed(!isCollapsed)\n  }, [isCollapsed, setIsCollapsed])\n\n  return (\n    <div className={TOOLBAR_CONTAINER}>\n      <button\n        className={TOOLBAR_BTN}\n        onClick={toggle}\n        title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}\n        type=\"button\"\n      >\n        {isCollapsed ? <ChevronsRight className=\"h-4 w-4\" /> : <ChevronsLeft className=\"h-4 w-4\" />}\n      </button>\n    </div>\n  )\n}\n\n// ── Right toolbar buttons ───────────────────────────────────────────────────\n\nfunction WalkthroughButton() {\n  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)\n  const setFirstPersonMode = useEditor((s) => s.setFirstPersonMode)\n\n  const toggle = () => {\n    setFirstPersonMode(!isFirstPersonMode)\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={cn(\n            TOOLBAR_BTN,\n            isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',\n          )}\n          onClick={toggle}\n          type=\"button\"\n        >\n          <Footprints className=\"h-4 w-4\" />\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">Walkthrough</TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction UnitToggle() {\n  const unit = useViewer((s) => s.unit)\n  const setUnit = useViewer((s) => s.setUnit)\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={TOOLBAR_BTN}\n          onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}\n          type=\"button\"\n        >\n          <span className=\"font-semibold text-[10px]\">{unit === 'metric' ? 'm' : 'ft'}</span>\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">\n        {unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction ThemeToggle() {\n  const theme = useViewer((s) => s.theme)\n  const setTheme = useViewer((s) => s.setTheme)\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/60' : 'text-amber-400/60')}\n          onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}\n          type=\"button\"\n        >\n          {theme === 'dark' ? <Moon className=\"h-3.5 w-3.5\" /> : <Sun className=\"h-3.5 w-3.5\" />}\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">{theme === 'dark' ? 'Dark' : 'Light'}</TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ── Level mode toggle ───────────────────────────────────────────────────────\n\nconst levelModeOrder = ['stacked', 'exploded', 'solo'] as const\nconst levelModeLabels: Record<string, string> = {\n  manual: 'Stack',\n  stacked: 'Stack',\n  exploded: 'Exploded',\n  solo: 'Solo',\n}\n\nfunction LevelModeToggle() {\n  const levelMode = useViewer((s) => s.levelMode)\n  const setLevelMode = useViewer((s) => s.setLevelMode)\n\n  const cycle = () => {\n    if (levelMode === 'manual') {\n      setLevelMode('stacked')\n      return\n    }\n    const idx = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])\n    const next = levelModeOrder[(idx + 1) % levelModeOrder.length]\n    if (next) setLevelMode(next)\n  }\n\n  const isDefault = levelMode === 'stacked' || levelMode === 'manual'\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={cn(\n            TOOLBAR_BTN,\n            'w-auto gap-1.5 px-2.5',\n            !isDefault && 'bg-white/10 text-foreground/90',\n          )}\n          onClick={cycle}\n          type=\"button\"\n        >\n          {levelMode === 'solo' ? (\n            <IconifyIcon height={14} icon=\"lucide:diamond\" width={14} />\n          ) : levelMode === 'exploded' ? (\n            <IconifyIcon height={14} icon=\"charm:stack-pop\" width={14} />\n          ) : (\n            <IconifyIcon height={14} icon=\"charm:stack-push\" width={14} />\n          )}\n          <span className=\"font-medium text-xs\">{levelModeLabels[levelMode] ?? 'Stack'}</span>\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">\n        Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode]}\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ── Wall mode toggle ────────────────────────────────────────────────────────\n\nconst wallModeOrder = ['cutaway', 'up', 'down'] as const\nconst wallModeConfig: Record<string, { icon: string; label: string }> = {\n  up: { icon: '/icons/room.png', label: 'Full height' },\n  cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },\n  down: { icon: '/icons/walllow.png', label: 'Low' },\n}\n\nfunction WallModeToggle() {\n  const wallMode = useViewer((s) => s.wallMode)\n  const setWallMode = useViewer((s) => s.setWallMode)\n\n  const cycle = () => {\n    const idx = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])\n    const next = wallModeOrder[(idx + 1) % wallModeOrder.length]\n    if (next) setWallMode(next)\n  }\n\n  const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={cn(\n            TOOLBAR_BTN,\n            'w-auto gap-1.5 px-2.5',\n            wallMode !== 'cutaway'\n              ? 'bg-white/10'\n              : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',\n          )}\n          onClick={cycle}\n          type=\"button\"\n        >\n          <img alt={config.label} className=\"h-4 w-4 object-contain\" src={config.icon} />\n          <span className=\"font-medium text-xs\">{config.label}</span>\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">Walls: {config.label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ── Camera mode toggle ──────────────────────────────────────────────────────\n\nfunction CameraModeToggle() {\n  const cameraMode = useViewer((s) => s.cameraMode)\n  const setCameraMode = useViewer((s) => s.setCameraMode)\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className={cn(\n            TOOLBAR_BTN,\n            cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',\n          )}\n          onClick={() =>\n            setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')\n          }\n          type=\"button\"\n        >\n          {cameraMode === 'perspective' ? (\n            <IconifyIcon height={16} icon=\"icon-park-outline:perspective\" width={16} />\n          ) : (\n            <IconifyIcon height={16} icon=\"vaadin:grid\" width={16} />\n          )}\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">\n        {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction PreviewButton() {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <button\n          className=\"flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90\"\n          onClick={() => useEditor.getState().setPreviewMode(true)}\n          type=\"button\"\n        >\n          <Eye className=\"h-3.5 w-3.5 shrink-0\" />\n          <span>Preview</span>\n        </button>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">Preview mode</TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ── Composed toolbar sections ───────────────────────────────────────────────\n\nexport function ViewerToolbarLeft() {\n  return (\n    <>\n      <CollapseSidebarButton />\n      <ViewModeControl />\n    </>\n  )\n}\n\nexport function ViewerToolbarRight() {\n  return (\n    <div className={TOOLBAR_CONTAINER}>\n      <LevelModeToggle />\n      <WallModeToggle />\n      <div className=\"my-1.5 w-px bg-border/50\" />\n      <UnitToggle />\n      <ThemeToggle />\n      <CameraModeToggle />\n      <div className=\"my-1.5 w-px bg-border/50\" />\n      <WalkthroughButton />\n      <PreviewButton />\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/editor/src/components/viewer-overlay.tsx",
    "content": "'use client'\r\n\r\nimport { Icon } from '@iconify/react'\r\nimport {\r\n  type AnyNode,\r\n  type AnyNodeId,\r\n  type BuildingNode,\r\n  emitter,\r\n  type LevelNode,\r\n  useScene,\r\n  type ZoneNode,\r\n} from '@pascal-app/core'\r\nimport { useViewer } from '@pascal-app/viewer'\r\nimport { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react'\r\nimport { motion } from 'motion/react'\r\nimport Link from 'next/link'\r\nimport { cn } from '../lib/utils'\r\nimport useEditor from '../store/use-editor'\r\nimport { ActionButton } from './ui/action-menu/action-button'\r\nimport { TooltipProvider } from './ui/primitives/tooltip'\r\n\r\ntype ProjectOwner = {\r\n  id: string\r\n  name: string\r\n  username: string | null\r\n  image: string | null\r\n}\r\n\r\nconst levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {\r\n  stacked: 'Stacked',\r\n  exploded: 'Exploded',\r\n  solo: 'Solo',\r\n}\r\n\r\nconst levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {\r\n  manual: 'Stack',\r\n  stacked: 'Stack',\r\n  exploded: 'Exploded',\r\n  solo: 'Solo',\r\n}\r\n\r\nconst wallModeConfig = {\r\n  up: {\r\n    icon: (props: any) => (\r\n      <img alt=\"Full Height\" height={28} src=\"/icons/room.png\" width={28} {...props} />\r\n    ),\r\n    label: 'Full Height',\r\n  },\r\n  cutaway: {\r\n    icon: (props: any) => (\r\n      <img alt=\"Cutaway\" height={28} src=\"/icons/wallcut.png\" width={28} {...props} />\r\n    ),\r\n    label: 'Cutaway',\r\n  },\r\n  down: {\r\n    icon: (props: any) => (\r\n      <img alt=\"Low\" height={28} src=\"/icons/walllow.png\" width={28} {...props} />\r\n    ),\r\n    label: 'Low',\r\n  },\r\n}\r\n\r\nconst getNodeName = (node: AnyNode): string => {\r\n  if ('name' in node && node.name) return node.name\r\n  if (node.type === 'wall') return 'Wall'\r\n  if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'\r\n  if (node.type === 'slab') return 'Slab'\r\n  if (node.type === 'ceiling') return 'Ceiling'\r\n  if (node.type === 'roof') return 'Roof'\r\n  if (node.type === 'roof-segment') return 'Roof Segment'\r\n  return node.type\r\n}\r\n\r\ninterface ViewerOverlayProps {\r\n  projectName?: string | null\r\n  owner?: ProjectOwner | null\r\n  canShowScans?: boolean\r\n  canShowGuides?: boolean\r\n  onBack?: () => void\r\n}\r\n\r\nexport const ViewerOverlay = ({\r\n  projectName,\r\n  owner,\r\n  canShowScans = true,\r\n  canShowGuides = true,\r\n  onBack,\r\n}: ViewerOverlayProps) => {\r\n  const selection = useViewer((s) => s.selection)\r\n  const nodes = useScene((s) => s.nodes)\r\n  const showScans = useViewer((s) => s.showScans)\r\n  const showGuides = useViewer((s) => s.showGuides)\r\n  const cameraMode = useViewer((s) => s.cameraMode)\r\n  const levelMode = useViewer((s) => s.levelMode)\r\n  const wallMode = useViewer((s) => s.wallMode)\r\n  const theme = useViewer((s) => s.theme)\r\n\r\n  const building = selection.buildingId\r\n    ? (nodes[selection.buildingId] as BuildingNode | undefined)\r\n    : null\r\n  const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null\r\n  const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null\r\n\r\n  // Get the first selected item (if any)\r\n  const selectedNode =\r\n    selection.selectedIds.length > 0\r\n      ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)\r\n      : null\r\n\r\n  // Get all levels for the selected building\r\n  const levels =\r\n    building?.children\r\n      .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)\r\n      .filter((n): n is LevelNode => n?.type === 'level')\r\n      .sort((a, b) => a.level - b.level) ?? []\r\n\r\n  const handleLevelClick = (levelId: LevelNode['id']) => {\r\n    // When switching levels, deselect zone and items\r\n    useViewer.getState().setSelection({ levelId })\r\n  }\r\n\r\n  const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {\r\n    switch (depth) {\r\n      case 'root':\r\n        useViewer.getState().resetSelection()\r\n        break\r\n      case 'building':\r\n        useViewer.getState().setSelection({ levelId: null })\r\n        break\r\n      case 'level':\r\n        useViewer.getState().setSelection({ zoneId: null })\r\n        break\r\n    }\r\n  }\r\n\r\n  return (\r\n    <>\r\n      {/* Unified top-left card */}\r\n      <div className=\"dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground\">\r\n        <div className=\"pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out\">\r\n          {/* Project info + back */}\r\n          <div className=\"flex items-center gap-3 px-3 py-2.5\">\r\n            {onBack ? (\r\n              <button\r\n                className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10\"\r\n                onClick={onBack}\r\n              >\r\n                <ArrowLeft className=\"h-4 w-4 text-muted-foreground\" />\r\n              </button>\r\n            ) : (\r\n              <Link\r\n                className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10\"\r\n                href=\"/\"\r\n              >\r\n                <ArrowLeft className=\"h-4 w-4 text-muted-foreground\" />\r\n              </Link>\r\n            )}\r\n            <div className=\"min-w-0\">\r\n              <div className=\"truncate font-medium text-foreground text-sm\">\r\n                {projectName || 'Untitled'}\r\n              </div>\r\n              {owner?.username && (\r\n                <Link\r\n                  className=\"text-muted-foreground text-xs transition-colors hover:text-foreground\"\r\n                  href={`/u/${owner.username}`}\r\n                >\r\n                  @{owner.username}\r\n                </Link>\r\n              )}\r\n            </div>\r\n          </div>\r\n\r\n          {/* Breadcrumb — only shown when navigated into a building */}\r\n          {building && (\r\n            <div className=\"border-border/40 border-t px-3 py-2\">\r\n              <div className=\"flex items-center gap-1.5 text-xs\">\r\n                <button\r\n                  className=\"text-muted-foreground transition-colors hover:text-foreground\"\r\n                  onClick={() => handleBreadcrumbClick('root')}\r\n                >\r\n                  Site\r\n                </button>\r\n\r\n                {building && (\r\n                  <>\r\n                    <ChevronRight className=\"h-3 w-3 text-muted-foreground/50\" />\r\n                    <button\r\n                      className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}\r\n                      onClick={() => handleBreadcrumbClick('building')}\r\n                    >\r\n                      {building.name || 'Building'}\r\n                    </button>\r\n                  </>\r\n                )}\r\n\r\n                {level && (\r\n                  <>\r\n                    <ChevronRight className=\"h-3 w-3 text-muted-foreground/50\" />\r\n                    <button\r\n                      className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}\r\n                      onClick={() => handleBreadcrumbClick('level')}\r\n                    >\r\n                      {level.name || `Level ${level.level}`}\r\n                    </button>\r\n                  </>\r\n                )}\r\n\r\n                {zone && (\r\n                  <>\r\n                    <ChevronRight className=\"h-3 w-3 text-muted-foreground/50\" />\r\n                    <span\r\n                      className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}\r\n                    >\r\n                      {zone.name}\r\n                    </span>\r\n                  </>\r\n                )}\r\n\r\n                {selectedNode && zone && (\r\n                  <>\r\n                    <ChevronRight className=\"h-3 w-3 text-muted-foreground/50\" />\r\n                    <span className=\"truncate font-medium text-foreground\">\r\n                      {getNodeName(selectedNode)}\r\n                    </span>\r\n                  </>\r\n                )}\r\n              </div>\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        {/* Level List (only when building is selected) */}\r\n        {building && levels.length > 0 && (\r\n          <div className=\"pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out\">\r\n            <span className=\"px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider\">\r\n              Levels\r\n            </span>\r\n            <div className=\"flex flex-col\">\r\n              {levels.map((lvl) => {\r\n                const isSelected = lvl.id === selection.levelId\r\n                return (\r\n                  <button\r\n                    className={cn(\r\n                      'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',\r\n                      isSelected\r\n                        ? 'border-r-3 border-r-white bg-accent/50 text-foreground'\r\n                        : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',\r\n                    )}\r\n                    key={lvl.id}\r\n                    onClick={() => handleLevelClick(lvl.id)}\r\n                  >\r\n                    <div className=\"flex min-w-0 flex-1 items-center gap-2\">\r\n                      <span\r\n                        className={cn(\r\n                          'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',\r\n                          !isSelected && 'opacity-60 grayscale',\r\n                        )}\r\n                      >\r\n                        <Layers className=\"h-3.5 w-3.5\" />\r\n                      </span>\r\n                      <div className=\"min-w-0 flex-1 truncate text-left\">\r\n                        {lvl.name || `Level ${lvl.level}`}\r\n                      </div>\r\n                    </div>\r\n                  </button>\r\n                )\r\n              })}\r\n            </div>\r\n          </div>\r\n        )}\r\n      </div>\r\n\r\n      {/* Controls Panel - Bottom Center */}\r\n      <div className=\"dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground\">\r\n        <TooltipProvider delayDuration={0}>\r\n          <div className=\"pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out\">\r\n            {/* Theme Toggle */}\r\n            <button\r\n              aria-label=\"Toggle theme\"\r\n              className=\"flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1\"\r\n              onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}\r\n              type=\"button\"\r\n            >\r\n              <div className=\"relative flex\">\r\n                {/* Sliding Background */}\r\n                <motion.div\r\n                  animate={{\r\n                    x: theme === 'light' ? '100%' : '0%',\r\n                  }}\r\n                  className=\"absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20\"\r\n                  initial={false}\r\n                  style={{ width: '50%' }}\r\n                  transition={{\r\n                    type: 'spring',\r\n                    stiffness: 500,\r\n                    damping: 35,\r\n                  }}\r\n                />\r\n\r\n                {/* Dark Mode Icon */}\r\n                <div\r\n                  className={cn(\r\n                    'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',\r\n                    theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',\r\n                  )}\r\n                >\r\n                  <Moon className=\"h-4 w-4\" />\r\n                </div>\r\n\r\n                {/* Light Mode Icon */}\r\n                <div\r\n                  className={cn(\r\n                    'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',\r\n                    theme === 'light' ? 'text-foreground' : 'text-muted-foreground',\r\n                  )}\r\n                >\r\n                  <Sun className=\"h-4 w-4\" />\r\n                </div>\r\n              </div>\r\n            </button>\r\n\r\n            <div className=\"mx-1 h-5 w-px bg-border/40\" />\r\n\r\n            {/* Scans and Guides Visibility */}\r\n            {canShowScans && (\r\n              <ActionButton\r\n                className={\r\n                  showScans\r\n                    ? 'bg-white/10'\r\n                    : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'\r\n                }\r\n                label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}\r\n                onClick={() => useViewer.getState().setShowScans(!showScans)}\r\n                size=\"icon\"\r\n                tooltipSide=\"top\"\r\n                variant=\"ghost\"\r\n              >\r\n                <img\r\n                  alt=\"Scans\"\r\n                  className=\"h-[28px] w-[28px] object-contain\"\r\n                  src=\"/icons/mesh.png\"\r\n                />\r\n              </ActionButton>\r\n            )}\r\n\r\n            {canShowGuides && (\r\n              <ActionButton\r\n                className={\r\n                  showGuides\r\n                    ? 'bg-white/10'\r\n                    : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'\r\n                }\r\n                label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}\r\n                onClick={() => useViewer.getState().setShowGuides(!showGuides)}\r\n                size=\"icon\"\r\n                tooltipSide=\"top\"\r\n                variant=\"ghost\"\r\n              >\r\n                <img\r\n                  alt=\"Guides\"\r\n                  className=\"h-[28px] w-[28px] object-contain\"\r\n                  src=\"/icons/floorplan.png\"\r\n                />\r\n              </ActionButton>\r\n            )}\r\n\r\n            {(canShowScans || canShowGuides) && <div className=\"mx-1 h-5 w-px bg-border/40\" />}\r\n\r\n            {/* Camera Mode */}\r\n            <ActionButton\r\n              className={\r\n                cameraMode === 'orthographic'\r\n                  ? 'bg-violet-500/20 text-violet-400'\r\n                  : 'hover:bg-white/5 hover:text-violet-400'\r\n              }\r\n              label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}\r\n              onClick={() =>\r\n                useViewer\r\n                  .getState()\r\n                  .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')\r\n              }\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <Camera className=\"h-6 w-6\" />\r\n            </ActionButton>\r\n\r\n            {/* Level Mode */}\r\n            <ActionButton\r\n              className={cn(\r\n                'p-0',\r\n                levelMode === 'stacked' || levelMode === 'manual'\r\n                  ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'\r\n                  : 'bg-white/10 text-foreground',\r\n              )}\r\n              label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}\r\n              onClick={() => {\r\n                if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')\r\n                const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']\r\n                const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length\r\n                useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')\r\n              }}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <span className=\"relative flex h-full w-full items-center justify-center pb-1\">\r\n                {levelMode === 'solo' && <Diamond className=\"h-6 w-6\" />}\r\n                {levelMode === 'exploded' && (\r\n                  <Icon color=\"currentColor\" height={24} icon=\"charm:stack-pop\" width={24} />\r\n                )}\r\n                {(levelMode === 'stacked' || levelMode === 'manual') && (\r\n                  <Icon color=\"currentColor\" height={24} icon=\"charm:stack-push\" width={24} />\r\n                )}\r\n                <span\r\n                  aria-hidden=\"true\"\r\n                  className=\"pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm\"\r\n                >\r\n                  {levelModeBadgeLabels[levelMode]}\r\n                </span>\r\n              </span>\r\n            </ActionButton>\r\n\r\n            {/* Wall Mode */}\r\n            <ActionButton\r\n              className={\r\n                wallMode !== 'cutaway'\r\n                  ? 'bg-white/10'\r\n                  : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'\r\n              }\r\n              label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}\r\n              onClick={() => {\r\n                const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']\r\n                const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length\r\n                useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')\r\n              }}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              {(() => {\r\n                const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon\r\n                return <Icon className=\"h-[28px] w-[28px]\" />\r\n              })()}\r\n            </ActionButton>\r\n\r\n            <div className=\"mx-1 h-5 w-px bg-border/40\" />\r\n\r\n            {/* Camera Actions */}\r\n            <ActionButton\r\n              className=\"group hidden hover:bg-white/5 sm:inline-flex\"\r\n              label=\"Orbit Left\"\r\n              onClick={() => emitter.emit('camera-controls:orbit-ccw')}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <img\r\n                alt=\"Orbit Left\"\r\n                className=\"h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n                src=\"/icons/rotate.png\"\r\n              />\r\n            </ActionButton>\r\n\r\n            <ActionButton\r\n              className=\"group hidden hover:bg-white/5 sm:inline-flex\"\r\n              label=\"Orbit Right\"\r\n              onClick={() => emitter.emit('camera-controls:orbit-cw')}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <img\r\n                alt=\"Orbit Right\"\r\n                className=\"h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n                src=\"/icons/rotate.png\"\r\n              />\r\n            </ActionButton>\r\n\r\n            <ActionButton\r\n              className=\"group hover:bg-white/5\"\r\n              label=\"Top View\"\r\n              onClick={() => emitter.emit('camera-controls:top-view')}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <img\r\n                alt=\"Top View\"\r\n                className=\"h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100\"\r\n                src=\"/icons/topview.png\"\r\n              />\r\n            </ActionButton>\r\n\r\n            <div className=\"mx-1 h-5 w-px bg-border/40\" />\r\n\r\n            {/* Street View */}\r\n            <ActionButton\r\n              className=\"group hover:bg-white/5\"\r\n              label=\"Street View\"\r\n              onClick={() => useEditor.getState().setFirstPersonMode(true)}\r\n              size=\"icon\"\r\n              tooltipSide=\"top\"\r\n              variant=\"ghost\"\r\n            >\r\n              <Footprints className=\"h-5 w-5 opacity-70 transition-opacity group-hover:opacity-100\" />\r\n            </ActionButton>\r\n          </div>\r\n        </TooltipProvider>\r\n      </div>\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "packages/editor/src/components/viewer-zone-system.tsx",
    "content": "'use client'\n\nimport { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useFrame } from '@react-three/fiber'\n\nexport const ViewerZoneSystem = () => {\n  useFrame(() => {\n    const { levelId, zoneId } = useViewer.getState().selection\n    const nodes = useScene.getState().nodes\n\n    sceneRegistry.byType.zone.forEach((id) => {\n      const obj = sceneRegistry.nodes.get(id)\n      if (!obj) return\n\n      const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined\n      if (!zone) return\n\n      // Hide zones if:\n      // 1. No level is selected\n      // 2. Zone is not on the selected level\n      // 3. A zone is already selected (hide all zones to show zone contents)\n      const isOnSelectedLevel = zone.parentId === levelId\n      const shouldShow = !!levelId && isOnSelectedLevel && !zoneId\n\n      obj.visible = shouldShow\n\n      const targetOpacity = shouldShow ? '1' : '0'\n      const labelEl = document.getElementById(`${id}-label`)\n      if (labelEl && labelEl.style.opacity !== targetOpacity) {\n        labelEl.style.opacity = targetOpacity\n      }\n    })\n  })\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/contexts/presets-context.tsx",
    "content": "'use client'\n\nimport { createContext, useContext } from 'react'\nimport type { PresetData, PresetType } from '../components/ui/panels/presets/presets-popover'\n\nexport type { PresetData, PresetType }\n\nexport type PresetsTab = 'community' | 'mine'\n\nexport interface PresetsAdapter {\n  /** Tabs to show. Default: both. Standalone passes ['mine']. */\n  tabs?: PresetsTab[]\n  isAuthenticated?: boolean\n  fetchPresets: (type: PresetType, tab: PresetsTab) => Promise<PresetData[]>\n  savePreset: (\n    type: PresetType,\n    name: string,\n    data: Record<string, unknown>,\n  ) => Promise<string | null>\n  overwritePreset: (type: PresetType, id: string, data: Record<string, unknown>) => Promise<void>\n  renamePreset: (id: string, name: string) => Promise<void>\n  deletePreset: (id: string) => Promise<void>\n  togglePresetCommunity?: (id: string, current: boolean) => Promise<void>\n  uploadPresetThumbnail?: (presetId: string, blob: Blob) => Promise<string | null>\n}\n\nconst PRESETS_KEY = (type: string) => `pascal-presets-${type}`\n\nexport const localStoragePresetsAdapter: PresetsAdapter = {\n  tabs: ['mine'],\n  isAuthenticated: true,\n\n  fetchPresets: async (type, tab) => {\n    if (tab === 'community') return []\n    try {\n      const raw = localStorage.getItem(PRESETS_KEY(type))\n      return raw ? (JSON.parse(raw) as PresetData[]) : []\n    } catch {\n      return []\n    }\n  },\n\n  savePreset: async (type, name, data) => {\n    try {\n      const id = Math.random().toString(36).slice(2, 10)\n      const raw = localStorage.getItem(PRESETS_KEY(type))\n      const presets: PresetData[] = raw ? JSON.parse(raw) : []\n      presets.push({\n        id,\n        type,\n        name,\n        data,\n        thumbnail_url: null,\n        user_id: null,\n        is_community: false,\n        created_at: new Date().toISOString(),\n      })\n      localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets))\n      return id\n    } catch {\n      return null\n    }\n  },\n\n  overwritePreset: async (type, id, data) => {\n    try {\n      const raw = localStorage.getItem(PRESETS_KEY(type))\n      if (!raw) return\n      const presets: PresetData[] = JSON.parse(raw)\n      localStorage.setItem(\n        PRESETS_KEY(type),\n        JSON.stringify(presets.map((p) => (p.id === id ? { ...p, data } : p))),\n      )\n    } catch {}\n  },\n\n  renamePreset: async (id, name) => {\n    for (const type of ['door', 'window']) {\n      try {\n        const raw = localStorage.getItem(PRESETS_KEY(type))\n        if (!raw) continue\n        const presets: PresetData[] = JSON.parse(raw)\n        localStorage.setItem(\n          PRESETS_KEY(type),\n          JSON.stringify(presets.map((p) => (p.id === id ? { ...p, name } : p))),\n        )\n      } catch {}\n    }\n  },\n\n  deletePreset: async (id) => {\n    for (const type of ['door', 'window']) {\n      try {\n        const raw = localStorage.getItem(PRESETS_KEY(type))\n        if (!raw) continue\n        const presets: PresetData[] = JSON.parse(raw)\n        localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.filter((p) => p.id !== id)))\n      } catch {}\n    }\n  },\n}\n\nconst PresetsContext = createContext<PresetsAdapter>(localStoragePresetsAdapter)\n\nexport function PresetsProvider({\n  adapter,\n  children,\n}: {\n  adapter?: PresetsAdapter\n  children: React.ReactNode\n}) {\n  return (\n    <PresetsContext.Provider value={adapter ?? localStoragePresetsAdapter}>\n      {children}\n    </PresetsContext.Provider>\n  )\n}\n\nexport function usePresetsAdapter(): PresetsAdapter {\n  return useContext(PresetsContext)\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-auto-save.ts",
    "content": "'use client'\n\nimport { useScene } from '@pascal-app/core'\nimport { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'\nimport { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'\n\nconst AUTOSAVE_DEBOUNCE_MS = 1000\n\nexport type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'\n\ninterface UseAutoSaveOptions {\n  onSave?: (scene: SceneGraph) => Promise<void>\n  onDirty?: () => void\n  onSaveStatusChange?: (status: SaveStatus) => void\n  isVersionPreviewMode?: boolean\n}\n\n/**\n * Generic autosave hook. Subscribes to the scene store and debounces saves.\n * Falls back to localStorage when no `onSave` is provided.\n *\n * ⚠️  Mount in exactly ONE component (the Editor).\n */\nexport function useAutoSave({\n  onSave,\n  onDirty,\n  onSaveStatusChange,\n  isVersionPreviewMode = false,\n}: UseAutoSaveOptions): { saveStatus: SaveStatus; isLoadingSceneRef: MutableRefObject<boolean> } {\n  const [saveStatus, _setSaveStatus] = useState<SaveStatus>('idle')\n\n  const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)\n  const isSavingRef = useRef(false)\n  const isLoadingSceneRef = useRef(false)\n  const pendingSaveRef = useRef(false)\n  const executeSaveRef = useRef<(() => Promise<void>) | null>(null)\n  const hasDirtyChangesRef = useRef(false)\n\n  // Keep latest callback/value refs so the stable subscription always uses current values\n  const onSaveRef = useRef(onSave)\n  const onDirtyRef = useRef(onDirty)\n  const onSaveStatusChangeRef = useRef(onSaveStatusChange)\n  const isVersionPreviewModeRef = useRef(isVersionPreviewMode)\n\n  useEffect(() => {\n    onSaveRef.current = onSave\n  }, [onSave])\n  useEffect(() => {\n    onDirtyRef.current = onDirty\n  }, [onDirty])\n  useEffect(() => {\n    onSaveStatusChangeRef.current = onSaveStatusChange\n  }, [onSaveStatusChange])\n  useEffect(() => {\n    isVersionPreviewModeRef.current = isVersionPreviewMode\n  }, [isVersionPreviewMode])\n\n  const setSaveStatus = useCallback((status: SaveStatus) => {\n    _setSaveStatus(status)\n    onSaveStatusChangeRef.current?.(status)\n  }, [])\n\n  // Stable subscription to scene changes\n  useEffect(() => {\n    let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)\n\n    async function executeSave() {\n      if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {\n        pendingSaveRef.current = true\n        setSaveStatus('paused')\n        return\n      }\n\n      const { nodes, rootNodeIds } = useScene.getState()\n      const sceneGraph = { nodes, rootNodeIds } as SceneGraph\n\n      isSavingRef.current = true\n      pendingSaveRef.current = false\n      setSaveStatus('saving')\n\n      try {\n        if (onSaveRef.current) {\n          await onSaveRef.current(sceneGraph)\n        } else {\n          saveSceneToLocalStorage(sceneGraph)\n        }\n        hasDirtyChangesRef.current = false\n        setSaveStatus('saved')\n      } catch {\n        setSaveStatus('error')\n      } finally {\n        isSavingRef.current = false\n\n        if (pendingSaveRef.current) {\n          pendingSaveRef.current = false\n          setSaveStatus('pending')\n          saveTimeoutRef.current = setTimeout(() => {\n            saveTimeoutRef.current = undefined\n            executeSave()\n          }, AUTOSAVE_DEBOUNCE_MS)\n        }\n      }\n    }\n\n    executeSaveRef.current = executeSave\n\n    const unsubscribe = useScene.subscribe((state) => {\n      if (isLoadingSceneRef.current) {\n        lastNodesSnapshot = JSON.stringify(state.nodes)\n        return\n      }\n\n      if (isVersionPreviewModeRef.current) {\n        setSaveStatus('paused')\n        lastNodesSnapshot = JSON.stringify(state.nodes)\n        return\n      }\n\n      const currentNodesSnapshot = JSON.stringify(state.nodes)\n      if (currentNodesSnapshot === lastNodesSnapshot) return\n\n      lastNodesSnapshot = currentNodesSnapshot\n      hasDirtyChangesRef.current = true\n      onDirtyRef.current?.()\n      setSaveStatus('pending')\n\n      if (isSavingRef.current) {\n        pendingSaveRef.current = true\n        return\n      }\n\n      if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)\n\n      saveTimeoutRef.current = setTimeout(() => {\n        saveTimeoutRef.current = undefined\n        executeSave()\n      }, AUTOSAVE_DEBOUNCE_MS)\n    })\n\n    function flushOnExit() {\n      if (!hasDirtyChangesRef.current) return\n      const { nodes, rootNodeIds } = useScene.getState()\n      const sceneGraph = { nodes, rootNodeIds } as SceneGraph\n      if (onSaveRef.current) {\n        onSaveRef.current(sceneGraph).catch(() => {})\n      } else {\n        saveSceneToLocalStorage(sceneGraph)\n      }\n      hasDirtyChangesRef.current = false\n    }\n\n    window.addEventListener('beforeunload', flushOnExit)\n\n    return () => {\n      executeSaveRef.current = null\n      window.removeEventListener('beforeunload', flushOnExit)\n      if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)\n      flushOnExit()\n      unsubscribe()\n    }\n  }, [setSaveStatus])\n\n  // Handle version preview mode transitions\n  useEffect(() => {\n    if (isVersionPreviewMode) {\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current)\n        saveTimeoutRef.current = undefined\n      }\n      if (hasDirtyChangesRef.current) {\n        pendingSaveRef.current = true\n      }\n      setSaveStatus('paused')\n      return\n    }\n\n    if (isSavingRef.current) return\n\n    if (hasDirtyChangesRef.current) {\n      setSaveStatus('pending')\n      if (!saveTimeoutRef.current) {\n        saveTimeoutRef.current = setTimeout(() => {\n          saveTimeoutRef.current = undefined\n          executeSaveRef.current?.()\n        }, AUTOSAVE_DEBOUNCE_MS)\n      }\n      return\n    }\n\n    setSaveStatus('saved')\n  }, [isVersionPreviewMode, setSaveStatus])\n\n  return { saveStatus, isLoadingSceneRef }\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-contextual-tools.ts",
    "content": "import { type AnyNodeId, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useMemo } from 'react'\nimport useEditor, { type StructureTool } from '../store/use-editor'\n\nexport function useContextualTools() {\n  const selection = useViewer((s) => s.selection)\n  const nodes = useScene((s) => s.nodes)\n  const phase = useEditor((s) => s.phase)\n  const structureLayer = useEditor((s) => s.structureLayer)\n\n  return useMemo(() => {\n    // If we are in the zones layer, only zone tool is relevant\n    if (structureLayer === 'zones') {\n      return ['zone'] as StructureTool[]\n    }\n\n    // Default tools when nothing is selected\n    const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window']\n\n    if (selection.selectedIds.length === 0) {\n      return defaultTools\n    }\n\n    // Get types of selected nodes\n    const selectedTypes = new Set(\n      selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),\n    )\n\n    // If a wall is selected, prioritize wall-hosted elements\n    if (selectedTypes.has('wall')) {\n      return ['window', 'door', 'wall'] as StructureTool[]\n    }\n\n    // If a slab is selected, prioritize slab editing\n    if (selectedTypes.has('slab')) {\n      return ['slab', 'wall'] as StructureTool[]\n    }\n\n    // If a ceiling is selected, prioritize ceiling editing\n    if (selectedTypes.has('ceiling')) {\n      return ['ceiling'] as StructureTool[]\n    }\n\n    // If a roof is selected, prioritize roof editing\n    if (selectedTypes.has('roof')) {\n      return ['roof'] as StructureTool[]\n    }\n\n    return defaultTools\n  }, [selection.selectedIds, nodes, structureLayer])\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-grid-events.ts",
    "content": "import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useThree } from '@react-three/fiber'\nimport { useEffect, useRef } from 'react'\nimport { Plane, Raycaster, Vector2, Vector3 } from 'three'\n\n/**\n * Custom grid events hook that uses manual raycasting instead of mesh events.\n * This ensures grid events work even when other meshes block pointer events with stopPropagation.\n */\nexport function useGridEvents(gridY: number) {\n  const { camera, gl } = useThree()\n  const raycaster = useRef(new Raycaster())\n  const pointer = useRef(new Vector2())\n  const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))\n  const intersectionPoint = useRef(new Vector3())\n\n  // Update ground plane when grid Y changes\n  useEffect(() => {\n    groundPlane.current.constant = -gridY\n  }, [gridY])\n\n  useEffect(() => {\n    const canvas = gl.domElement\n\n    const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => {\n      // Convert mouse position to normalized device coordinates (-1 to +1)\n      const rect = canvas.getBoundingClientRect()\n      pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1\n      pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1\n\n      // Update raycaster\n      raycaster.current.setFromCamera(pointer.current, camera)\n\n      // Intersect with ground plane\n      if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) {\n        return intersectionPoint.current.clone()\n      }\n\n      return null\n    }\n\n    const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => {\n      const point = getIntersection(nativeEvent)\n      if (!point) return\n\n      const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`\n      const payload: GridEvent = {\n        position: [point.x, point.y, point.z],\n        nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent\n      }\n\n      emitter.emit(eventKey, payload)\n    }\n\n    const handlePointerDown = (e: PointerEvent) => {\n      if (useViewer.getState().cameraDragging) return\n      if (e.button !== 0) return\n      emit('pointerdown', e)\n    }\n\n    const handlePointerUp = (e: PointerEvent) => {\n      if (useViewer.getState().cameraDragging) return\n      if (e.button !== 0) return\n      emit('pointerup', e)\n    }\n\n    const handleClick = (e: PointerEvent) => {\n      if (useViewer.getState().cameraDragging) return\n      if (e.button !== 0) return\n      emit('click', e)\n    }\n\n    const handlePointerMove = (e: PointerEvent) => {\n      // Emit move even if camera is dragging, so tools like PolygonEditor still work\n      emit('move', e)\n    }\n\n    const handleDoubleClick = (e: MouseEvent) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('double-click', e)\n    }\n\n    const handleContextMenu = (e: MouseEvent) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('context-menu', e)\n    }\n\n    // Attach listeners to canvas\n    canvas.addEventListener('pointerdown', handlePointerDown)\n    canvas.addEventListener('pointerup', handlePointerUp)\n    canvas.addEventListener('click', handleClick)\n    canvas.addEventListener('pointermove', handlePointerMove)\n    canvas.addEventListener('dblclick', handleDoubleClick)\n    canvas.addEventListener('contextmenu', handleContextMenu)\n\n    return () => {\n      canvas.removeEventListener('pointerdown', handlePointerDown)\n      canvas.removeEventListener('pointerup', handlePointerUp)\n      canvas.removeEventListener('click', handleClick)\n      canvas.removeEventListener('pointermove', handlePointerMove)\n      canvas.removeEventListener('dblclick', handleDoubleClick)\n      canvas.removeEventListener('contextmenu', handleContextMenu)\n    }\n  }, [camera, gl])\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-keyboard.ts",
    "content": "import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { useEffect } from 'react'\nimport { sfxEmitter } from '../lib/sfx-bus'\nimport useEditor from '../store/use-editor'\n\n// Tools call this in their onCancel handler when they have an active mid-action to cancel,\n// so that the global Escape handler knows not to also switch to select mode.\nlet _toolCancelConsumed = false\nexport const markToolCancelConsumed = () => {\n  _toolCancelConsumed = true\n}\n\nexport const useKeyboard = () => {\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Don't handle shortcuts if user is typing in an input\n      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {\n        return\n      }\n\n      if (e.key === 'Escape') {\n        // If in walkthrough mode, let WalkthroughControls handle ESC\n        if (useViewer.getState().walkthroughMode) return\n\n        e.preventDefault()\n        _toolCancelConsumed = false\n        emitter.emit('tool:cancel')\n\n        // Only switch to select mode if no tool had an active mid-action to cancel.\n        // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)\n        if (!_toolCancelConsumed) {\n          // Return to the default select tool while keeping the active building/level context.\n          useEditor.getState().setEditingHole(null)\n          useEditor.getState().setMode('select')\n          useEditor.getState().setFloorplanSelectionTool('click')\n\n          // Clear selections to close UI panels, but KEEP the active building and level context.\n          useViewer.getState().setSelection({ selectedIds: [], zoneId: null })\n          useEditor.getState().setSelectedReferenceId(null)\n        }\n      } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('site')\n        useEditor.getState().setMode('select')\n      } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('structure')\n        useEditor.getState().setMode('select')\n      } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('furnish')\n        useEditor.getState().setMode('select')\n      } else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('structure')\n        useEditor.getState().setStructureLayer('elements')\n      } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('furnish')\n      } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setPhase('structure')\n        useEditor.getState().setStructureLayer('zones')\n      }\n      if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setMode('select')\n        useEditor.getState().setFloorplanSelectionTool('click')\n      } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {\n        e.preventDefault()\n        useEditor.getState().setMode('build')\n      } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        useScene.temporal.getState().undo()\n      } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        useScene.temporal.getState().redo()\n      } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        const { buildingId, levelId } = useViewer.getState().selection\n        if (buildingId) {\n          const building = useScene.getState().nodes[buildingId]\n          if (building && building.type === 'building' && building.children.length > 0) {\n            const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1\n            const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx\n            if (nextIdx !== -1 && nextIdx !== currentIdx) {\n              useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })\n            } else if (currentIdx === -1) {\n              useViewer.getState().setSelection({ levelId: building.children[0] as any })\n            }\n          }\n        }\n      } else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        const { buildingId, levelId } = useViewer.getState().selection\n        if (buildingId) {\n          const building = useScene.getState().nodes[buildingId]\n          if (building && building.type === 'building' && building.children.length > 0) {\n            const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1\n            const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx\n            if (prevIdx !== -1 && prevIdx !== currentIdx) {\n              useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })\n            } else if (currentIdx === -1) {\n              useViewer\n                .getState()\n                .setSelection({ levelId: building.children[building.children.length - 1] as any })\n            }\n          }\n        }\n      } else if (e.key === 'r' || e.key === 'R') {\n        // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)\n        const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]\n        if (selectedNodeIds.length === 1) {\n          const node = useScene.getState().nodes[selectedNodeIds[0]!]\n          if (node && 'rotation' in node) {\n            e.preventDefault()\n            const ROTATION_STEP = Math.PI / 4\n\n            // Handle different rotation types (number for roof, array for items/windows/doors)\n            if (typeof node.rotation === 'number') {\n              useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })\n            } else if (Array.isArray(node.rotation)) {\n              useScene.getState().updateNode(node.id, {\n                rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]],\n              })\n            }\n            sfxEmitter.emit('sfx:item-rotate')\n          }\n        }\n      } else if (e.key === 't' || e.key === 'T') {\n        // Rotate selected node counter-clockwise\n        const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]\n        if (selectedNodeIds.length === 1) {\n          const node = useScene.getState().nodes[selectedNodeIds[0]!]\n          if (node && 'rotation' in node) {\n            e.preventDefault()\n            const ROTATION_STEP = Math.PI / 4\n\n            if (typeof node.rotation === 'number') {\n              useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })\n            } else if (Array.isArray(node.rotation)) {\n              useScene.getState().updateNode(node.id, {\n                rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],\n              })\n            }\n            sfxEmitter.emit('sfx:item-rotate')\n          }\n        }\n      } else if (e.key === 'Delete' || e.key === 'Backspace') {\n        e.preventDefault()\n\n        const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]\n\n        if (selectedNodeIds.length > 0) {\n          // Play appropriate SFX based on what's being deleted\n          if (selectedNodeIds.length === 1) {\n            const node = useScene.getState().nodes[selectedNodeIds[0]!]\n            if (node?.type === 'item') {\n              sfxEmitter.emit('sfx:item-delete')\n            } else {\n              sfxEmitter.emit('sfx:structure-delete')\n            }\n          } else {\n            sfxEmitter.emit('sfx:structure-delete')\n          }\n\n          useScene.getState().deleteNodes(selectedNodeIds)\n        }\n      }\n    }\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [])\n\n  return null\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-mobile.ts",
    "content": "import * as React from 'react'\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener('change', onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener('change', onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "packages/editor/src/hooks/use-reduced-motion.ts",
    "content": "import { useEffect, useState } from 'react'\n\n/**\n * Returns true when the user has requested reduced motion via OS settings.\n * Useful for disabling animations (WCAG 2.3.3).\n */\nexport function useReducedMotion(): boolean {\n  const [reducedMotion, setReducedMotion] = useState(false)\n\n  useEffect(() => {\n    const mql = window.matchMedia('(prefers-reduced-motion: reduce)')\n    setReducedMotion(mql.matches)\n\n    const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)\n    mql.addEventListener('change', handler)\n    return () => mql.removeEventListener('change', handler)\n  }, [])\n\n  return reducedMotion\n}\n"
  },
  {
    "path": "packages/editor/src/index.tsx",
    "content": "export type { EditorProps } from './components/editor'\nexport { default as Editor } from './components/editor'\nexport { useCommandPalette } from './components/ui/command-palette'\nexport { SliderControl } from './components/ui/controls/slider-control'\nexport { FloatingLevelSelector } from './components/ui/floating-level-selector'\nexport { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'\nexport { useSidebarStore } from './components/ui/primitives/sidebar'\nexport { Slider } from './components/ui/primitives/slider'\nexport { SceneLoader } from './components/ui/scene-loader'\nexport type { ExtraPanel } from './components/ui/sidebar/icon-rail'\nexport {\n  type ProjectVisibility,\n  SettingsPanel,\n  type SettingsPanelProps,\n} from './components/ui/sidebar/panels/settings-panel'\nexport type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'\nexport type { SidebarTab } from './components/ui/sidebar/tab-bar'\nexport { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'\nexport type { PresetsAdapter, PresetsTab } from './contexts/presets-context'\nexport { PresetsProvider } from './contexts/presets-context'\nexport type { SaveStatus } from './hooks/use-auto-save'\nexport type { SceneGraph } from './lib/scene'\nexport { applySceneGraphToEditor } from './lib/scene'\nexport { default as useAudio } from './store/use-audio'\nexport { type CommandAction, useCommandRegistry } from './store/use-command-registry'\nexport type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'\nexport { default as useEditor } from './store/use-editor'\nexport {\n  type PaletteView,\n  type PaletteViewProps,\n  usePaletteViewRegistry,\n} from './store/use-palette-view-registry'\nexport { useUploadStore } from './store/use-upload'\n"
  },
  {
    "path": "packages/editor/src/lib/constants.ts",
    "content": "/** Three.js layer used for editor-only objects (helpers, grid, polygon editors).\n *  The thumbnail camera renders only layer 0, so these are excluded from thumbnails. */\nexport const EDITOR_LAYER = 1\n"
  },
  {
    "path": "packages/editor/src/lib/level-selection.ts",
    "content": "import type { AnyNodeId, BuildingNode, LevelNode } from '@pascal-app/core'\nimport { useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\n\nfunction getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {\n  const { nodes } = useScene.getState()\n  const level = nodes[levelId]\n  if (!level || level.type !== 'level' || !level.parentId) return null\n\n  const building = nodes[level.parentId as AnyNodeId]\n  if (!building || building.type !== 'building') return null\n\n  const siblingLevelIds = (building as BuildingNode).children.filter(\n    (childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',\n  )\n  const currentIndex = siblingLevelIds.indexOf(level.id)\n  if (currentIndex === -1) return null\n\n  return siblingLevelIds[currentIndex - 1] ?? siblingLevelIds[currentIndex + 1] ?? null\n}\n\nexport function deleteLevelWithFallbackSelection(levelId: AnyNodeId) {\n  const isSelectedLevel = useViewer.getState().selection.levelId === levelId\n  const nextLevelId = getAdjacentLevelIdForDeletion(levelId)\n\n  useScene.getState().deleteNode(levelId)\n\n  if (isSelectedLevel) {\n    useViewer.getState().setSelection({ levelId: nextLevelId })\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/lib/scene.ts",
    "content": "'use client'\n\nimport { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport useEditor, {\n  hasCustomPersistedEditorUiState,\n  normalizePersistedEditorUiState,\n  type PersistedEditorUiState,\n} from '../store/use-editor'\n\nexport type SceneGraph = {\n  nodes: Record<string, unknown>\n  rootNodeIds: string[]\n}\n\ntype PersistedSelectionPath = {\n  buildingId: string | null\n  levelId: string | null\n  zoneId: string | null\n  selectedIds: string[]\n}\n\n/**\n * IDs are stored as plain strings in localStorage. Cast them back to their\n * branded template-literal types before passing to the viewer store.\n */\nfunction toViewerSelection(s: PersistedSelectionPath) {\n  return s as unknown as Parameters<ReturnType<typeof useViewer.getState>['setSelection']>[0]\n}\n\nconst EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {\n  buildingId: null,\n  levelId: null,\n  zoneId: null,\n  selectedIds: [],\n}\n\nconst SELECTION_STORAGE_KEY = 'pascal-editor-selection'\n\nfunction getSelectionStorageKey(): string {\n  const projectId = useViewer.getState().projectId\n  return projectId ? `${SELECTION_STORAGE_KEY}:${projectId}` : SELECTION_STORAGE_KEY\n}\n\nfunction getSelectionStorageReadKeys(): string[] {\n  const scopedKey = getSelectionStorageKey()\n  return scopedKey === SELECTION_STORAGE_KEY ? [scopedKey] : [scopedKey, SELECTION_STORAGE_KEY]\n}\n\nfunction getDefaultLevelIdForBuilding(\n  sceneNodes: Record<string, any>,\n  buildingId: string | null,\n): string | null {\n  if (!buildingId) {\n    return null\n  }\n\n  const buildingNode = sceneNodes[buildingId]\n  if (buildingNode?.type !== 'building' || !Array.isArray(buildingNode.children)) {\n    return null\n  }\n\n  let firstLevelId: string | null = null\n\n  for (const childId of buildingNode.children) {\n    const levelNode = sceneNodes[childId]\n    if (levelNode?.type !== 'level') {\n      continue\n    }\n\n    firstLevelId ??= levelNode.id\n\n    if (levelNode.level === 0) {\n      return levelNode.id\n    }\n  }\n\n  return firstLevelId\n}\n\nfunction normalizePersistedSelectionPath(\n  selection: Partial<PersistedSelectionPath> | null | undefined,\n): PersistedSelectionPath {\n  return {\n    buildingId: typeof selection?.buildingId === 'string' ? selection.buildingId : null,\n    levelId: typeof selection?.levelId === 'string' ? selection.levelId : null,\n    zoneId: typeof selection?.zoneId === 'string' ? selection.zoneId : null,\n    selectedIds: Array.isArray(selection?.selectedIds)\n      ? selection.selectedIds.filter((id): id is string => typeof id === 'string')\n      : [],\n  }\n}\n\nfunction hasPersistedSelectionValue(selection: PersistedSelectionPath): boolean {\n  return Boolean(\n    selection.buildingId ||\n      selection.levelId ||\n      selection.zoneId ||\n      selection.selectedIds.length > 0,\n  )\n}\n\nfunction readPersistedSelection(): PersistedSelectionPath | null {\n  if (typeof window === 'undefined') {\n    return null\n  }\n\n  try {\n    for (const key of getSelectionStorageReadKeys()) {\n      const rawSelection = window.localStorage.getItem(key)\n      if (!rawSelection) {\n        continue\n      }\n\n      return normalizePersistedSelectionPath(\n        JSON.parse(rawSelection) as Partial<PersistedSelectionPath>,\n      )\n    }\n  } catch {\n    return null\n  }\n\n  return null\n}\n\nexport function writePersistedSelection(selection: {\n  buildingId: string | null\n  levelId: string | null\n  zoneId: string | null\n  selectedIds: string[]\n}) {\n  if (typeof window === 'undefined') {\n    return\n  }\n\n  try {\n    const sceneNodes = useScene.getState().nodes as Record<string, any>\n    const normalizedSelection = normalizePersistedSelectionPath(selection)\n    const validatedSelection =\n      getValidatedSelectionForScene(sceneNodes, normalizedSelection) ?? normalizedSelection\n\n    window.localStorage.setItem(getSelectionStorageKey(), JSON.stringify(validatedSelection))\n  } catch {\n    // Swallow storage quota errors\n  }\n}\n\nfunction getEditorUiStateForRestoredSelection(\n  sceneNodes: Record<string, any>,\n  selection: PersistedSelectionPath,\n  fallbackUiState: PersistedEditorUiState,\n): PersistedEditorUiState {\n  if (!selection.levelId) {\n    return {\n      ...fallbackUiState,\n      phase: 'site',\n      mode: fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select',\n      tool: null,\n      structureLayer: 'elements',\n      catalogCategory: null,\n    }\n  }\n\n  if (selection.zoneId) {\n    return {\n      ...fallbackUiState,\n      phase: 'structure',\n      mode: 'select',\n      tool: null,\n      structureLayer: 'zones',\n      catalogCategory: null,\n    }\n  }\n\n  const selectedNodes = selection.selectedIds\n    .map((id) => sceneNodes[id])\n    .filter((node): node is Record<string, any> => Boolean(node))\n\n  const shouldRestoreFurnishPhase =\n    selectedNodes.length > 0 &&\n    selectedNodes.every(\n      (node) =>\n        node.type === 'item' &&\n        node.asset?.category !== 'door' &&\n        node.asset?.category !== 'window',\n    )\n\n  return {\n    ...fallbackUiState,\n    phase: shouldRestoreFurnishPhase ? 'furnish' : 'structure',\n    mode: 'select',\n    tool: null,\n    structureLayer: 'elements',\n    catalogCategory: null,\n  }\n}\n\nfunction getValidatedSelectionForScene(\n  sceneNodes: Record<string, any>,\n  selection: PersistedSelectionPath,\n): PersistedSelectionPath | null {\n  const levelNode = selection.levelId ? sceneNodes[selection.levelId] : null\n  const hasValidLevel = levelNode?.type === 'level'\n  const buildingNodeFromLevel =\n    hasValidLevel && levelNode.parentId ? sceneNodes[levelNode.parentId] : null\n  const explicitBuildingNode = selection.buildingId ? sceneNodes[selection.buildingId] : null\n  const buildingId =\n    buildingNodeFromLevel?.type === 'building'\n      ? buildingNodeFromLevel.id\n      : explicitBuildingNode?.type === 'building'\n        ? explicitBuildingNode.id\n        : null\n\n  if (!buildingId) {\n    return null\n  }\n\n  const levelId = hasValidLevel\n    ? levelNode.id\n    : getDefaultLevelIdForBuilding(sceneNodes, buildingId)\n\n  if (levelId) {\n    const zoneNode = selection.zoneId ? sceneNodes[selection.zoneId] : null\n    const zoneId =\n      zoneNode?.type === 'zone' && resolveLevelId(zoneNode, sceneNodes) === levelId\n        ? zoneNode.id\n        : null\n\n    const selectedIds = selection.selectedIds.filter((id) => {\n      const node = sceneNodes[id]\n      return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId\n    })\n\n    return {\n      buildingId,\n      levelId,\n      zoneId,\n      selectedIds,\n    }\n  }\n\n  return {\n    ...EMPTY_PERSISTED_SELECTION,\n    buildingId,\n  }\n}\n\nfunction getRestoredSelectionForScene(\n  sceneNodes: Record<string, any>,\n): PersistedSelectionPath | null {\n  const persistedSelection = readPersistedSelection()\n  if (!(persistedSelection && hasPersistedSelectionValue(persistedSelection))) {\n    return null\n  }\n\n  return getValidatedSelectionForScene(sceneNodes, persistedSelection)\n}\n\nexport function syncEditorSelectionFromCurrentScene() {\n  const sceneNodes = useScene.getState().nodes as Record<string, any>\n  const sceneRootIds = useScene.getState().rootNodeIds\n  const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null\n  const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)\n  const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')\n  const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')\n  const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState())\n  const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState)\n  const restoredSelection = getRestoredSelectionForScene(sceneNodes)\n  const selectionDrivenEditorUiState = restoredSelection\n    ? getEditorUiStateForRestoredSelection(sceneNodes, restoredSelection, restoredEditorUiState)\n    : null\n\n  if (firstBuilding && firstLevel) {\n    if (shouldRestoreEditorUiState) {\n      if (restoredSelection) {\n        useViewer.getState().setSelection(toViewerSelection(restoredSelection))\n        useEditor.setState(\n          restoredEditorUiState.phase === 'site'\n            ? (selectionDrivenEditorUiState ?? restoredEditorUiState)\n            : restoredEditorUiState,\n        )\n      } else if (restoredEditorUiState.phase === 'site') {\n        useViewer.getState().resetSelection()\n        useEditor.setState(restoredEditorUiState)\n      } else {\n        useViewer.getState().setSelection({\n          buildingId: firstBuilding.id,\n          levelId: firstLevel.id,\n          selectedIds: [],\n          zoneId: null,\n        })\n        useEditor.setState(restoredEditorUiState)\n      }\n      return\n    }\n\n    if (restoredSelection) {\n      useViewer.getState().setSelection(toViewerSelection(restoredSelection))\n      if (selectionDrivenEditorUiState) {\n        useEditor.setState(selectionDrivenEditorUiState)\n      }\n      return\n    }\n\n    useViewer.getState().setSelection({\n      buildingId: firstBuilding.id,\n      levelId: firstLevel.id,\n      selectedIds: [],\n      zoneId: null,\n    })\n    useEditor.getState().setPhase('structure')\n    useEditor.getState().setStructureLayer('elements')\n\n    if (!firstLevel.children || firstLevel.children.length === 0) {\n      useEditor.getState().setMode('build')\n      useEditor.getState().setTool('wall')\n    }\n  } else {\n    useEditor.getState().setPhase('site')\n    useViewer.getState().setSelection({\n      buildingId: null,\n      levelId: null,\n      selectedIds: [],\n      zoneId: null,\n    })\n  }\n}\n\nfunction resetEditorInteractionState() {\n  useViewer.getState().setHoveredId(null)\n  useViewer.getState().resetSelection()\n  // Clear outliner arrays synchronously so stale Object3D refs from the old\n  // scene don't leak into the post-processing pipeline's outline passes.\n  const outliner = useViewer.getState().outliner\n  outliner.selectedObjects.length = 0\n  outliner.hoveredObjects.length = 0\n  sceneRegistry.clear()\n  useEditor.setState({\n    phase: 'site',\n    mode: 'select',\n    tool: null,\n    structureLayer: 'elements',\n    catalogCategory: null,\n    selectedItem: null,\n    movingNode: null,\n    selectedReferenceId: null,\n    spaces: {},\n    editingHole: null,\n    isPreviewMode: false,\n  })\n}\n\nfunction hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph {\n  return (\n    !!sceneGraph &&\n    Object.keys(sceneGraph.nodes ?? {}).length > 0 &&\n    (sceneGraph.rootNodeIds?.length ?? 0) > 0\n  )\n}\n\nexport function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {\n  if (hasUsableSceneGraph(sceneGraph)) {\n    const { nodes, rootNodeIds } = sceneGraph\n    useScene.getState().setScene(nodes as any, rootNodeIds as any)\n  } else {\n    useScene.getState().clearScene()\n  }\n\n  syncEditorSelectionFromCurrentScene()\n}\n\nconst LOCAL_STORAGE_KEY = 'pascal-editor-scene'\n\nexport function saveSceneToLocalStorage(scene: SceneGraph): void {\n  try {\n    localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(scene))\n  } catch {\n    // Swallow storage quota errors\n  }\n}\n\nexport function loadSceneFromLocalStorage(): SceneGraph | null {\n  try {\n    const raw = localStorage.getItem(LOCAL_STORAGE_KEY)\n    return raw ? (JSON.parse(raw) as SceneGraph) : null\n  } catch {\n    return null\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/lib/sfx/index.ts",
    "content": "export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'\nexport { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player'\n"
  },
  {
    "path": "packages/editor/src/lib/sfx-bus.ts",
    "content": "import mitt from 'mitt'\nimport { playSFX } from './sfx-player'\n\n/**\n * SFX-specific events that tools can trigger\n */\ntype SFXEvents = {\n  'sfx:grid-snap': undefined\n  'sfx:item-delete': undefined\n  'sfx:item-pick': undefined\n  'sfx:item-place': undefined\n  'sfx:item-rotate': undefined\n  'sfx:structure-build': undefined\n  'sfx:structure-delete': undefined\n}\n\n/**\n * Dedicated event emitter for SFX\n * Tools should use this to trigger sound effects\n */\nexport const sfxEmitter = mitt<SFXEvents>()\n\n/**\n * Initialize SFX Bus - connects SFX events to actual sound playback\n * Call once in your app initialization\n */\nexport function initSFXBus() {\n  // Map SFX events to sound playback\n  sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))\n  sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))\n  sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))\n  sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))\n  sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))\n  sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))\n  sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))\n}\n\n/**\n * Helper function to trigger SFX events from tools\n * @example\n * triggerSFX('sfx:item-place')\n */\nexport function triggerSFX(event: keyof SFXEvents) {\n  sfxEmitter.emit(event)\n}\n"
  },
  {
    "path": "packages/editor/src/lib/sfx-player.ts",
    "content": "import { Howl } from 'howler'\nimport useAudio from '../store/use-audio'\n\n// SFX sound definitions\nexport const SFX = {\n  gridSnap: '/audios/sfx/grid_snap.mp3',\n  itemDelete: '/audios/sfx/item_delete.mp3',\n  itemPick: '/audios/sfx/item_pick.mp3',\n  itemPlace: '/audios/sfx/item_place.mp3',\n  itemRotate: '/audios/sfx/item_rotate.mp3',\n  structureBuild: '/audios/sfx/structure_build.mp3',\n  structureDelete: '/audios/sfx/structure_delete.mp3',\n} as const\n\nexport type SFXName = keyof typeof SFX\n\n// Preload all SFX sounds\nconst sfxCache = new Map<SFXName, Howl>()\n\n// Initialize all sounds\nObject.entries(SFX).forEach(([name, path]) => {\n  const sound = new Howl({\n    src: [path],\n    preload: true,\n    volume: 0.5, // Will be adjusted by the bus\n  })\n  sfxCache.set(name as SFXName, sound)\n})\n\n/**\n * Play a sound effect with volume based on audio settings\n */\nexport function playSFX(name: SFXName) {\n  const sound = sfxCache.get(name)\n  if (!sound) {\n    console.warn(`SFX not found: ${name}`)\n    return\n  }\n\n  const { masterVolume, sfxVolume, muted } = useAudio.getState()\n\n  if (muted) return\n\n  // Calculate final volume (masterVolume and sfxVolume are 0-100)\n  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)\n  sound.volume(finalVolume)\n  sound.play()\n}\n\n/**\n * Update all cached SFX volumes (useful when settings change)\n */\nexport function updateSFXVolumes() {\n  const { masterVolume, sfxVolume } = useAudio.getState()\n  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)\n\n  sfxCache.forEach((sound) => {\n    sound.volume(finalVolume)\n  })\n}\n"
  },
  {
    "path": "packages/editor/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\nexport const isDevelopment =\n  process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'\n\nexport const isProduction =\n  process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'\n\nexport const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'\n\n/**\n * Base URL for the application\n * Uses NEXT_PUBLIC_* variables which are available at build time\n */\nexport const BASE_URL = (() => {\n  // Development: localhost\n  if (isDevelopment) {\n    return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`\n  }\n\n  // Preview deployments: use Vercel branch URL\n  if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {\n    return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`\n  }\n\n  // Production: use custom domain or Vercel production URL\n  if (isProduction) {\n    return (\n      process.env.NEXT_PUBLIC_APP_URL ||\n      (process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL\n        ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`\n        : 'https://editor.pascal.app')\n    )\n  }\n\n  // Fallback (should never reach here in normal operation)\n  return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'\n})()\n"
  },
  {
    "path": "packages/editor/src/store/use-audio.tsx",
    "content": "'use client'\n\nimport { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\ninterface AudioState {\n  masterVolume: number\n  sfxVolume: number\n  radioVolume: number\n  isRadioPlaying: boolean\n  muted: boolean\n  autoplay: boolean\n  setMasterVolume: (v: number) => void\n  setSfxVolume: (v: number) => void\n  setRadioVolume: (v: number) => void\n  setRadioPlaying: (v: boolean) => void\n  toggleRadioPlaying: () => void\n  toggleMute: () => void\n  setAutoplay: (v: boolean) => void\n}\n\nconst useAudio = create<AudioState>()(\n  persist(\n    (set) => ({\n      masterVolume: 70,\n      sfxVolume: 50,\n      radioVolume: 25,\n      isRadioPlaying: false,\n      muted: false,\n      autoplay: true,\n      setMasterVolume: (v) => set({ masterVolume: v }),\n      setSfxVolume: (v) => set({ sfxVolume: v }),\n      setRadioVolume: (v) => set({ radioVolume: v }),\n      setRadioPlaying: (v) => set({ isRadioPlaying: v }),\n      toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })),\n      toggleMute: () => set((state) => ({ muted: !state.muted })),\n      setAutoplay: (v) => set({ autoplay: v }),\n    }),\n    {\n      name: 'pascal-audio-settings',\n    },\n  ),\n)\n\nexport default useAudio\n"
  },
  {
    "path": "packages/editor/src/store/use-command-registry.ts",
    "content": "import type { ReactNode } from 'react'\nimport { create } from 'zustand'\n\nexport type CommandAction = {\n  id: string\n  /** Static string or a function evaluated at render time (for reactive labels). */\n  label: string | (() => string)\n  group: string\n  icon?: ReactNode\n  keywords?: string[]\n  shortcut?: string[]\n  /** Static string or a function evaluated at render time (for reactive badges). */\n  badge?: string | (() => string)\n  /** Show a chevron to indicate this action navigates to a sub-page. */\n  navigate?: boolean\n  /** Called at render time — returning false disables the item. */\n  when?: () => boolean\n  execute: () => void\n}\n\ninterface CommandRegistryStore {\n  actions: CommandAction[]\n  /** Register actions and return an unsubscribe function. */\n  register: (actions: CommandAction[]) => () => void\n}\n\nexport const useCommandRegistry = create<CommandRegistryStore>((set) => ({\n  actions: [],\n  register: (newActions) => {\n    const ids = newActions.map((a) => a.id)\n    set((s) => ({\n      actions: [...s.actions.filter((a) => !ids.includes(a.id)), ...newActions],\n    }))\n    return () => set((s) => ({ actions: s.actions.filter((a) => !ids.includes(a.id)) }))\n  },\n}))\n"
  },
  {
    "path": "packages/editor/src/store/use-editor.tsx",
    "content": "'use client'\n\nimport type { AssetInput } from '@pascal-app/core'\nimport {\n  type BuildingNode,\n  type DoorNode,\n  type ItemNode,\n  type LevelNode,\n  type RoofNode,\n  type RoofSegmentNode,\n  type Space,\n  type StairNode,\n  type StairSegmentNode,\n  useScene,\n  type WindowNode,\n} from '@pascal-app/core'\nimport { useViewer } from '@pascal-app/viewer'\nimport { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\nconst DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'\nconst DEFAULT_FLOORPLAN_PANE_RATIO = 0.5\nconst MIN_FLOORPLAN_PANE_RATIO = 0.15\nconst MAX_FLOORPLAN_PANE_RATIO = 0.85\n\nexport type ViewMode = '3d' | '2d' | 'split'\nexport type SplitOrientation = 'horizontal' | 'vertical'\n\nexport type Phase = 'site' | 'structure' | 'furnish'\n\nexport type Mode = 'select' | 'edit' | 'delete' | 'build'\n\n// Structure mode tools (building elements)\nexport type StructureTool =\n  | 'wall'\n  | 'room'\n  | 'custom-room'\n  | 'slab'\n  | 'ceiling'\n  | 'roof'\n  | 'column'\n  | 'stair'\n  | 'item'\n  | 'zone'\n  | 'window'\n  | 'door'\n\n// Furnish mode tools (items and decoration)\nexport type FurnishTool = 'item'\n\n// Site mode tools\nexport type SiteTool = 'property-line'\n\n// Catalog categories for furnish mode items\nexport type CatalogCategory =\n  | 'furniture'\n  | 'appliance'\n  | 'bathroom'\n  | 'kitchen'\n  | 'outdoor'\n  | 'window'\n  | 'door'\n\nexport type StructureLayer = 'zones' | 'elements'\n\nexport type FloorplanSelectionTool = 'click' | 'marquee'\n\n// Combined tool type\nexport type Tool = SiteTool | StructureTool | FurnishTool\n\ntype EditorState = {\n  phase: Phase\n  setPhase: (phase: Phase) => void\n  mode: Mode\n  setMode: (mode: Mode) => void\n  tool: Tool | null\n  setTool: (tool: Tool | null) => void\n  structureLayer: StructureLayer\n  setStructureLayer: (layer: StructureLayer) => void\n  catalogCategory: CatalogCategory | null\n  setCatalogCategory: (category: CatalogCategory | null) => void\n  selectedItem: AssetInput | null\n  setSelectedItem: (item: AssetInput) => void\n  movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null\n  setMovingNode: (\n    node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,\n  ) => void\n  selectedReferenceId: string | null\n  setSelectedReferenceId: (id: string | null) => void\n  // Space detection for cutaway mode\n  spaces: Record<string, Space>\n  setSpaces: (spaces: Record<string, Space>) => void\n  // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)\n  editingHole: { nodeId: string; holeIndex: number } | null\n  setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void\n  // Preview mode (viewer-like experience inside the editor)\n  isPreviewMode: boolean\n  setPreviewMode: (preview: boolean) => void\n  // View mode (3D only, 2D only, or split 2D+3D)\n  viewMode: ViewMode\n  setViewMode: (mode: ViewMode) => void\n  splitOrientation: SplitOrientation\n  setSplitOrientation: (orientation: SplitOrientation) => void\n  // Toggleable 2D floorplan overlay (backward compat — derived from viewMode)\n  isFloorplanOpen: boolean\n  setFloorplanOpen: (open: boolean) => void\n  toggleFloorplanOpen: () => void\n  isFloorplanHovered: boolean\n  setFloorplanHovered: (hovered: boolean) => void\n  floorplanSelectionTool: FloorplanSelectionTool\n  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void\n  // Development-only camera debug flag for inspecting underside geometry\n  allowUndergroundCamera: boolean\n  setAllowUndergroundCamera: (enabled: boolean) => void\n  // First-person walkthrough mode (street view)\n  isFirstPersonMode: boolean\n  setFirstPersonMode: (enabled: boolean) => void\n  activeSidebarPanel: string\n  setActiveSidebarPanel: (id: string) => void\n  floorplanPaneRatio: number\n  setFloorplanPaneRatio: (ratio: number) => void\n}\n\nexport type PersistedEditorUiState = Pick<\n  EditorState,\n  'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'\n>\n\ntype PersistedEditorLayoutState = Pick<\n  EditorState,\n  'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'\n>\ntype PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState\n\nexport const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {\n  phase: 'site',\n  mode: 'select',\n  tool: null,\n  structureLayer: 'elements',\n  catalogCategory: null,\n  isFloorplanOpen: false,\n  viewMode: '3d',\n}\n\nexport const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {\n  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,\n  floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,\n  splitOrientation: 'horizontal',\n  floorplanSelectionTool: 'click',\n}\n\nfunction normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {\n  if (phase === 'site') {\n    return 'select'\n  }\n\n  return mode === 'build' || mode === 'delete' ? mode : 'select'\n}\n\nfunction normalizeFloorplanPaneRatio(value: unknown): number {\n  if (!(typeof value === 'number' && Number.isFinite(value))) {\n    return DEFAULT_FLOORPLAN_PANE_RATIO\n  }\n\n  return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))\n}\n\nexport function normalizePersistedEditorUiState(\n  state: Partial<PersistedEditorUiState> | null | undefined,\n): PersistedEditorUiState {\n  const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'\n  const mode = normalizeModeForPhase(phase, state?.mode)\n\n  // Migrate old isFloorplanOpen to viewMode\n  let viewMode: ViewMode = '3d'\n  if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {\n    viewMode = state.viewMode\n  } else if (state?.isFloorplanOpen) {\n    viewMode = 'split'\n  }\n  const isFloorplanOpen = viewMode !== '3d'\n\n  if (phase === 'site') {\n    return {\n      ...DEFAULT_PERSISTED_EDITOR_UI_STATE,\n      phase,\n      mode,\n      viewMode,\n      isFloorplanOpen,\n    }\n  }\n\n  if (phase === 'furnish') {\n    return {\n      phase,\n      mode,\n      tool: mode === 'build' ? 'item' : null,\n      structureLayer: 'elements',\n      catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,\n      viewMode,\n      isFloorplanOpen,\n    }\n  }\n\n  const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'\n\n  if (mode !== 'build') {\n    return {\n      phase,\n      mode,\n      tool: null,\n      structureLayer,\n      catalogCategory: null,\n      viewMode,\n      isFloorplanOpen,\n    }\n  }\n\n  if (structureLayer === 'zones') {\n    return {\n      phase,\n      mode,\n      tool: 'zone',\n      structureLayer,\n      catalogCategory: null,\n      viewMode,\n      isFloorplanOpen,\n    }\n  }\n\n  return {\n    phase,\n    mode,\n    tool:\n      state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',\n    structureLayer,\n    catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,\n    viewMode,\n    isFloorplanOpen,\n  }\n}\n\nfunction normalizePersistedEditorLayoutState(\n  state: Partial<PersistedEditorLayoutState> | null | undefined,\n): PersistedEditorLayoutState {\n  return {\n    activeSidebarPanel:\n      typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()\n        ? state.activeSidebarPanel\n        : DEFAULT_ACTIVE_SIDEBAR_PANEL,\n    floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),\n    splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',\n    floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',\n  }\n}\n\nexport function hasCustomPersistedEditorUiState(\n  state: Partial<PersistedEditorUiState> | null | undefined,\n): boolean {\n  const normalizedState = normalizePersistedEditorUiState(state)\n\n  return (\n    normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||\n    normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||\n    normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||\n    normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||\n    normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||\n    normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||\n    normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode\n  )\n}\n\n/**\n * Selects the first building and level 0 in the scene.\n * Safe to call any time — no-ops if already selected or scene is empty.\n */\nexport function selectDefaultBuildingAndLevel() {\n  const viewer = useViewer.getState()\n  const scene = useScene.getState()\n\n  let buildingId = viewer.selection.buildingId\n\n  // If no building selected, find the first one from site's children\n  if (!buildingId) {\n    const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null\n    if (siteNode?.type === 'site') {\n      const firstBuilding = siteNode.children\n        .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))\n        .find((node) => node?.type === 'building')\n      if (firstBuilding) {\n        buildingId = firstBuilding.id as BuildingNode['id']\n        viewer.setSelection({ buildingId })\n      }\n    }\n  }\n\n  // If no level selected, find level 0 in the building\n  if (buildingId && !viewer.selection.levelId) {\n    const buildingNode = scene.nodes[buildingId] as BuildingNode\n    const level0Id = buildingNode.children.find((childId) => {\n      const levelNode = scene.nodes[childId] as LevelNode\n      return levelNode?.type === 'level' && levelNode.level === 0\n    })\n    if (level0Id) {\n      viewer.setSelection({ levelId: level0Id as LevelNode['id'] })\n    } else if (buildingNode.children[0]) {\n      // Fallback to first level if level 0 doesn't exist\n      viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })\n    }\n  }\n}\n\nconst useEditor = create<EditorState>()(\n  persist(\n    (set, get) => ({\n      phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,\n      setPhase: (phase) => {\n        const currentPhase = get().phase\n        if (currentPhase === phase) return\n\n        set({ phase })\n\n        const { mode, structureLayer } = get()\n\n        if (mode === 'build') {\n          // Stay in build mode, select the first tool for the new phase\n          if (phase === 'site') {\n            set({ tool: 'property-line', catalogCategory: null })\n          } else if (phase === 'structure' && structureLayer === 'zones') {\n            set({ tool: 'zone', catalogCategory: null })\n          } else if (phase === 'structure') {\n            set({ tool: 'wall', catalogCategory: null })\n          } else if (phase === 'furnish') {\n            set({ tool: 'item', catalogCategory: 'furniture' })\n          }\n        } else {\n          // Reset to select mode and clear tool/catalog when switching phases\n          set({ mode: 'select', tool: null, catalogCategory: null })\n        }\n\n        const viewer = useViewer.getState()\n\n        switch (phase) {\n          case 'site':\n            // In Site mode, we zoom out and deselect specific levels/buildings\n            viewer.resetSelection()\n            break\n\n          case 'structure':\n            selectDefaultBuildingAndLevel()\n            break\n\n          case 'furnish':\n            selectDefaultBuildingAndLevel()\n            // Furnish mode only supports elements layer, not zones\n            set({ structureLayer: 'elements' })\n            break\n        }\n      },\n      mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,\n      setMode: (mode) => {\n        set({ mode })\n\n        const { phase, structureLayer, tool } = get()\n\n        if (mode === 'build') {\n          // Ensure a tool is selected in build mode\n          if (!tool) {\n            if (phase === 'structure' && structureLayer === 'zones') {\n              set({ tool: 'zone' })\n            } else if (phase === 'structure' && structureLayer === 'elements') {\n              set({ tool: 'wall' })\n            } else if (phase === 'furnish') {\n              set({ tool: 'item', catalogCategory: 'furniture' })\n            }\n          }\n        }\n        // When leaving build mode, clear tool\n        else if (tool) {\n          set({ tool: null })\n        }\n      },\n      tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,\n      setTool: (tool) => set({ tool }),\n      structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,\n      setStructureLayer: (layer) => {\n        const { mode } = get()\n\n        if (mode === 'build') {\n          const tool = layer === 'zones' ? 'zone' : 'wall'\n          set({ structureLayer: layer, tool })\n        } else {\n          set({ structureLayer: layer, mode: 'select', tool: null })\n        }\n\n        const viewer = useViewer.getState()\n        viewer.setSelection({\n          selectedIds: [],\n          zoneId: null,\n        })\n      },\n      catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,\n      setCatalogCategory: (category) => set({ catalogCategory: category }),\n      selectedItem: null,\n      setSelectedItem: (item) => set({ selectedItem: item }),\n      movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,\n      setMovingNode: (node) => set({ movingNode: node }),\n      selectedReferenceId: null,\n      setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),\n      spaces: {},\n      setSpaces: (spaces) => set({ spaces }),\n      editingHole: null,\n      setEditingHole: (hole) => set({ editingHole: hole }),\n      isPreviewMode: false,\n      setPreviewMode: (preview) => {\n        if (preview) {\n          set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })\n          // Clear zone/item selection for clean viewer drill-down hierarchy\n          useViewer.getState().setSelection({ selectedIds: [], zoneId: null })\n        } else {\n          set({ isPreviewMode: false })\n        }\n      },\n      viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,\n      setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),\n      splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,\n      setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),\n      isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,\n      setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),\n      toggleFloorplanOpen: () =>\n        set((state) => {\n          const open = !state.isFloorplanOpen\n          return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }\n        }),\n      isFloorplanHovered: false,\n      setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),\n      floorplanSelectionTool: 'click' as FloorplanSelectionTool,\n      setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),\n      allowUndergroundCamera: false,\n      setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),\n      isFirstPersonMode: false,\n      _viewModeBeforeFirstPerson: null as ViewMode | null,\n      setFirstPersonMode: (enabled) => {\n        if (enabled) {\n          // Save current view mode and force 3D for immersive walkthrough\n          const currentViewMode = get().viewMode\n          // Force perspective camera and full-height walls for immersive walkthrough\n          useViewer.getState().setCameraMode('perspective')\n          useViewer.getState().setWallMode('up')\n          set({\n            isFirstPersonMode: true,\n            _viewModeBeforeFirstPerson: currentViewMode,\n            viewMode: '3d',\n            isFloorplanOpen: false,\n            mode: 'select',\n            tool: null,\n            catalogCategory: null,\n          })\n          useViewer.getState().setSelection({ selectedIds: [], zoneId: null })\n        } else {\n          // Restore previous view mode\n          const prevMode = get()._viewModeBeforeFirstPerson\n          set({\n            isFirstPersonMode: false,\n            _viewModeBeforeFirstPerson: null,\n            ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),\n          })\n        }\n      },\n      activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,\n      setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),\n      floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,\n      setFloorplanPaneRatio: (ratio) =>\n        set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),\n    }),\n    {\n      name: 'pascal-editor-ui-preferences',\n      merge: (persistedState, currentState) => ({\n        ...currentState,\n        ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),\n        ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),\n      }),\n      partialize: (state) => ({\n        phase: state.phase,\n        mode: state.mode,\n        tool: state.tool,\n        structureLayer: state.structureLayer,\n        catalogCategory: state.catalogCategory,\n        isFloorplanOpen: state.isFloorplanOpen,\n        viewMode: state.viewMode,\n        activeSidebarPanel: state.activeSidebarPanel,\n        floorplanPaneRatio: state.floorplanPaneRatio,\n        splitOrientation: state.splitOrientation,\n        floorplanSelectionTool: state.floorplanSelectionTool,\n      }),\n    },\n  ),\n)\n\nexport default useEditor\n"
  },
  {
    "path": "packages/editor/src/store/use-palette-view-registry.ts",
    "content": "import type { ComponentType } from 'react'\nimport { create } from 'zustand'\n\nexport type PaletteViewProps = {\n  onClose: () => void\n  onBack: () => void\n}\n\nexport type PaletteView = {\n  /** Unique key — matches a page name or a mode name. */\n  key: string\n  /**\n   * `'page'` — renders inside the cmdk Command shell (list area only).\n   * Filtering and keyboard navigation still work.\n   *\n   * `'mode'` — replaces the entire cmdk shell inside the Dialog.\n   * Used for full-screen states like ai-executing or ai-review.\n   */\n  type: 'page' | 'mode'\n  /** Human-readable label shown as the breadcrumb for page views. */\n  label?: string\n  Component: ComponentType<PaletteViewProps>\n}\n\ninterface PaletteViewRegistryStore {\n  views: Map<string, PaletteView>\n  register: (view: PaletteView) => () => void\n}\n\nexport const usePaletteViewRegistry = create<PaletteViewRegistryStore>((set) => ({\n  views: new Map(),\n  register: (view) => {\n    set((s) => {\n      const next = new Map(s.views)\n      next.set(view.key, view)\n      return { views: next }\n    })\n    return () =>\n      set((s) => {\n        const next = new Map(s.views)\n        next.delete(view.key)\n        return { views: next }\n      })\n  },\n}))\n"
  },
  {
    "path": "packages/editor/src/store/use-upload.ts",
    "content": "import { create } from 'zustand'\n\nexport type UploadStatus = 'preparing' | 'uploading' | 'confirming' | 'done' | 'error'\n\nexport interface UploadEntry {\n  status: UploadStatus\n  assetType: 'scan' | 'guide'\n  fileName: string\n  progress: number // 0-100\n  error: string | null\n  resultUrl: string | null\n}\n\nexport type UploadHandler = (\n  projectId: string,\n  levelId: string,\n  file: File,\n  type: 'scan' | 'guide',\n) => void\n\ninterface UploadState {\n  uploads: Record<string, UploadEntry>\n  uploadHandler: UploadHandler | null\n  registerUploadHandler: (handler: UploadHandler) => void\n  unregisterUploadHandler: () => void\n  startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void\n  setProgress: (levelId: string, progress: number) => void\n  setStatus: (levelId: string, status: UploadStatus) => void\n  setError: (levelId: string, error: string) => void\n  setResult: (levelId: string, url: string) => void\n  clearUpload: (levelId: string) => void\n}\n\nexport const useUploadStore = create<UploadState>((set) => ({\n  uploads: {},\n  uploadHandler: null,\n  registerUploadHandler: (handler) => set({ uploadHandler: handler }),\n  unregisterUploadHandler: () => set({ uploadHandler: null }),\n\n  startUpload: (levelId, assetType, fileName) =>\n    set((s) => ({\n      uploads: {\n        ...s.uploads,\n        [levelId]: {\n          status: 'preparing',\n          assetType,\n          fileName,\n          progress: 0,\n          error: null,\n          resultUrl: null,\n        },\n      },\n    })),\n\n  setProgress: (levelId, progress) =>\n    set((s) => {\n      const entry = s.uploads[levelId]\n      if (!entry) return s\n      return { uploads: { ...s.uploads, [levelId]: { ...entry, progress } } }\n    }),\n\n  setStatus: (levelId, status) =>\n    set((s) => {\n      const entry = s.uploads[levelId]\n      if (!entry) return s\n      return { uploads: { ...s.uploads, [levelId]: { ...entry, status } } }\n    }),\n\n  setError: (levelId, error) =>\n    set((s) => {\n      const entry = s.uploads[levelId]\n      if (!entry) return s\n      return { uploads: { ...s.uploads, [levelId]: { ...entry, status: 'error' as const, error } } }\n    }),\n\n  setResult: (levelId, url) =>\n    set((s) => {\n      const entry = s.uploads[levelId]\n      if (!entry) return s\n      return {\n        uploads: { ...s.uploads, [levelId]: { ...entry, status: 'done' as const, resultUrl: url } },\n      }\n    }),\n\n  clearUpload: (levelId) =>\n    set((s) => {\n      const { [levelId]: _, ...rest } = s.uploads\n      return { uploads: rest }\n    }),\n}))\n"
  },
  {
    "path": "packages/editor/src/three-types.ts",
    "content": "// Pull in React Three Fiber JSX type augmentations (mesh, group, etc.)\n// This import triggers R3F's module augmentation of react/jsx-runtime\nimport '@react-three/fiber'\n"
  },
  {
    "path": "packages/editor/tsconfig.json",
    "content": "{\n  \"extends\": \"@pascal/typescript-config/react-library.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"noEmit\": true\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/eslint-config/README.md",
    "content": "# `@turbo/eslint-config`\n\nCollection of internal eslint configurations.\n"
  },
  {
    "path": "packages/eslint-config/base.js",
    "content": "import js from '@eslint/js'\nimport eslintConfigPrettier from 'eslint-config-prettier'\nimport onlyWarn from 'eslint-plugin-only-warn'\nimport turboPlugin from 'eslint-plugin-turbo'\nimport tseslint from 'typescript-eslint'\n\n/**\n * A shared ESLint configuration for the repository.\n *\n * @type {import(\"eslint\").Linter.Config[]}\n * */\nexport const config = [\n  js.configs.recommended,\n  eslintConfigPrettier,\n  ...tseslint.configs.recommended,\n  {\n    plugins: {\n      turbo: turboPlugin,\n    },\n    rules: {\n      'turbo/no-undeclared-env-vars': 'warn',\n    },\n  },\n  {\n    plugins: {\n      onlyWarn,\n    },\n  },\n  {\n    ignores: ['dist/**'],\n  },\n]\n"
  },
  {
    "path": "packages/eslint-config/next.js",
    "content": "import js from '@eslint/js'\nimport pluginNext from '@next/eslint-plugin-next'\nimport { globalIgnores } from 'eslint/config'\nimport eslintConfigPrettier from 'eslint-config-prettier'\nimport pluginReact from 'eslint-plugin-react'\nimport pluginReactHooks from 'eslint-plugin-react-hooks'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\nimport { config as baseConfig } from './base.js'\n\n/**\n * A custom ESLint configuration for libraries that use Next.js.\n *\n * @type {import(\"eslint\").Linter.Config[]}\n * */\nexport const nextJsConfig = [\n  ...baseConfig,\n  js.configs.recommended,\n  eslintConfigPrettier,\n  ...tseslint.configs.recommended,\n  globalIgnores([\n    // Default ignores of eslint-config-next:\n    '.next/**',\n    'out/**',\n    'build/**',\n    'next-env.d.ts',\n  ]),\n  {\n    ...pluginReact.configs.flat.recommended,\n    languageOptions: {\n      ...pluginReact.configs.flat.recommended.languageOptions,\n      globals: {\n        ...globals.serviceworker,\n      },\n    },\n  },\n  {\n    plugins: {\n      '@next/next': pluginNext,\n    },\n    rules: {\n      ...pluginNext.configs.recommended.rules,\n      ...pluginNext.configs['core-web-vitals'].rules,\n    },\n  },\n  {\n    plugins: {\n      'react-hooks': pluginReactHooks,\n    },\n    settings: { react: { version: 'detect' } },\n    rules: {\n      ...pluginReactHooks.configs.recommended.rules,\n      // React scope no longer necessary with new JSX transform.\n      'react/react-in-jsx-scope': 'off',\n    },\n  },\n]\n"
  },
  {
    "path": "packages/eslint-config/package.json",
    "content": "{\n  \"name\": \"@repo/eslint-config\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"exports\": {\n    \"./base\": \"./base.js\",\n    \"./next-js\": \"./next.js\",\n    \"./react-internal\": \"./react-internal.js\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.1\",\n    \"@next/eslint-plugin-next\": \"^15.5.0\",\n    \"eslint\": \"^9.39.1\",\n    \"eslint-config-prettier\": \"^10.1.1\",\n    \"eslint-plugin-only-warn\": \"^1.1.0\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-react-hooks\": \"^5.2.0\",\n    \"eslint-plugin-turbo\": \"^2.7.1\",\n    \"globals\": \"^16.5.0\",\n    \"typescript\": \"^5.9.2\",\n    \"typescript-eslint\": \"^8.50.0\"\n  }\n}\n"
  },
  {
    "path": "packages/eslint-config/react-internal.js",
    "content": "import js from '@eslint/js'\nimport eslintConfigPrettier from 'eslint-config-prettier'\nimport pluginReact from 'eslint-plugin-react'\nimport pluginReactHooks from 'eslint-plugin-react-hooks'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\nimport { config as baseConfig } from './base.js'\n\n/**\n * A custom ESLint configuration for libraries that use React.\n *\n * @type {import(\"eslint\").Linter.Config[]} */\nexport const config = [\n  ...baseConfig,\n  js.configs.recommended,\n  eslintConfigPrettier,\n  ...tseslint.configs.recommended,\n  pluginReact.configs.flat.recommended,\n  {\n    languageOptions: {\n      ...pluginReact.configs.flat.recommended.languageOptions,\n      globals: {\n        ...globals.serviceworker,\n        ...globals.browser,\n      },\n    },\n  },\n  {\n    plugins: {\n      'react-hooks': pluginReactHooks,\n    },\n    settings: { react: { version: 'detect' } },\n    rules: {\n      ...pluginReactHooks.configs.recommended.rules,\n      // React scope no longer necessary with new JSX transform.\n      'react/react-in-jsx-scope': 'off',\n    },\n  },\n]\n"
  },
  {
    "path": "packages/typescript-config/base.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"incremental\": false,\n    \"isolatedModules\": true,\n    \"lib\": [\"es2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"NodeNext\",\n    \"moduleDetection\": \"force\",\n    \"moduleResolution\": \"NodeNext\",\n    \"noUncheckedIndexedAccess\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"target\": \"ES2022\"\n  }\n}\n"
  },
  {
    "path": "packages/typescript-config/nextjs.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"plugins\": [{ \"name\": \"next\" }],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"allowJs\": true,\n    \"jsx\": \"preserve\",\n    \"noEmit\": true,\n    \"declaration\": false,\n    \"declarationMap\": false\n  }\n}\n"
  },
  {
    "path": "packages/typescript-config/package.json",
    "content": "{\n  \"name\": \"@repo/typescript-config\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"license\": \"MIT\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/typescript-config/react-library.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/eslint.config.mjs",
    "content": "import { config } from \"@repo/eslint-config/react-internal\";\n\n/** @type {import(\"eslint\").Linter.Config} */\nexport default config;\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n  \"name\": \"@repo/ui\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"exports\": {\n    \"./*\": \"./src/*.tsx\"\n  },\n  \"scripts\": {\n    \"lint\": \"eslint . --max-warnings 0\",\n    \"generate:component\": \"turbo gen react-component\",\n    \"check-types\": \"tsc --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@repo/eslint-config\": \"*\",\n    \"@repo/typescript-config\": \"*\",\n    \"@types/node\": \"^22.15.3\",\n    \"@types/react\": \"19.2.2\",\n    \"@types/react-dom\": \"19.2.2\",\n    \"eslint\": \"^9.39.1\",\n    \"typescript\": \"5.9.3\"\n  },\n  \"dependencies\": {\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/src/button.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\n\ninterface ButtonProps {\n  children: ReactNode\n  className?: string\n  appName: string\n}\n\nexport const Button = ({ children, className, appName }: ButtonProps) => {\n  return (\n    <button className={className} onClick={() => alert(`Hello from your ${appName} app!`)}>\n      {children}\n    </button>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/card.tsx",
    "content": "import type { JSX } from 'react'\n\nexport function Card({\n  className,\n  title,\n  children,\n  href,\n}: {\n  className?: string\n  title: string\n  children: React.ReactNode\n  href: string\n}): JSX.Element {\n  return (\n    <a\n      className={className}\n      href={`${href}?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo\"`}\n      rel=\"noopener noreferrer\"\n      target=\"_blank\"\n    >\n      <h2>\n        {title} <span>-&gt;</span>\n      </h2>\n      <p>{children}</p>\n    </a>\n  )\n}\n"
  },
  {
    "path": "packages/ui/src/code.tsx",
    "content": "import type { JSX } from 'react'\n\nexport function Code({\n  children,\n  className,\n}: {\n  children: React.ReactNode\n  className?: string\n}): JSX.Element {\n  return <code className={className}>{children}</code>\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n  \"extends\": \"@repo/typescript-config/react-library.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "packages/viewer/README.md",
    "content": "# @pascal-app/viewer\n\n3D viewer component for Pascal building editor.\n\n## Installation\n\n```bash\nnpm install @pascal-app/viewer @pascal-app/core\n```\n\n## Peer Dependencies\n\n```bash\nnpm install react three @react-three/fiber @react-three/drei\n```\n\n## What's Included\n\n- **Viewer Component** - WebGPU-powered 3D viewer with camera controls\n- **Node Renderers** - React Three Fiber components for all node types\n- **Post-Processing** - SSGI (ambient occlusion + global illumination), TRAA (anti-aliasing), outline effects\n- **Level System** - Level visibility and positioning (stacked/exploded/solo modes)\n- **Wall Cutout System** - Dynamic wall hiding based on camera position\n- **Asset URL Helpers** - CDN URL resolution for models and textures\n\n## Usage\n\n```typescript\nimport { Viewer, useViewer } from '@pascal-app/viewer'\nimport { useScene } from '@pascal-app/core'\n\nfunction App() {\n  return (\n    <div style={{ width: '100vw', height: '100vh' }}>\n      <Viewer />\n    </div>\n  )\n}\n```\n\n## Custom Camera Controls\n\n```typescript\nimport { Viewer } from '@pascal-app/viewer'\nimport { CameraControls } from '@react-three/drei'\n\nfunction App() {\n  return (\n    <Viewer selectionManager=\"custom\">\n      <CameraControls />\n    </Viewer>\n  )\n}\n```\n\n## Viewer State\n\n```typescript\nimport { useViewer } from '@pascal-app/viewer'\n\nfunction ViewerControls() {\n  const levelMode = useViewer(s => s.levelMode)\n  const setLevelMode = useViewer(s => s.setLevelMode)\n  const wallMode = useViewer(s => s.wallMode)\n  const setWallMode = useViewer(s => s.setWallMode)\n\n  return (\n    <div>\n      <button onClick={() => setLevelMode('stacked')}>Stacked</button>\n      <button onClick={() => setLevelMode('exploded')}>Exploded</button>\n      <button onClick={() => setWallMode('cutaway')}>Cutaway</button>\n      <button onClick={() => setWallMode('up')}>Full Height</button>\n    </div>\n  )\n}\n```\n\n## Asset CDN Helpers\n\n```typescript\nimport { resolveCdnUrl, ASSETS_CDN_URL } from '@pascal-app/viewer'\n\n// Resolves relative paths to CDN URLs\nconst url = resolveCdnUrl('/items/chair/model.glb')\n// → 'https://pascal-cdn.wawasensei.dev/items/chair/model.glb'\n\n// Handles external URLs and asset:// protocol\nconst externalUrl = resolveCdnUrl('https://example.com/model.glb')\n// → 'https://example.com/model.glb' (unchanged)\n```\n\n## Features\n\n- **WebGPU Rendering** - Hardware-accelerated rendering via Three.js WebGPU\n- **Post-Processing** - SSGI for realistic lighting, outline effects for selection\n- **Level Modes** - Stacked, exploded, or solo level display\n- **Wall Cutaway** - Automatic wall hiding for interior views\n- **Camera Modes** - Perspective and orthographic projection\n- **Scan/Guide Support** - 3D scans and 2D guide images\n\n## License\n\nMIT\n"
  },
  {
    "path": "packages/viewer/package.json",
    "content": "{\n  \"name\": \"@pascal-app/viewer\",\n  \"version\": \"0.3.3\",\n  \"description\": \"3D viewer component for Pascal building editor\",\n  \"type\": \"module\",\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"default\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\",\n    \"README.md\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc --build\",\n    \"dev\": \"tsc --build --watch\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"peerDependencies\": {\n    \"@pascal-app/core\": \"^0.1.4\",\n    \"@react-three/drei\": \"^10\",\n    \"@react-three/fiber\": \"^9\",\n    \"react\": \"^18 || ^19\",\n    \"three\": \"^0.183\"\n  },\n  \"dependencies\": {\n    \"polygon-clipping\": \"^0.15.7\",\n    \"zustand\": \"^5\"\n  },\n  \"devDependencies\": {\n    \"@pascal/typescript-config\": \"*\",\n    \"@types/node\": \"^25.5.0\",\n    \"@types/react\": \"^19.2.2\",\n    \"@types/three\": \"^0.183.0\",\n    \"typescript\": \"5.9.3\"\n  },\n  \"keywords\": [\n    \"3d\",\n    \"building\",\n    \"editor\",\n    \"viewer\",\n    \"architecture\",\n    \"webgpu\",\n    \"three.js\",\n    \"react-three-fiber\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/pascalorg/editor.git\",\n    \"directory\": \"packages/viewer\"\n  },\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/pascalorg/editor/tree/main/packages/viewer#readme\",\n  \"bugs\": \"https://github.com/pascalorg/editor/issues\"\n}\n"
  },
  {
    "path": "packages/viewer/src/components/error-boundary.tsx",
    "content": "import type { ErrorInfo, ReactNode } from 'react'\nimport { Component } from 'react'\n\nexport class ErrorBoundary extends Component<\n  { children: ReactNode; fallback: ReactNode },\n  { hasError: boolean }\n> {\n  state = { hasError: false }\n  static getDerivedStateFromError() {\n    return { hasError: true }\n  }\n  componentDidCatch(_e: Error, _i: ErrorInfo) {}\n  render() {\n    return this.state.hasError ? this.props.fallback : this.props.children\n  }\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/building/building-renderer.tsx",
    "content": "import { type BuildingNode, useRegistry } from '@pascal-app/core'\nimport { useRef } from 'react'\nimport type { Group } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { NodeRenderer } from '../node-renderer'\n\nexport const BuildingRenderer = ({ node }: { node: BuildingNode }) => {\n  const ref = useRef<Group>(null!)\n\n  useRegistry(node.id, node.type, ref)\n  const handlers = useNodeEvents(node, 'building')\n  return (\n    <group ref={ref} {...handlers}>\n      {node.children.map((childId) => (\n        <NodeRenderer key={childId} nodeId={childId} />\n      ))}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx",
    "content": "import { type CeilingNode, resolveMaterial, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport { float, mix, positionWorld, smoothstep } from 'three/tsl'\nimport { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { NodeRenderer } from '../node-renderer'\n\nconst gridScale = 5\nconst gridX = positionWorld.x.mul(gridScale).fract()\nconst gridY = positionWorld.z.mul(gridScale).fract()\nconst lineWidth = 0.05\nconst lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))\nconst lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))\nconst gridPattern = lineX.max(lineY)\nconst gridOpacity = mix(float(0.2), float(0.6), gridPattern)\n\nfunction createCeilingMaterials(color: string = '#999999') {\n  const topMaterial = new MeshBasicNodeMaterial({\n    color,\n    transparent: true,\n    depthWrite: false,\n    side: FrontSide,\n  })\n  topMaterial.opacityNode = gridOpacity\n\n  const bottomMaterial = new MeshBasicNodeMaterial({\n    color,\n    transparent: true,\n    side: BackSide,\n  })\n\n  return { topMaterial, bottomMaterial }\n}\n\nexport const CeilingRenderer = ({ node }: { node: CeilingNode }) => {\n  const ref = useRef<Mesh>(null!)\n\n  useRegistry(node.id, 'ceiling', ref)\n  const handlers = useNodeEvents(node, 'ceiling')\n\n  const materials = useMemo(() => {\n    const props = resolveMaterial(node.material)\n    const color = props.color || '#999999'\n    return createCeilingMaterials(color)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh material={materials.bottomMaterial} ref={ref}>\n      <boxGeometry args={[0, 0, 0]} />\n      <mesh\n        material={materials.topMaterial}\n        name=\"ceiling-grid\"\n        {...handlers}\n        scale={0}\n        visible={false}\n      >\n        <boxGeometry args={[0, 0, 0]} />\n      </mesh>\n      {node.children.map((childId) => (\n        <NodeRenderer key={childId} nodeId={childId} />\n      ))}\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/door/door-renderer.tsx",
    "content": "import { type DoorNode, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport type { Mesh } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'\n\nexport const DoorRenderer = ({ node }: { node: DoorNode }) => {\n  const ref = useRef<Mesh>(null!)\n\n  useRegistry(node.id, 'door', ref)\n  const handlers = useNodeEvents(node, 'door')\n  const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_DOOR_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh\n      castShadow\n      material={material}\n      position={node.position}\n      receiveShadow\n      ref={ref}\n      rotation={node.rotation}\n      visible={node.visible}\n      {...(isTransient ? {} : handlers)}\n    >\n      <boxGeometry args={[0, 0, 0]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/guide/guide-renderer.tsx",
    "content": "import { type GuideNode, useRegistry } from '@pascal-app/core'\nimport { useLoader } from '@react-three/fiber'\nimport { Suspense, useMemo, useRef } from 'react'\nimport { DoubleSide, type Group, type Texture, TextureLoader } from 'three'\nimport { float, texture } from 'three/tsl'\nimport { MeshBasicNodeMaterial } from 'three/webgpu'\nimport { useAssetUrl } from '../../../hooks/use-asset-url'\nimport useViewer from '../../../store/use-viewer'\n\nexport const GuideRenderer = ({ node }: { node: GuideNode }) => {\n  const showGuides = useViewer((s) => s.showGuides)\n  const ref = useRef<Group>(null!)\n  useRegistry(node.id, 'guide', ref)\n\n  const resolvedUrl = useAssetUrl(node.url)\n\n  return (\n    <group\n      position={node.position}\n      ref={ref}\n      rotation={[0, node.rotation[1], 0]}\n      visible={showGuides}\n    >\n      {resolvedUrl && (\n        <Suspense>\n          <GuidePlane opacity={node.opacity} scale={node.scale} url={resolvedUrl} />\n        </Suspense>\n      )}\n    </group>\n  )\n}\n\nconst GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {\n  const tex = useLoader(TextureLoader, url) as Texture\n\n  const { width, height, material } = useMemo(() => {\n    const img = tex.image as HTMLImageElement | ImageBitmap\n    const w = img.width || 1\n    const h = img.height || 1\n    const aspect = w / h\n\n    // Default: 10 meters wide, height from aspect ratio\n    const planeWidth = 10 * scale\n    const planeHeight = (10 / aspect) * scale\n\n    const normalizedOpacity = opacity / 100\n\n    const mat = new MeshBasicNodeMaterial({\n      transparent: true,\n      colorNode: texture(tex),\n      opacityNode: float(normalizedOpacity),\n      side: DoubleSide,\n      depthWrite: false,\n    })\n\n    return { width: planeWidth, height: planeHeight, material: mat }\n  }, [tex, scale, opacity])\n\n  return (\n    <mesh\n      frustumCulled={false}\n      material={material}\n      raycast={() => {}}\n      rotation={[-Math.PI / 2, 0, 0]}\n    >\n      <planeGeometry args={[width, height]} boundingBox={null} boundingSphere={null} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/item/item-renderer.tsx",
    "content": "import {\n  type AnimationEffect,\n  type AnyNodeId,\n  type Interactive,\n  type ItemNode,\n  type LightEffect,\n  useInteractive,\n  useRegistry,\n  useScene,\n} from '@pascal-app/core'\nimport { useAnimations } from '@react-three/drei'\nimport { Clone } from '@react-three/drei/core/Clone'\nimport { useGLTF } from '@react-three/drei/core/Gltf'\nimport { useFrame } from '@react-three/fiber'\nimport { Suspense, useEffect, useMemo, useRef } from 'react'\nimport type { AnimationAction, Group, Material, Mesh } from 'three'\nimport { MathUtils } from 'three'\nimport { positionLocal, smoothstep, time } from 'three/tsl'\nimport { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { resolveCdnUrl } from '../../../lib/asset-url'\nimport { useItemLightPool } from '../../../store/use-item-light-pool'\nimport { ErrorBoundary } from '../../error-boundary'\nimport { NodeRenderer } from '../node-renderer'\n\n// Shared materials to avoid creating new instances for every mesh\nconst defaultMaterial = new MeshStandardNodeMaterial({\n  color: 0xff_ff_ff,\n  roughness: 1,\n  metalness: 0,\n})\n\nconst glassMaterial = new MeshStandardNodeMaterial({\n  name: 'glass',\n  color: 'lightgray',\n  roughness: 0.8,\n  metalness: 0,\n  transparent: true,\n  opacity: 0.35,\n  side: DoubleSide,\n  depthWrite: false,\n})\n\nconst getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {\n  if (original.name.toLowerCase() === 'glass') {\n    return glassMaterial\n  }\n  return defaultMaterial\n}\n\nconst BrokenItemFallback = ({ node }: { node: ItemNode }) => {\n  const handlers = useNodeEvents(node, 'item')\n  const [w, h, d] = node.asset.dimensions\n  return (\n    <mesh position-y={h / 2} {...handlers}>\n      <boxGeometry args={[w, h, d]} />\n      <meshStandardMaterial color=\"#ef4444\" opacity={0.6} transparent wireframe />\n    </mesh>\n  )\n}\n\nexport const ItemRenderer = ({ node }: { node: ItemNode }) => {\n  const ref = useRef<Group>(null!)\n\n  useRegistry(node.id, node.type, ref)\n\n  return (\n    <group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>\n      <ErrorBoundary fallback={<BrokenItemFallback node={node} />}>\n        <Suspense fallback={<PreviewModel node={node} />}>\n          <ModelRenderer node={node} />\n        </Suspense>\n      </ErrorBoundary>\n      {node.children?.map((childId) => (\n        <NodeRenderer key={childId} nodeId={childId} />\n      ))}\n    </group>\n  )\n}\n\nconst previewMaterial = new MeshStandardNodeMaterial({\n  color: '#cccccc',\n  roughness: 1,\n  metalness: 0,\n  depthTest: false,\n})\n\nconst previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())\n\npreviewMaterial.opacityNode = previewOpacity\npreviewMaterial.transparent = true\n\nconst PreviewModel = ({ node }: { node: ItemNode }) => {\n  return (\n    <mesh material={previewMaterial} position-y={node.asset.dimensions[1] / 2}>\n      <boxGeometry\n        args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}\n      />\n    </mesh>\n  )\n}\n\nconst multiplyScales = (\n  a: [number, number, number],\n  b: [number, number, number],\n): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]\n\nconst ModelRenderer = ({ node }: { node: ItemNode }) => {\n  const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')\n  const ref = useRef<Group>(null!)\n  const { actions } = useAnimations(animations, ref)\n  // Freeze the interactive definition at mount — asset schemas don't change at runtime\n  const interactiveRef = useRef(node.asset.interactive)\n\n  if (nodes.cutout) {\n    nodes.cutout.visible = false\n  }\n\n  const handlers = useNodeEvents(node, 'item')\n\n  useEffect(() => {\n    if (!node.parentId) return\n    useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)\n  }, [node.parentId])\n\n  useEffect(() => {\n    const interactive = interactiveRef.current\n    if (!interactive) return\n    useInteractive.getState().initItem(node.id, interactive)\n    return () => useInteractive.getState().removeItem(node.id)\n  }, [node.id])\n\n  useMemo(() => {\n    scene.traverse((child) => {\n      if ((child as Mesh).isMesh) {\n        const mesh = child as Mesh\n        if (mesh.name === 'cutout') {\n          child.visible = false\n          return\n        }\n\n        let hasGlass = false\n\n        // Handle both single material and material array cases\n        if (Array.isArray(mesh.material)) {\n          mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))\n          hasGlass = mesh.material.some((mat) => mat.name === 'glass')\n        } else {\n          mesh.material = getMaterialForOriginal(mesh.material)\n          hasGlass = mesh.material.name === 'glass'\n        }\n        mesh.castShadow = !hasGlass\n        mesh.receiveShadow = !hasGlass\n      }\n    })\n  }, [scene])\n\n  const interactive = interactiveRef.current\n  const animEffect =\n    interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null\n  const lightEffects =\n    interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []\n\n  return (\n    <>\n      <Clone\n        object={scene}\n        position={node.asset.offset}\n        ref={ref}\n        rotation={node.asset.rotation}\n        scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}\n        {...handlers}\n      />\n      {animations.length > 0 && (\n        <ItemAnimation\n          actions={actions}\n          animations={animations}\n          animEffect={animEffect}\n          interactive={interactive ?? null}\n          nodeId={node.id}\n        />\n      )}\n      {lightEffects.map((effect, i) => (\n        <ItemLightRegistrar\n          effect={effect}\n          index={i}\n          interactive={interactive!}\n          key={i}\n          nodeId={node.id}\n        />\n      ))}\n    </>\n  )\n}\n\nconst ItemAnimation = ({\n  nodeId,\n  animEffect,\n  interactive,\n  actions,\n  animations,\n}: {\n  nodeId: AnyNodeId\n  animEffect: AnimationEffect | null\n  interactive: Interactive | null\n  actions: Record<string, AnimationAction | null>\n  animations: { name: string }[]\n}) => {\n  const activeClipRef = useRef<string | null>(null)\n  const fadingOutRef = useRef<AnimationAction | null>(null)\n\n  // Reactive: derive target clip name — only re-renders when the clip name itself changes\n  const targetClip = useInteractive((s) => {\n    const values = s.items[nodeId]?.controlValues\n    if (!animEffect) return animations[0]?.name ?? null\n    const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')\n    const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false\n    return isOn\n      ? (animEffect.clips.on ?? null)\n      : (animEffect.clips.off ?? animEffect.clips.loop ?? null)\n  })\n\n  // When target clip changes: kick off the transition\n  useEffect(() => {\n    // Cancel any ongoing fade-out immediately\n    if (fadingOutRef.current) {\n      fadingOutRef.current.timeScale = 0\n      fadingOutRef.current = null\n    }\n    // Move current clip to fade-out\n    if (activeClipRef.current && activeClipRef.current !== targetClip) {\n      const old = actions[activeClipRef.current]\n      if (old?.isRunning()) fadingOutRef.current = old\n    }\n    // Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1\n    activeClipRef.current = targetClip\n    if (targetClip) {\n      const next = actions[targetClip]\n      if (next) {\n        next.timeScale = 0.01\n        next.play()\n      }\n    }\n  }, [targetClip, actions])\n\n  // useFrame: only lerping — no logic\n  useFrame((_, delta) => {\n    if (fadingOutRef.current) {\n      const action = fadingOutRef.current\n      action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))\n      if (action.timeScale < 0.01) {\n        action.timeScale = 0\n        fadingOutRef.current = null\n      }\n    }\n    if (activeClipRef.current) {\n      const action = actions[activeClipRef.current]\n      if (action?.isRunning() && action.timeScale < 1) {\n        action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))\n        if (1 - action.timeScale < 0.01) action.timeScale = 1\n      }\n    }\n  })\n\n  return null\n}\n\nconst ItemLightRegistrar = ({\n  nodeId,\n  effect,\n  interactive,\n  index,\n}: {\n  nodeId: AnyNodeId\n  effect: LightEffect\n  interactive: Interactive\n  index: number\n}) => {\n  useEffect(() => {\n    const key = `${nodeId}:${index}`\n    useItemLightPool.getState().register(key, nodeId, effect, interactive)\n    return () => useItemLightPool.getState().unregister(key)\n  }, [nodeId, index, effect, interactive])\n\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/level/level-renderer.tsx",
    "content": "import { type LevelNode, useRegistry } from '@pascal-app/core'\nimport { useRef } from 'react'\nimport type { Group } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { NodeRenderer } from '../node-renderer'\n\nexport const LevelRenderer = ({ node }: { node: LevelNode }) => {\n  const ref = useRef<Group>(null!)\n\n  useRegistry(node.id, node.type, ref)\n  const handlers = useNodeEvents(node, 'level')\n\n  return (\n    <group ref={ref} {...handlers}>\n      {node.children.map((childId) => (\n        <NodeRenderer key={childId} nodeId={childId} />\n      ))}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/node-renderer.tsx",
    "content": "'use client'\n\nimport { type AnyNode, useScene } from '@pascal-app/core'\nimport { BuildingRenderer } from './building/building-renderer'\nimport { CeilingRenderer } from './ceiling/ceiling-renderer'\nimport { DoorRenderer } from './door/door-renderer'\nimport { GuideRenderer } from './guide/guide-renderer'\nimport { ItemRenderer } from './item/item-renderer'\nimport { LevelRenderer } from './level/level-renderer'\nimport { RoofRenderer } from './roof/roof-renderer'\nimport { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'\nimport { ScanRenderer } from './scan/scan-renderer'\nimport { SiteRenderer } from './site/site-renderer'\nimport { SlabRenderer } from './slab/slab-renderer'\nimport { StairRenderer } from './stair/stair-renderer'\nimport { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'\nimport { WallRenderer } from './wall/wall-renderer'\nimport { WindowRenderer } from './window/window-renderer'\nimport { ZoneRenderer } from './zone/zone-renderer'\n\nexport const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {\n  const node = useScene((state) => state.nodes[nodeId])\n\n  if (!node) return null\n\n  return (\n    <>\n      {node.type === 'site' && <SiteRenderer node={node} />}\n      {node.type === 'building' && <BuildingRenderer node={node} />}\n      {node.type === 'ceiling' && <CeilingRenderer node={node} />}\n      {node.type === 'level' && <LevelRenderer node={node} />}\n      {node.type === 'item' && <ItemRenderer node={node} />}\n      {node.type === 'slab' && <SlabRenderer node={node} />}\n      {node.type === 'wall' && <WallRenderer node={node} />}\n      {node.type === 'door' && <DoorRenderer node={node} />}\n      {node.type === 'window' && <WindowRenderer node={node} />}\n      {node.type === 'zone' && <ZoneRenderer node={node} />}\n      {node.type === 'roof' && <RoofRenderer node={node} />}\n      {node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}\n      {node.type === 'stair' && <StairRenderer node={node} />}\n      {node.type === 'stair-segment' && <StairSegmentRenderer node={node} />}\n      {node.type === 'scan' && <ScanRenderer node={node} />}\n      {node.type === 'guide' && <GuideRenderer node={node} />}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/roof/roof-materials.ts",
    "content": "import * as THREE from 'three'\n\n// Production materials — match the rest of the scene (white walls, light-gray slabs).\n// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle\nexport const roofMaterials: THREE.Material[] = [\n  new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim\n  new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck\n  new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior\n  new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle\n]\n\n// Debug materials — vivid, distinct colours to identify each surface group.\nexport const roofDebugMaterials: THREE.Material[] = [\n  new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall\n  new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck\n  new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior\n  new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle\n]\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/roof/roof-renderer.tsx",
    "content": "import { type RoofNode, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport type * as THREE from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial } from '../../../lib/materials'\nimport useViewer from '../../../store/use-viewer'\nimport { NodeRenderer } from '../node-renderer'\nimport { roofDebugMaterials, roofMaterials } from './roof-materials'\n\nexport const RoofRenderer = ({ node }: { node: RoofNode }) => {\n  const ref = useRef<THREE.Group>(null!)\n\n  useRegistry(node.id, 'roof', ref)\n\n  const handlers = useNodeEvents(node, 'roof')\n  const debugColors = useViewer((s) => s.debugColors)\n\n  const customMaterial = useMemo(() => {\n    const mat = node.material\n    if (!mat) return null\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials\n\n  return (\n    <group\n      position={node.position}\n      ref={ref}\n      rotation-y={node.rotation}\n      visible={node.visible}\n      {...handlers}\n    >\n      <mesh castShadow material={material} name=\"merged-roof\" receiveShadow>\n        <boxGeometry args={[0, 0, 0]} />\n      </mesh>\n      <group name=\"segments-wrapper\" visible={false}>\n        {(node.children ?? []).map((childId) => (\n          <NodeRenderer key={childId} nodeId={childId} />\n        ))}\n      </group>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx",
    "content": "import { type RoofSegmentNode, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport type * as THREE from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial } from '../../../lib/materials'\nimport useViewer from '../../../store/use-viewer'\nimport { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'\n\nexport const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {\n  const ref = useRef<THREE.Mesh>(null!)\n\n  useRegistry(node.id, 'roof-segment', ref)\n\n  const handlers = useNodeEvents(node, 'roof-segment')\n  const debugColors = useViewer((s) => s.debugColors)\n\n  const customMaterial = useMemo(() => {\n    const mat = node.material\n    if (!mat) return null\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials\n\n  return (\n    <mesh\n      material={material}\n      position={node.position}\n      ref={ref}\n      rotation-y={node.rotation}\n      visible={node.visible}\n      {...handlers}\n    >\n      {/* RoofSystem will replace this geometry in the next frame */}\n      <boxGeometry args={[0, 0, 0]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/scan/scan-renderer.tsx",
    "content": "import { type ScanNode, useRegistry } from '@pascal-app/core'\nimport { Suspense, useMemo, useRef } from 'react'\nimport type { Group, Material, Mesh } from 'three'\nimport { useAssetUrl } from '../../../hooks/use-asset-url'\nimport { useGLTFKTX2 } from '../../../hooks/use-gltf-ktx2'\nimport useViewer from '../../../store/use-viewer'\n\nexport const ScanRenderer = ({ node }: { node: ScanNode }) => {\n  const showScans = useViewer((s) => s.showScans)\n  const ref = useRef<Group>(null!)\n  useRegistry(node.id, 'scan', ref)\n\n  const resolvedUrl = useAssetUrl(node.url)\n\n  return (\n    <group\n      position={node.position}\n      ref={ref}\n      rotation={node.rotation}\n      scale={[node.scale, node.scale, node.scale]}\n      visible={showScans}\n    >\n      {resolvedUrl && (\n        <Suspense>\n          <ScanModel opacity={node.opacity} url={resolvedUrl} />\n        </Suspense>\n      )}\n    </group>\n  )\n}\n\nconst ScanModel = ({ url, opacity }: { url: string; opacity: number }) => {\n  const gltf = useGLTFKTX2(url) as any\n  const scene = gltf.scene\n\n  useMemo(() => {\n    const normalizedOpacity = opacity / 100\n    const isTransparent = normalizedOpacity < 1\n\n    const updateMaterial = (material: Material) => {\n      if (isTransparent) {\n        material.transparent = true\n        material.opacity = normalizedOpacity\n        material.depthWrite = false\n      } else {\n        material.transparent = false\n        material.opacity = 1\n        material.depthWrite = true\n      }\n      material.needsUpdate = true\n    }\n\n    scene.traverse((child: any) => {\n      if ((child as Mesh).isMesh) {\n        const mesh = child as Mesh\n\n        // Disable raycasting\n        mesh.raycast = () => {}\n\n        // Exclude from bounding box calculations\n        mesh.geometry.boundingBox = null\n        mesh.geometry.boundingSphere = null\n        mesh.frustumCulled = false\n\n        if (Array.isArray(mesh.material)) {\n          mesh.material.forEach((material) => {\n            updateMaterial(material)\n          })\n        } else {\n          updateMaterial(mesh.material)\n        }\n      }\n    })\n  }, [scene, opacity])\n\n  return <primitive object={scene} />\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/scene-renderer.tsx",
    "content": "'use client'\n\nimport { useScene } from '@pascal-app/core'\nimport { NodeRenderer } from './node-renderer'\n\nexport const SceneRenderer = () => {\n  const rootNodes = useScene((state) => state.rootNodeIds)\n\n  return (\n    <group name=\"scene-renderer\">\n      {rootNodes.map((nodeId) => (\n        <NodeRenderer key={nodeId} nodeId={nodeId} />\n      ))}\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/site/site-renderer.tsx",
    "content": "import { type SiteNode, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { NodeRenderer } from '../node-renderer'\n\nconst Y_OFFSET = 0.01\n\n/**\n * Creates simple line geometry for site boundary\n * Single horizontal line at ground level\n */\nconst createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => {\n  const geometry = new BufferGeometry()\n\n  if (points.length < 2) return geometry\n\n  const positions: number[] = []\n\n  // Create a simple line loop at ground level\n  for (const [x, z] of points) {\n    positions.push(x ?? 0, Y_OFFSET, z ?? 0)\n  }\n  // Close the loop\n  positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)\n\n  geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))\n\n  return geometry\n}\n\nexport const SiteRenderer = ({ node }: { node: SiteNode }) => {\n  const ref = useRef<Group>(null!)\n\n  useRegistry(node.id, 'site', ref)\n\n  // Create floor shape from polygon points\n  const floorShape = useMemo(() => {\n    if (!node?.polygon?.points || node.polygon.points.length < 3) return null\n    const shape = new Shape()\n    const firstPt = node.polygon.points[0]!\n\n    // Shape is in X-Y plane, we rotate it to X-Z plane\n    // Negate Y (which becomes Z) to get correct orientation\n    shape.moveTo(firstPt[0]!, -firstPt[1]!)\n\n    for (let i = 1; i < node.polygon.points.length; i++) {\n      const pt = node.polygon.points[i]!\n      shape.lineTo(pt[0]!, -pt[1]!)\n    }\n    shape.closePath()\n\n    return shape\n  }, [node?.polygon?.points])\n\n  // Create boundary line geometry\n  const lineGeometry = useMemo(() => {\n    if (!node?.polygon?.points || node.polygon.points.length < 2) return null\n    return createBoundaryLineGeometry(node.polygon.points)\n  }, [node?.polygon?.points])\n\n  const handlers = useNodeEvents(node, 'site')\n\n  if (!(node && floorShape && lineGeometry)) {\n    return null\n  }\n\n  return (\n    <group ref={ref} {...handlers}>\n      {/* Render children (buildings and items) */}\n      {node.children.map((child) => (\n        <NodeRenderer\n          key={typeof child === 'string' ? child : child.id}\n          nodeId={typeof child === 'string' ? child : child.id}\n        />\n      ))}\n\n      {/* Transparent floor fill */}\n      <mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>\n        <shapeGeometry args={[floorShape]} />\n        <shadowMaterial opacity={0.75} transparent />\n      </mesh>\n\n      {/* Simple boundary line */}\n      {/* @ts-ignore */}\n      <line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>\n        <lineBasicMaterial color=\"#f59e0b\" linewidth={2} opacity={0.6} transparent />\n      </line>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/slab/slab-renderer.tsx",
    "content": "import { type SlabNode, useRegistry } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport type { Mesh } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials'\n\nexport const SlabRenderer = ({ node }: { node: SlabNode }) => {\n  const ref = useRef<Mesh>(null!)\n\n  useRegistry(node.id, 'slab', ref)\n\n  const handlers = useNodeEvents(node, 'slab')\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_SLAB_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh\n      castShadow\n      receiveShadow\n      ref={ref}\n      {...handlers}\n      visible={node.visible}\n      material={material}\n    >\n      <boxGeometry args={[0, 0, 0]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/stair/stair-renderer.tsx",
    "content": "import { type StairNode, useRegistry, useScene } from '@pascal-app/core'\nimport { useLayoutEffect, useMemo, useRef } from 'react'\nimport type * as THREE from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'\nimport { NodeRenderer } from '../node-renderer'\n\nexport const StairRenderer = ({ node }: { node: StairNode }) => {\n  const ref = useRef<THREE.Group>(null!)\n\n  useRegistry(node.id, 'stair', ref)\n\n  useLayoutEffect(() => {\n    useScene.getState().markDirty(node.id)\n  }, [node.id])\n\n  const handlers = useNodeEvents(node, 'stair')\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_STAIR_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <group\n      position={node.position}\n      ref={ref}\n      rotation-y={node.rotation}\n      visible={node.visible}\n      {...handlers}\n    >\n      <mesh castShadow material={material} name=\"merged-stair\" receiveShadow>\n        <boxGeometry args={[0, 0, 0]} />\n      </mesh>\n      <group name=\"segments-wrapper\" visible={false}>\n        {(node.children ?? []).map((childId) => (\n          <NodeRenderer key={childId} nodeId={childId} />\n        ))}\n      </group>\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx",
    "content": "import { type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'\nimport { useLayoutEffect, useMemo, useRef } from 'react'\nimport type * as THREE from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'\n\nexport const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {\n  const ref = useRef<THREE.Mesh>(null!)\n\n  useRegistry(node.id, 'stair-segment', ref)\n\n  useLayoutEffect(() => {\n    useScene.getState().markDirty(node.id)\n  }, [node.id])\n\n  const handlers = useNodeEvents(node, 'stair-segment')\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_STAIR_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh\n      material={material}\n      position={node.position}\n      ref={ref}\n      rotation-y={node.rotation}\n      visible={node.visible}\n      {...handlers}\n    >\n      {/* StairSystem will replace this geometry in the next frame */}\n      <boxGeometry args={[0, 0, 0]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/wall/wall-renderer.tsx",
    "content": "import { useRegistry, useScene, type WallNode } from '@pascal-app/core'\nimport { useLayoutEffect, useMemo, useRef } from 'react'\nimport type { Mesh } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_WALL_MATERIAL } from '../../../lib/materials'\nimport { NodeRenderer } from '../node-renderer'\n\nexport const WallRenderer = ({ node }: { node: WallNode }) => {\n  const ref = useRef<Mesh>(null!)\n\n  useRegistry(node.id, 'wall', ref)\n\n  useLayoutEffect(() => {\n    useScene.getState().markDirty(node.id)\n  }, [node.id])\n\n  const handlers = useNodeEvents(node, 'wall')\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_WALL_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}>\n      <boxGeometry args={[0, 0, 0]} />\n      <mesh name=\"collision-mesh\" visible={false} {...handlers}>\n        <boxGeometry args={[0, 0, 0]} />\n      </mesh>\n\n      {node.children.map((childId) => (\n        <NodeRenderer key={childId} nodeId={childId} />\n      ))}\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/window/window-renderer.tsx",
    "content": "import { useRegistry, type WindowNode } from '@pascal-app/core'\nimport { useMemo, useRef } from 'react'\nimport type { Mesh } from 'three'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials'\n\nexport const WindowRenderer = ({ node }: { node: WindowNode }) => {\n  const ref = useRef<Mesh>(null!)\n\n  useRegistry(node.id, 'window', ref)\n  const handlers = useNodeEvents(node, 'window')\n  const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient\n\n  const material = useMemo(() => {\n    const mat = node.material\n    if (!mat) return DEFAULT_WINDOW_MATERIAL\n    return createMaterial(mat)\n  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])\n\n  return (\n    <mesh\n      castShadow\n      material={material}\n      position={node.position}\n      receiveShadow\n      ref={ref}\n      rotation={node.rotation}\n      visible={node.visible}\n      {...(isTransient ? {} : handlers)}\n    >\n      <boxGeometry args={[0, 0, 0]} />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/renderers/zone/zone-renderer.tsx",
    "content": "import { useRegistry, type ZoneNode } from '@pascal-app/core'\nimport { Html } from '@react-three/drei'\nimport { useMemo, useRef } from 'react'\nimport { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'\nimport { color, float, uniform, uv } from 'three/tsl'\nimport { MeshBasicNodeMaterial } from 'three/webgpu'\nimport { useNodeEvents } from '../../../hooks/use-node-events'\nimport { ZONE_LAYER } from '../../../lib/layers'\n\nconst Y_OFFSET = 0.01\nconst WALL_HEIGHT = 2.3\n\n/**\n * Creates a gradient material for zone walls using TSL\n * Gradient goes from zone color at bottom to transparent at top\n */\nconst createWallGradientMaterial = (zoneColor: string) => {\n  const baseColor = color(new Color(zoneColor))\n\n  // Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)\n  const gradientT = uv().y\n\n  const opacity = uniform(0)\n  // Fade opacity from 0.6 at bottom to 0 at top\n  const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity)\n\n  return new MeshBasicNodeMaterial({\n    transparent: true,\n    colorNode: baseColor,\n    opacityNode: finalOpacity,\n    side: DoubleSide,\n    depthWrite: true,\n    depthTest: false,\n    userData: {\n      uOpacity: opacity,\n    },\n  })\n}\n\n/**\n * Creates a floor material for zones using TSL\n */\nconst createFloorMaterial = (zoneColor: string) => {\n  const baseColor = color(new Color(zoneColor))\n  const opacity = uniform(0)\n  return new MeshBasicNodeMaterial({\n    transparent: true,\n    colorNode: baseColor,\n    opacityNode: float(0.25).mul(opacity),\n    side: DoubleSide,\n    depthWrite: false,\n    depthTest: false,\n    userData: { uOpacity: opacity },\n  })\n}\n\n/**\n * Creates wall geometry for zone borders\n * Each wall segment is a vertical quad from one polygon point to the next\n */\nconst createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry => {\n  const geometry = new BufferGeometry()\n\n  if (polygon.length < 2) return geometry\n\n  const positions: number[] = []\n  const uvs: number[] = []\n  const indices: number[] = []\n\n  // Create a wall segment for each edge of the polygon\n  for (let i = 0; i < polygon.length; i++) {\n    const current = polygon[i]!\n    const next = polygon[(i + 1) % polygon.length]!\n\n    const baseIndex = i * 4\n\n    // Four vertices per wall segment (two triangles forming a quad)\n    // Bottom-left\n    positions.push(current[0]!, Y_OFFSET, current[1]!)\n    uvs.push(0, 0)\n\n    // Bottom-right\n    positions.push(next[0]!, Y_OFFSET, next[1]!)\n    uvs.push(1, 0)\n\n    // Top-right\n    positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!)\n    uvs.push(1, 1)\n\n    // Top-left\n    positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!)\n    uvs.push(0, 1)\n\n    // Two triangles for the quad\n    indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3)\n  }\n\n  geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))\n  geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))\n  geometry.setIndex(indices)\n  geometry.computeVertexNormals()\n\n  return geometry\n}\n\nexport const ZoneRenderer = ({ node }: { node: ZoneNode }) => {\n  const ref = useRef<Group>(null!)\n\n  useRegistry(node.id, 'zone', ref)\n\n  // Create floor shape from polygon\n  const floorShape = useMemo(() => {\n    if (!node?.polygon || node.polygon.length < 3) return null\n    const shape = new Shape()\n    const firstPt = node.polygon[0]!\n\n    // Shape is in X-Y plane, we rotate it to X-Z plane\n    // Negate Y (which becomes Z) to get correct orientation\n    shape.moveTo(firstPt[0]!, -firstPt[1]!)\n\n    for (let i = 1; i < node.polygon.length; i++) {\n      const pt = node.polygon[i]!\n      shape.lineTo(pt[0]!, -pt[1]!)\n    }\n    shape.closePath()\n\n    return shape\n  }, [node?.polygon])\n\n  // Create wall geometry from polygon\n  const wallGeometry = useMemo(() => {\n    if (!node?.polygon || node.polygon.length < 2) return null\n    return createWallGeometry(node.polygon)\n  }, [node?.polygon])\n\n  // Calculate polygon centroid for label positioning using the geometric centroid formula\n  // This correctly handles polygons regardless of vertex distribution along edges\n  const centroid = useMemo(() => {\n    if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number]\n\n    const polygon = node.polygon\n    let signedArea = 0\n    let cx = 0\n    let cz = 0\n\n    for (let i = 0; i < polygon.length; i++) {\n      const [x0, z0] = polygon[i]!\n      const [x1, z1] = polygon[(i + 1) % polygon.length]!\n\n      // Cross product for signed area\n      const cross = x0 * z1 - x1 * z0\n      signedArea += cross\n      cx += (x0 + x1) * cross\n      cz += (z0 + z1) * cross\n    }\n\n    signedArea /= 2\n    const factor = 1 / (6 * signedArea)\n\n    return [cx * factor, cz * factor] as [number, number]\n  }, [node?.polygon])\n\n  // Create materials\n  const floorMaterial = useMemo(() => {\n    if (!node?.color) return null\n    return createFloorMaterial(node.color)\n  }, [node?.color])\n\n  const wallMaterial = useMemo(() => {\n    if (!node?.color) return null\n    return createWallGradientMaterial(node.color)\n  }, [node?.color])\n\n  const handlers = useNodeEvents(node, 'zone')\n\n  if (!(node && floorShape && wallGeometry && floorMaterial && wallMaterial)) {\n    return null\n  }\n\n  return (\n    <group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>\n      <Html\n        name=\"label\"\n        position={[centroid[0], 1, centroid[1]]}\n        style={{ pointerEvents: 'none' }}\n        zIndexRange={[10, 0]}\n      >\n        <div\n          id={`${node.id}-label`}\n          style={{\n            display: 'flex',\n            flexDirection: 'column',\n            alignItems: 'center',\n            transform: 'translate3d(-50%, -50%, 0)',\n            opacity: 0,\n            transition: 'opacity 0.3s ease-in-out',\n          }}\n        >\n          <div\n            style={{\n              width: 'max-content',\n              color: 'white',\n              textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,\n              textAlign: 'center',\n            }}\n          >\n            <span>{node.name}</span>\n          </div>\n          <div\n            className=\"label-pin\"\n            style={{\n              display: 'flex',\n              flexDirection: 'column',\n              alignItems: 'center',\n              marginTop: '2px',\n              opacity: 0,\n              transition: 'opacity 0.5s ease-in-out',\n            }}\n          >\n            <div\n              style={{\n                width: '2px',\n                height: '40px',\n                backgroundColor: node.color,\n              }}\n            />\n            <div\n              style={{\n                width: '10px',\n                height: '10px',\n                borderRadius: '50%',\n                backgroundColor: node.color,\n                border: '1px solid white',\n              }}\n            />\n          </div>\n        </div>\n      </Html>\n\n      {/* Floor fill */}\n      <mesh\n        layers={ZONE_LAYER}\n        material={floorMaterial}\n        name=\"floor\"\n        position={[0, Y_OFFSET, 0]}\n        rotation={[-Math.PI / 2, 0, 0]}\n      >\n        <shapeGeometry args={[floorShape]} />\n      </mesh>\n\n      {/* Wall borders with gradient */}\n      <mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name=\"walls\" />\n    </group>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/ground-occluder.tsx",
    "content": "import { type LevelNode, useScene } from '@pascal-app/core'\nimport polygonClipping from 'polygon-clipping'\nimport { useMemo } from 'react'\nimport * as THREE from 'three'\nimport useViewer from '../../store/use-viewer'\n\nexport const GroundOccluder = () => {\n  const theme = useViewer((state) => state.theme)\n  const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'\n\n  const nodes = useScene((state) => state.nodes)\n\n  const shape = useMemo(() => {\n    const s = new THREE.Shape()\n    const size = 1000\n    // Create outer infinite plane\n    s.moveTo(-size, -size)\n    s.lineTo(size, -size)\n    s.lineTo(size, size)\n    s.lineTo(-size, size)\n    s.closePath()\n\n    const levelIndexById = new Map<LevelNode['id'], number>()\n    let lowestLevelIndex = Number.POSITIVE_INFINITY\n\n    Object.values(nodes).forEach((node) => {\n      if (node.type !== 'level') {\n        return\n      }\n\n      levelIndexById.set(node.id, node.level)\n      lowestLevelIndex = Math.min(lowestLevelIndex, node.level)\n    })\n\n    // Only the lowest level should punch through the ground plane.\n    // Upper-level slabs should still cast shadows, but they should not\n    // reveal their footprint on the level-zero ground material.\n    const polygons: [number, number][][] = []\n\n    Object.values(nodes).forEach((node) => {\n      if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) {\n        return\n      }\n\n      if (Number.isFinite(lowestLevelIndex)) {\n        const parentLevelIndex = node.parentId\n          ? levelIndexById.get(node.parentId as LevelNode['id'])\n          : undefined\n\n        if (parentLevelIndex !== lowestLevelIndex) {\n          return\n        }\n      }\n\n      polygons.push(node.polygon as [number, number][])\n    })\n\n    if (polygons.length > 0) {\n      // Format for polygon-clipping: [[[x, y], [x, y], ...]]\n      const multiPolygons = polygons.map((pts) => {\n        const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)\n        return [ring]\n      })\n\n      // Union all polygons together to prevent artifacts from overlapping\n      const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))\n\n      // Add each resulting unioned polygon as a hole\n      for (const geom of unionedPolygons) {\n        // First ring in each geometry is the exterior ring\n        if (geom.length > 0) {\n          const ring = geom[0]!\n          const hole = new THREE.Path()\n\n          if (ring.length > 0) {\n            hole.moveTo(ring[0]![0], ring[0]![1])\n            for (let i = 1; i < ring.length; i++) {\n              hole.lineTo(ring[i]![0], ring[i]![1])\n            }\n            hole.closePath()\n            s.holes.push(hole)\n          }\n        }\n      }\n    }\n\n    return s\n  }, [nodes])\n\n  return (\n    <mesh position-y={-0.05} rotation-x={-Math.PI / 2}>\n      <shapeGeometry args={[shape]} />\n      <meshBasicMaterial\n        color={bgColor}\n        depthWrite={true}\n        polygonOffset={true}\n        polygonOffsetFactor={1}\n        polygonOffsetUnits={1}\n      />\n    </mesh>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/index.tsx",
    "content": "'use client'\n\nimport {\n  CeilingSystem,\n  DoorSystem,\n  ItemSystem,\n  RoofSystem,\n  SlabSystem,\n  StairSystem,\n  WallSystem,\n  WindowSystem,\n} from '@pascal-app/core'\nimport { Bvh } from '@react-three/drei'\nimport { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'\nimport { useEffect, useMemo, useRef } from 'react'\nimport * as THREE from 'three/webgpu'\nimport useViewer from '../../store/use-viewer'\nimport { ExportSystem } from '../../systems/export/export-system'\nimport { GuideSystem } from '../../systems/guide/guide-system'\nimport { ItemLightSystem } from '../../systems/item-light/item-light-system'\nimport { LevelSystem } from '../../systems/level/level-system'\nimport { ScanSystem } from '../../systems/scan/scan-system'\nimport { WallCutout } from '../../systems/wall/wall-cutout'\nimport { ZoneSystem } from '../../systems/zone/zone-system'\nimport { SceneRenderer } from '../renderers/scene-renderer'\nimport { Lights } from './lights'\nimport { PerfMonitor } from './perf-monitor'\nimport PostProcessing from './post-processing'\nimport { SelectionManager } from './selection-manager'\nimport { ViewerCamera } from './viewer-camera'\n\nfunction AnimatedBackground({ isDark }: { isDark: boolean }) {\n  const targetColor = useMemo(() => new THREE.Color(), [])\n  const initialized = useRef(false)\n\n  useFrame(({ scene }, delta) => {\n    const dt = Math.min(delta, 0.1) * 4\n    const targetHex = isDark ? '#1f2433' : '#ffffff'\n\n    if (!(scene.background && scene.background instanceof THREE.Color)) {\n      scene.background = new THREE.Color(targetHex)\n      initialized.current = true\n      return\n    }\n\n    if (!initialized.current) {\n      scene.background.set(targetHex)\n      initialized.current = true\n      return\n    }\n\n    targetColor.set(targetHex)\n    scene.background.lerp(targetColor, dt)\n  })\n\n  return null\n}\n\ndeclare module '@react-three/fiber' {\n  interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}\n}\n\nextend(THREE as any)\n\n/**\n * Monitors the WebGPU device for loss events and logs them.\n * WebGPU device loss can happen when:\n *  - Tab is backgrounded and OS reclaims GPU\n *  - Driver crash or GPU reset\n *  - Browser security policy kills the context\n */\nfunction GPUDeviceWatcher() {\n  const gl = useThree((s) => s.gl)\n\n  useEffect(() => {\n    const backend = (gl as any).backend\n    const device: GPUDevice | undefined = backend?.device\n\n    if (!device) return\n\n    device.lost.then((info) => {\n      console.error(\n        `[viewer] WebGPU device lost: reason=\"${info.reason}\", message=\"${info.message}\". ` +\n          'The page must be reloaded to recover the GPU context.',\n      )\n    })\n  }, [gl])\n\n  return null\n}\n\ninterface ViewerProps {\n  children?: React.ReactNode\n  selectionManager?: 'default' | 'custom'\n  perf?: boolean\n}\n\nconst Viewer: React.FC<ViewerProps> = ({\n  children,\n  selectionManager = 'default',\n  perf = false,\n}) => {\n  const theme = useViewer((state) => state.theme)\n\n  return (\n    <Canvas\n      camera={{ position: [50, 50, 50], fov: 50 }}\n      className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}\n      dpr={[1, 1.5]}\n      gl={(props) => {\n        const renderer = new THREE.WebGPURenderer(props as any)\n        renderer.toneMapping = THREE.ACESFilmicToneMapping\n        renderer.toneMappingExposure = 0.9\n        // renderer.init() // Only use when using <DebugRenderer />\n        return renderer\n      }}\n      resize={{\n        debounce: 100,\n      }}\n      shadows={{\n        type: THREE.PCFShadowMap,\n        enabled: true,\n      }}\n    >\n      {/* <AnimatedBackground isDark={theme === 'dark'} /> */}\n      <ViewerCamera />\n\n      {/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow\n        /> */}\n      <Lights />\n      <Bvh>\n        <SceneRenderer />\n      </Bvh>\n\n      {/* Default Systems */}\n      <LevelSystem />\n      <GuideSystem />\n      <ScanSystem />\n      <WallCutout />\n      {/* Core systems */}\n      <CeilingSystem />\n      <DoorSystem />\n      <ItemSystem />\n      <RoofSystem />\n      <SlabSystem />\n      <StairSystem />\n      <WallSystem />\n      <WindowSystem />\n      <ZoneSystem />\n      <ExportSystem />\n      <PostProcessing />\n      {/* <DebugRenderer /> */}\n      <GPUDeviceWatcher />\n\n      <ItemLightSystem />\n      {selectionManager === 'default' && <SelectionManager />}\n      {perf && <PerfMonitor />}\n      {children}\n    </Canvas>\n  )\n}\n\nconst DebugRenderer = () => {\n  useFrame(({ gl, scene, camera }) => {\n    gl.render(scene, camera)\n  })\n  return null\n}\n\nexport default Viewer\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/lights.tsx",
    "content": "import { useFrame } from '@react-three/fiber'\nimport { useMemo, useRef } from 'react'\nimport type { AmbientLight, DirectionalLight, OrthographicCamera } from 'three/webgpu'\nimport * as THREE from 'three/webgpu'\nimport useViewer from '../../store/use-viewer'\n\nexport function Lights() {\n  const theme = useViewer((state) => state.theme)\n  const isDark = theme === 'dark'\n\n  const light1Ref = useRef<DirectionalLight>(null)\n  const shadowCamera = useRef<OrthographicCamera>(null)\n  const shadowCameraSize = 50 // The \"area\" around the camera to shadow\n\n  const light2Ref = useRef<DirectionalLight>(null)\n  const light3Ref = useRef<DirectionalLight>(null)\n  const ambientRef = useRef<AmbientLight>(null)\n\n  const initialized = useRef(false)\n\n  const targets = useMemo(\n    () => ({\n      l1Color: new THREE.Color(),\n      l2Color: new THREE.Color(),\n      l3Color: new THREE.Color(),\n      ambColor: new THREE.Color(),\n    }),\n    [],\n  )\n\n  useFrame((_, delta) => {\n    // clamp delta to avoid huge jumps on tab switch\n    const dt = Math.min(delta, 0.1) * 4\n\n    if (!initialized.current) {\n      if (light1Ref.current) {\n        light1Ref.current.intensity = isDark ? 0.8 : 4\n        light1Ref.current.color.set(isDark ? '#e0e5ff' : '#ffffff')\n\n        if (light1Ref.current.shadow) light1Ref.current.shadow.intensity = isDark ? 0.8 : 0.4\n      }\n      if (light2Ref.current) {\n        light2Ref.current.intensity = isDark ? 0.2 : 0.75\n        light2Ref.current.color.set(isDark ? '#8090ff' : '#ffffff')\n      }\n      if (light3Ref.current) {\n        light3Ref.current.intensity = isDark ? 0.3 : 1\n        light3Ref.current.color.set(isDark ? '#a0b0ff' : '#ffffff')\n      }\n      if (ambientRef.current) {\n        ambientRef.current.intensity = isDark ? 0.15 : 0.5\n        ambientRef.current.color.set(isDark ? '#a0b0ff' : '#ffffff')\n      }\n      initialized.current = true\n      return\n    }\n\n    if (light1Ref.current) {\n      light1Ref.current.intensity = THREE.MathUtils.lerp(\n        light1Ref.current.intensity,\n        isDark ? 0.8 : 4,\n        dt,\n      )\n      targets.l1Color.set(isDark ? '#e0e5ff' : '#ffffff')\n      light1Ref.current.color.lerp(targets.l1Color, dt)\n\n      if (light1Ref.current.shadow) {\n        if (light1Ref.current.shadow.intensity !== undefined) {\n          light1Ref.current.shadow.intensity = THREE.MathUtils.lerp(\n            light1Ref.current.shadow.intensity,\n            isDark ? 0.8 : 0.4,\n            dt,\n          )\n        }\n      }\n    }\n\n    if (light2Ref.current) {\n      light2Ref.current.intensity = THREE.MathUtils.lerp(\n        light2Ref.current.intensity,\n        isDark ? 0.2 : 0.75,\n        dt,\n      )\n      targets.l2Color.set(isDark ? '#8090ff' : '#ffffff')\n      light2Ref.current.color.lerp(targets.l2Color, dt)\n    }\n\n    if (light3Ref.current) {\n      light3Ref.current.intensity = THREE.MathUtils.lerp(\n        light3Ref.current.intensity,\n        isDark ? 0.3 : 1,\n        dt,\n      )\n      targets.l3Color.set(isDark ? '#a0b0ff' : '#ffffff')\n      light3Ref.current.color.lerp(targets.l3Color, dt)\n    }\n\n    if (ambientRef.current) {\n      ambientRef.current.intensity = THREE.MathUtils.lerp(\n        ambientRef.current.intensity,\n        isDark ? 0.15 : 0.5,\n        dt,\n      )\n      targets.ambColor.set(isDark ? '#a0b0ff' : '#ffffff')\n      ambientRef.current.color.lerp(targets.ambColor, dt)\n    }\n  })\n\n  return (\n    <>\n      <directionalLight\n        castShadow\n        position={[10, 10, 10]}\n        ref={light1Ref}\n        shadow-bias={-0.002}\n        shadow-mapSize={[1024, 1024]}\n        shadow-normalBias={0.3}\n        shadow-radius={3}\n      >\n        <orthographicCamera\n          attach=\"shadow-camera\"\n          bottom={-shadowCameraSize}\n          far={100}\n          left={-shadowCameraSize}\n          near={1}\n          ref={shadowCamera}\n          right={shadowCameraSize}\n          top={shadowCameraSize}\n        />\n      </directionalLight>\n\n      <directionalLight position={[-10, 10, -10]} ref={light2Ref} />\n\n      <directionalLight position={[-10, 10, 10]} ref={light3Ref} />\n\n      <ambientLight ref={ambientRef} />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/perf-monitor.tsx",
    "content": "import { useScene } from '@pascal-app/core'\nimport { Html } from '@react-three/drei'\nimport { useFrame } from '@react-three/fiber'\nimport { useRef, useState } from 'react'\n\nconst SAMPLE_INTERVAL = 0.5 // seconds between display updates\n\nexport const PerfMonitor = () => {\n  const [stats, setStats] = useState({ fps: 0, frameMs: 0, drawCalls: 0, triangles: 0, dirty: 0 })\n  const frameCount = useRef(0)\n  const elapsed = useRef(0)\n  const lastMs = useRef(0)\n\n  useFrame(({ gl, clock }) => {\n    frameCount.current++\n    const now = clock.elapsedTime\n    const dt = now - elapsed.current\n\n    if (dt >= SAMPLE_INTERVAL) {\n      const fps = Math.round(frameCount.current / dt)\n      const frameMs = lastMs.current\n      const info = gl.info\n      const drawCalls = info.render?.calls ?? 0\n      const triangles = info.render?.triangles ?? 0\n      const dirty = useScene.getState().dirtyNodes.size\n\n      setStats({ fps, frameMs, drawCalls, triangles, dirty })\n      frameCount.current = 0\n      elapsed.current = now\n    }\n\n    lastMs.current = Math.round(clock.getDelta() * 1000 * 10) / 10\n  })\n\n  return (\n    <Html\n      position={[0, 0, 0]}\n      style={{ position: 'fixed', top: 8, left: 8, pointerEvents: 'none' }}\n      zIndexRange={[100, 100]}\n    >\n      <div\n        style={{\n          fontFamily: 'monospace',\n          fontSize: 11,\n          lineHeight: 1.5,\n          color: stats.fps < 30 ? '#f87171' : stats.fps < 55 ? '#fbbf24' : '#4ade80',\n          background: 'rgba(0,0,0,0.7)',\n          borderRadius: 6,\n          padding: '6px 10px',\n          whiteSpace: 'pre',\n        }}\n      >\n        {`FPS  ${stats.fps}\nDRAW ${stats.drawCalls}\nTRI  ${(stats.triangles / 1000).toFixed(1)}k\nDIRTY ${stats.dirty}`}\n      </div>\n    </Html>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/post-processing.tsx",
    "content": "import { useFrame, useThree } from '@react-three/fiber'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Color, Layers, UnsignedByteType } from 'three'\nimport { outline } from 'three/addons/tsl/display/OutlineNode.js'\nimport { ssgi } from 'three/addons/tsl/display/SSGINode.js'\nimport { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'\nimport {\n  add,\n  colorToDirection,\n  diffuseColor,\n  directionToColor,\n  float,\n  mix,\n  mrt,\n  normalView,\n  oscSine,\n  output,\n  pass,\n  sample,\n  time,\n  uniform,\n  vec4,\n} from 'three/tsl'\nimport { RenderPipeline, type WebGPURenderer } from 'three/webgpu'\nimport { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'\nimport useViewer from '../../store/use-viewer'\n\n// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion\nexport const SSGI_PARAMS = {\n  enabled: true,\n  sliceCount: 1,\n  stepCount: 4,\n  radius: 1,\n  expFactor: 1.5,\n  thickness: 0.5,\n  backfaceLighting: 0.5,\n  aoIntensity: 1.5,\n  giIntensity: 0,\n  useLinearThickness: false,\n  useScreenSpaceSampling: true,\n  useTemporalFiltering: false,\n}\n\nconst MAX_PIPELINE_RETRIES = 3\nconst RETRY_DELAY_MS = 500\n\nconst DARK_BG = '#1f2433'\nconst LIGHT_BG = '#ffffff'\n\nconst PostProcessingPasses = () => {\n  const { gl: renderer, scene, camera } = useThree()\n  const renderPipelineRef = useRef<RenderPipeline | null>(null)\n  const hasPipelineErrorRef = useRef(false)\n  const retryCountRef = useRef(0)\n  const [isInitialized, setIsInitialized] = useState(false)\n\n  // Background color uniform — updated every frame via lerp, read by the TSL pipeline.\n  // Initialised from the current theme so there's no flash on first render.\n  const initBg = useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG\n  const bgUniform = useRef(uniform(new Color(initBg)))\n  const bgCurrent = useRef(new Color(initBg))\n  const bgTarget = useRef(new Color())\n\n  const zoneLayers = useMemo(() => {\n    const l = new Layers()\n    l.enable(ZONE_LAYER)\n    l.disable(SCENE_LAYER)\n    return l\n  }, [])\n\n  // Subscribe to projectId so the pipeline rebuilds on project switch\n  const projectId = useViewer((s) => s.projectId)\n\n  // Bump this to force a pipeline rebuild (used by retry logic)\n  const [pipelineVersion, setPipelineVersion] = useState(0)\n\n  const requestPipelineRebuild = useCallback(() => {\n    setPipelineVersion((v) => v + 1)\n  }, [])\n\n  // Renderer initialization\n\n  useEffect(() => {\n    let mounted = true\n\n    const initRenderer = async () => {\n      try {\n        if (renderer && (renderer as any).init) {\n          await (renderer as any).init()\n        }\n\n        if (mounted) {\n          setIsInitialized(true)\n        }\n      } catch (error) {\n        console.error('[viewer] Failed to initialize renderer for post-processing.', error)\n        if (mounted) {\n          setIsInitialized(false)\n        }\n      }\n    }\n\n    initRenderer()\n\n    return () => {\n      mounted = false\n    }\n  }, [renderer])\n\n  // Reset retry count when project changes\n  useEffect(() => {\n    retryCountRef.current = 0\n  }, [])\n\n  // Build / rebuild the post-processing pipeline\n  useEffect(() => {\n    if (!(renderer && scene && camera && isInitialized)) {\n      return\n    }\n\n    hasPipelineErrorRef.current = false\n\n    // Clear outliner arrays synchronously to prevent stale Object3D refs\n    // from the previous project leaking into the new pipeline's outline passes.\n    const outliner = useViewer.getState().outliner\n    outliner.selectedObjects.length = 0\n    outliner.hoveredObjects.length = 0\n\n    try {\n      const scenePass = pass(scene, camera)\n      const zonePass = pass(scene, camera)\n      zonePass.setLayers(zoneLayers)\n\n      const scenePassColor = scenePass.getTextureNode('output')\n\n      // Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame),\n      // so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1.\n      // WebGPU only applies clearColorValue to MRT attachment 0 (output), so scenePassColor.a\n      // is the reliable geometry mask — no normals, no flicker.\n      const hasGeometry = scenePassColor.a\n      const contentAlpha = hasGeometry.max(zonePass.a)\n\n      let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>\n\n      if (SSGI_PARAMS.enabled) {\n        // MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)\n        scenePass.setMRT(\n          mrt({\n            output,\n            diffuseColor,\n            normal: directionToColor(normalView),\n          }),\n        )\n\n        const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')\n        const scenePassDepth = scenePass.getTextureNode('depth')\n        const scenePassNormal = scenePass.getTextureNode('normal')\n\n        // Optimize texture bandwidth\n        const diffuseTexture = scenePass.getTexture('diffuseColor')\n        diffuseTexture.type = UnsignedByteType\n        const normalTexture = scenePass.getTexture('normal')\n        normalTexture.type = UnsignedByteType\n\n        // Extract normal from color-encoded texture\n        const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))\n\n        const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)\n        giPass.sliceCount.value = SSGI_PARAMS.sliceCount\n        giPass.stepCount.value = SSGI_PARAMS.stepCount\n        giPass.radius.value = SSGI_PARAMS.radius\n        giPass.expFactor.value = SSGI_PARAMS.expFactor\n        giPass.thickness.value = SSGI_PARAMS.thickness\n        giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting\n        giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity\n        giPass.giIntensity.value = SSGI_PARAMS.giIntensity\n        giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness\n        giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling\n        giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering\n\n        const giTexture = (giPass as any).getTextureNode()\n\n        // DenoiseNode only denoises RGB — alpha is passed through unchanged.\n        // SSGI packs AO into alpha, so we remap it into RGB before denoising.\n        const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))\n        const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)\n        denoisePass.index.value = 0\n        denoisePass.radius.value = 4\n\n        const gi = giPass.rgb\n        const ao = (denoisePass as any).r\n\n        // Composite: scene * AO + diffuse * GI\n        sceneColor = vec4(\n          add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))),\n          contentAlpha,\n        )\n      }\n\n      function generateSelectedOutlinePass() {\n        const edgeStrength = uniform(3)\n        const edgeGlow = uniform(0)\n        const edgeThickness = uniform(1)\n        const visibleEdgeColor = uniform(new Color(0xff_ff_ff))\n        const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))\n\n        const outlinePass = outline(scene, camera, {\n          selectedObjects: useViewer.getState().outliner.selectedObjects,\n          edgeGlow,\n          edgeThickness,\n        })\n        const { visibleEdge, hiddenEdge } = outlinePass\n\n        const outlineColor = visibleEdge\n          .mul(visibleEdgeColor)\n          .add(hiddenEdge.mul(hiddenEdgeColor))\n          .mul(edgeStrength)\n\n        return outlineColor\n      }\n\n      function generateHoverOutlinePass() {\n        const edgeStrength = uniform(5)\n        const edgeGlow = uniform(0.5)\n        const edgeThickness = uniform(1.5)\n        const pulsePeriod = uniform(3)\n        const visibleEdgeColor = uniform(new Color(0x00_aa_ff))\n        const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))\n\n        const outlinePass = outline(scene, camera, {\n          selectedObjects: useViewer.getState().outliner.hoveredObjects,\n          edgeGlow,\n          edgeThickness,\n        })\n        const { visibleEdge, hiddenEdge } = outlinePass\n\n        const period = time.div(pulsePeriod).mul(2)\n        const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]\n\n        const outlineColor = visibleEdge\n          .mul(visibleEdgeColor)\n          .add(hiddenEdge.mul(hiddenEdgeColor))\n          .mul(edgeStrength)\n        const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)\n\n        return outlinePulse\n      }\n\n      const selectedOutlinePass = generateSelectedOutlinePass()\n      const hoverOutlinePass = generateHoverOutlinePass()\n\n      const compositeWithOutlines = vec4(\n        add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),\n        sceneColor.a,\n      )\n\n      const finalOutput = vec4(\n        mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),\n        float(1),\n      )\n\n      const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)\n      renderPipeline.outputNode = finalOutput\n      renderPipelineRef.current = renderPipeline\n    } catch (error) {\n      hasPipelineErrorRef.current = true\n      console.error(\n        '[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',\n        error,\n      )\n      if (renderPipelineRef.current) {\n        renderPipelineRef.current.dispose()\n      }\n      renderPipelineRef.current = null\n    }\n\n    return () => {\n      if (renderPipelineRef.current) {\n        renderPipelineRef.current.dispose()\n      }\n      renderPipelineRef.current = null\n    }\n  }, [renderer, scene, camera, isInitialized, zoneLayers])\n\n  useFrame((_, delta) => {\n    // Animate background colour toward the current theme target (same lerp as AnimatedBackground)\n    bgTarget.current.set(useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG)\n    bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)\n    bgUniform.current.value.copy(bgCurrent.current)\n\n    if (hasPipelineErrorRef.current || !renderPipelineRef.current) {\n      return\n    }\n\n    try {\n      // Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0,\n      // making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node).\n      ;(renderer as any).setClearAlpha(0)\n      renderPipelineRef.current.render()\n    } catch (error) {\n      hasPipelineErrorRef.current = true\n      console.error('[viewer] Post-processing render pass failed.', error)\n      if (renderPipelineRef.current) {\n        renderPipelineRef.current.dispose()\n      }\n      renderPipelineRef.current = null\n\n      if (retryCountRef.current < MAX_PIPELINE_RETRIES) {\n        // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit\n        retryCountRef.current++\n        console.warn(\n          `[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,\n        )\n        setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)\n      } else {\n        console.error(\n          '[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',\n        )\n      }\n    }\n  }, 1)\n\n  return null\n}\n\nexport default PostProcessingPasses\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/selection-manager.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNode,\n  type AnyNodeId,\n  type BuildingNode,\n  emitter,\n  type ItemNode,\n  type LevelNode,\n  type NodeEvent,\n  pointInPolygon,\n  sceneRegistry,\n  useScene,\n  type WallNode,\n  type ZoneNode,\n} from '@pascal-app/core'\nimport { useThree } from '@react-three/fiber'\nimport { useEffect, useRef } from 'react'\nimport { Vector3 } from 'three'\nimport useViewer from '../../store/use-viewer'\n\nconst tempWorldPos = new Vector3()\n\n// Tolerance for edge detection (in meters)\nconst EDGE_TOLERANCE = 0.5\n\ntype SelectableNodeType =\n  | 'building'\n  | 'level'\n  | 'zone'\n  | 'wall'\n  | 'window'\n  | 'door'\n  | 'item'\n  | 'slab'\n  | 'ceiling'\n  | 'roof'\n  | 'roof-segment'\n\n// Expand polygon outward by a small amount to include items on edges\nconst expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {\n  if (polygon.length < 3) return polygon\n\n  // Calculate centroid\n  let cx = 0,\n    cz = 0\n  for (const [x, z] of polygon) {\n    cx += x\n    cz += z\n  }\n  cx /= polygon.length\n  cz /= polygon.length\n\n  // Expand each point outward from centroid\n  return polygon.map(([x, z]) => {\n    const dx = x - cx\n    const dz = z - cz\n    const len = Math.sqrt(dx * dx + dz * dz)\n    if (len === 0) return [x, z] as [number, number]\n    const scale = (len + tolerance) / len\n    return [cx + dx * scale, cz + dz * scale] as [number, number]\n  })\n}\n\n// Check if point is in polygon with tolerance for edges\nconst pointInPolygonWithTolerance = (\n  x: number,\n  z: number,\n  polygon: [number, number][],\n): boolean => {\n  // First try exact check\n  if (pointInPolygon(x, z, polygon)) return true\n  // Then try with expanded polygon for edge tolerance\n  const expanded = expandPolygon(polygon, EDGE_TOLERANCE)\n  return pointInPolygon(x, z, expanded)\n}\n\ninterface SelectionStrategy {\n  types: SelectableNodeType[]\n  handleClick: (node: AnyNode, nativeEvent?: MouseEvent) => void\n  handleDeselect: () => void\n  isValid: (node: AnyNode) => boolean\n}\n\n// Check if a node belongs to the selected level (directly or via wall parent)\nconst isNodeOnLevel = (node: AnyNode, levelId: string): boolean => {\n  const nodes = useScene.getState().nodes\n\n  // Direct child of level\n  if (node.parentId === levelId) return true\n\n  // Wall-attached nodes (window/door/item): check if parent wall is on the level\n  if ((node.type === 'item' || node.type === 'window' || node.type === 'door') && node.parentId) {\n    const parentNode = nodes[node.parentId as keyof typeof nodes]\n    if (parentNode?.type === 'wall' && parentNode.parentId === levelId) {\n      return true\n    }\n    // Ceiling/slab/roof-attached items: check if parent structure is on the level\n    if (\n      (parentNode?.type === 'ceiling' ||\n        parentNode?.type === 'slab' ||\n        parentNode?.type === 'roof') &&\n      parentNode.parentId === levelId\n    ) {\n      return true\n    }\n  }\n\n  return false\n}\n\n// Check if a node is on the selected level and within the selected zone's polygon\nconst isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean => {\n  const nodes = useScene.getState().nodes\n  const zone = nodes[zoneId as keyof typeof nodes] as ZoneNode | undefined\n  if (!zone?.polygon?.length) return false\n\n  // First check: node must be on the same level (directly or via wall)\n  if (!isNodeOnLevel(node, levelId)) return false\n\n  // Use world position from scene registry for accurate polygon check\n  const object3D = sceneRegistry.nodes.get(node.id)\n  if (object3D) {\n    object3D.getWorldPosition(tempWorldPos)\n    return pointInPolygonWithTolerance(tempWorldPos.x, tempWorldPos.z, zone.polygon)\n  }\n\n  // Fallback to node data if 3D object not available\n  if (node.type === 'item') {\n    const item = node as ItemNode\n    return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon)\n  }\n\n  if (node.type === 'wall') {\n    const wall = node as WallNode\n    const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon)\n    const endIn = pointInPolygonWithTolerance(wall.end[0], wall.end[1], zone.polygon)\n    return startIn || endIn\n  }\n\n  if (node.type === 'slab' || node.type === 'ceiling') {\n    const poly = (node as { polygon: [number, number][] }).polygon\n    if (!poly?.length) return false\n    // Check if any point of the node's polygon is in the zone (with tolerance)\n    for (const [px, pz] of poly) {\n      if (pointInPolygonWithTolerance(px, pz, zone.polygon)) return true\n    }\n    // Check if any point of the zone is in the node's polygon\n    for (const [zx, zz] of zone.polygon) {\n      if (pointInPolygon(zx, zz, poly)) return true\n    }\n    return false\n  }\n\n  if (node.type === 'roof' || node.type === 'roof-segment') {\n    // Roofs on the same level are valid when zone is selected\n    return true\n  }\n\n  return false\n}\n\nconst getStrategy = (): SelectionStrategy | null => {\n  const { buildingId, levelId, zoneId } = useViewer.getState().selection\n\n  const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => {\n    const isMeta = event?.metaKey || event?.nativeEvent?.metaKey\n    const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey\n\n    if (isMeta || isCtrl) {\n      if (selectedIds.includes(node.id)) {\n        return selectedIds.filter((id) => id !== node.id)\n      }\n      return [...selectedIds, node.id]\n    }\n\n    return [node.id]\n  }\n\n  // No building selected -> can select buildings\n  if (!buildingId) {\n    return {\n      types: ['building'],\n      handleClick: (node) => {\n        useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })\n      },\n      handleDeselect: () => {\n        // Nothing to deselect at root level\n      },\n      isValid: (node) => node.type === 'building',\n    }\n  }\n\n  // Building selected, no level -> can select levels\n  if (!levelId) {\n    return {\n      types: ['level'],\n      handleClick: (node) => {\n        useViewer.getState().setSelection({ levelId: (node as LevelNode).id })\n      },\n      handleDeselect: () => {\n        useViewer.getState().setSelection({ buildingId: null })\n      },\n      isValid: (node) => node.type === 'level',\n    }\n  }\n\n  // Level selected, no zone -> can select zones (only zones on the selected level)\n  if (!zoneId) {\n    return {\n      types: ['zone'],\n      handleClick: (node) => {\n        useViewer.getState().setSelection({ zoneId: (node as ZoneNode).id })\n      },\n      handleDeselect: () => {\n        useViewer.getState().setSelection({ levelId: null })\n      },\n      isValid: (node) => node.type === 'zone' && node.parentId === levelId,\n    }\n  }\n\n  // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)\n  return {\n    types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],\n    handleClick: (node, nativeEvent) => {\n      let nodeToSelect = node\n      if (node.type === 'roof-segment' && node.parentId) {\n        const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]\n        if (parentNode && parentNode.type === 'roof') {\n          nodeToSelect = parentNode\n        }\n      }\n\n      const { selectedIds } = useViewer.getState().selection\n      useViewer\n        .getState()\n        .setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) })\n    },\n    handleDeselect: () => {\n      const { selectedIds } = useViewer.getState().selection\n      // If items are selected, deselect them first; otherwise go back to level\n      if (selectedIds.length > 0) {\n        useViewer.getState().setSelection({ selectedIds: [] })\n      } else {\n        useViewer.getState().setSelection({ zoneId: null })\n      }\n    },\n    isValid: (node) => {\n      const validTypes = [\n        'wall',\n        'item',\n        'slab',\n        'ceiling',\n        'roof',\n        'roof-segment',\n        'window',\n        'door',\n      ]\n      if (!validTypes.includes(node.type)) return false\n      return isNodeInZone(node, levelId, zoneId)\n    },\n  }\n}\n\nexport const SelectionManager = () => {\n  const selection = useViewer((s) => s.selection)\n  const clickHandledRef = useRef(false)\n\n  useEffect(() => {\n    const onEnter = (event: NodeEvent) => {\n      const strategy = getStrategy()\n      if (!strategy) return\n      if (strategy.isValid(event.node)) {\n        event.stopPropagation()\n        useViewer.setState({ hoveredId: event.node.id })\n      }\n    }\n\n    const onLeave = (event: NodeEvent) => {\n      const strategy = getStrategy()\n      if (!strategy) return\n      if (strategy.isValid(event.node)) {\n        event.stopPropagation()\n        useViewer.setState({ hoveredId: null })\n      }\n    }\n\n    const onClick = (event: NodeEvent) => {\n      const strategy = getStrategy()\n      if (!strategy) return\n      if (!strategy.isValid(event.node)) return\n\n      event.stopPropagation()\n      clickHandledRef.current = true\n      strategy.handleClick(event.node, event.nativeEvent as unknown as MouseEvent)\n      // Clear hover immediately after clicking on building/level/zone\n      useViewer.setState({ hoveredId: null })\n    }\n\n    // Subscribe to all node types\n    const allTypes: SelectableNodeType[] = [\n      'building',\n      'level',\n      'zone',\n      'wall',\n      'item',\n      'slab',\n      'ceiling',\n      'roof',\n      'roof-segment',\n      'window',\n      'door',\n    ]\n    for (const type of allTypes) {\n      emitter.on(`${type}:enter`, onEnter)\n      emitter.on(`${type}:leave`, onLeave)\n      emitter.on(`${type}:click`, onClick)\n    }\n\n    return () => {\n      for (const type of allTypes) {\n        emitter.off(`${type}:enter`, onEnter)\n        emitter.off(`${type}:leave`, onLeave)\n        emitter.off(`${type}:click`, onClick)\n      }\n    }\n  }, [])\n\n  return (\n    <>\n      <PointerMissedHandler clickHandledRef={clickHandledRef} />\n      <OutlinerSync />\n    </>\n  )\n}\n\nconst PointerMissedHandler = ({\n  clickHandledRef,\n}: {\n  clickHandledRef: React.MutableRefObject<boolean>\n}) => {\n  const gl = useThree((s) => s.gl)\n\n  useEffect(() => {\n    const handleClick = (event: MouseEvent) => {\n      // Only handle left clicks\n      if (useViewer.getState().cameraDragging) return\n      if (event.button !== 0) return\n\n      // Use requestAnimationFrame to check after R3F event handlers\n      requestAnimationFrame(() => {\n        if (clickHandledRef.current) {\n          clickHandledRef.current = false\n          return\n        }\n\n        // Click was not handled by any 3D object -> deselect\n        const strategy = getStrategy()\n        if (strategy) {\n          strategy.handleDeselect()\n          useViewer.setState({ hoveredId: null })\n        }\n      })\n    }\n\n    const canvas = gl.domElement\n    canvas.addEventListener('click', handleClick)\n\n    return () => {\n      canvas.removeEventListener('click', handleClick)\n    }\n  }, [gl, clickHandledRef])\n\n  return null\n}\n\nconst OutlinerSync = () => {\n  const selection = useViewer((s) => s.selection)\n  const hoveredId = useViewer((s) => s.hoveredId)\n  const outliner = useViewer((s) => s.outliner)\n\n  useEffect(() => {\n    // Sync selected objects\n    outliner.selectedObjects.length = 0\n    for (const id of selection.selectedIds) {\n      const obj = sceneRegistry.nodes.get(id)\n      if (obj) outliner.selectedObjects.push(obj)\n    }\n\n    // Sync hovered objects\n    outliner.hoveredObjects.length = 0\n    if (hoveredId) {\n      const obj = sceneRegistry.nodes.get(hoveredId)\n      if (obj) outliner.hoveredObjects.push(obj)\n    }\n  }, [selection, hoveredId, outliner])\n\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/components/viewer/viewer-camera.tsx",
    "content": "import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei'\nimport useViewer from '../../store/use-viewer'\n\nexport const ViewerCamera = () => {\n  const cameraMode = useViewer((state) => state.cameraMode)\n\n  return cameraMode === 'perspective' ? (\n    <PerspectiveCamera far={1000} fov={50} makeDefault near={0.1} position={[10, 10, 10]} />\n  ) : (\n    <OrthographicCamera far={1000} makeDefault near={-1000} position={[10, 10, 10]} zoom={20} />\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/hooks/use-asset-url.ts",
    "content": "import { loadAssetUrl } from '@pascal-app/core'\nimport { useEffect, useState } from 'react'\n\n/**\n * Resolves an asset:// URL to a blob URL for use with Three.js loaders.\n * Returns null while loading or if resolution fails.\n */\nexport function useAssetUrl(url: string): string | null {\n  const [resolved, setResolved] = useState<string | null>(null)\n\n  useEffect(() => {\n    let cancelled = false\n    setResolved(null)\n    loadAssetUrl(url).then((result) => {\n      if (!cancelled) setResolved(result)\n    })\n    return () => {\n      cancelled = true\n    }\n  }, [url])\n\n  return resolved\n}\n"
  },
  {
    "path": "packages/viewer/src/hooks/use-gltf-ktx2.tsx",
    "content": "import { useGLTF } from '@react-three/drei'\nimport { useThree } from '@react-three/fiber'\nimport { KTX2Loader } from 'three/examples/jsm/Addons.js'\nimport { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'\n\nconst ktx2LoaderInstance = new KTX2Loader()\nktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')\nconst ktx2ConfiguredRenderers = new WeakSet<object>()\nconst ktx2WarningLoggedRenderers = new WeakSet<object>()\n\nconst useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {\n  const gl = useThree((state) => state.gl)\n\n  return useGLTF(path, true, true, (loader) => {\n    const renderer = gl as unknown as object\n\n    if (!ktx2ConfiguredRenderers.has(renderer)) {\n      try {\n        ktx2LoaderInstance.detectSupport(gl)\n        ktx2ConfiguredRenderers.add(renderer)\n      } catch (error) {\n        // Some WebGPU flows can transiently call this before backend init.\n        // Avoid crashing the whole scene; scans may render without KTX2 on this pass.\n        if (!ktx2WarningLoggedRenderers.has(renderer)) {\n          console.warn('[viewer] Skipping KTX2 support detection for now.', error)\n          ktx2WarningLoggedRenderers.add(renderer)\n        }\n      }\n    }\n\n    if (ktx2ConfiguredRenderers.has(renderer)) {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      loader.setKTX2Loader(ktx2LoaderInstance as any)\n    }\n\n    loader.setMeshoptDecoder(MeshoptDecoder)\n  })\n}\n\nexport { useGLTFKTX2 }\n"
  },
  {
    "path": "packages/viewer/src/hooks/use-node-events.ts",
    "content": "import {\n  type BuildingEvent,\n  type BuildingNode,\n  type CeilingEvent,\n  type CeilingNode,\n  type DoorEvent,\n  type DoorNode,\n  type EventSuffix,\n  emitter,\n  type ItemEvent,\n  type ItemNode,\n  type LevelEvent,\n  type LevelNode,\n  type RoofEvent,\n  type RoofNode,\n  type RoofSegmentEvent,\n  type RoofSegmentNode,\n  type SiteEvent,\n  type SiteNode,\n  type SlabEvent,\n  type SlabNode,\n  type StairEvent,\n  type StairNode,\n  type StairSegmentEvent,\n  type StairSegmentNode,\n  type WallEvent,\n  type WallNode,\n  type WindowEvent,\n  type WindowNode,\n  type ZoneEvent,\n  type ZoneNode,\n} from '@pascal-app/core'\nimport type { ThreeEvent } from '@react-three/fiber'\nimport useViewer from '../store/use-viewer'\n\ntype NodeConfig = {\n  site: { node: SiteNode; event: SiteEvent }\n  item: { node: ItemNode; event: ItemEvent }\n  wall: { node: WallNode; event: WallEvent }\n  building: { node: BuildingNode; event: BuildingEvent }\n  level: { node: LevelNode; event: LevelEvent }\n  zone: { node: ZoneNode; event: ZoneEvent }\n  slab: { node: SlabNode; event: SlabEvent }\n  ceiling: { node: CeilingNode; event: CeilingEvent }\n  roof: { node: RoofNode; event: RoofEvent }\n  'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }\n  stair: { node: StairNode; event: StairEvent }\n  'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }\n  window: { node: WindowNode; event: WindowEvent }\n  door: { node: DoorNode; event: DoorEvent }\n}\n\ntype NodeType = keyof NodeConfig\n\nexport function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {\n  const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {\n    const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`\n    const localPoint = e.object.worldToLocal(e.point.clone())\n    const payload = {\n      node,\n      position: [e.point.x, e.point.y, e.point.z],\n      localPosition: [localPoint.x, localPoint.y, localPoint.z],\n      normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,\n      stopPropagation: () => e.stopPropagation(),\n      nativeEvent: e,\n    } as NodeConfig[T]['event']\n\n    emitter.emit(eventKey, payload)\n  }\n\n  return {\n    onPointerDown: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      if (e.button !== 0) return\n      emit('pointerdown', e)\n    },\n    onPointerUp: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      if (e.button !== 0) return\n      emit('pointerup', e)\n      // Synthesize a click event on pointer up to be more forgiving than R3F's default onClick\n      // which often fails if the mouse moves even 1 pixel.\n      emit('click', e)\n    },\n    onClick: (e: ThreeEvent<PointerEvent>) => {\n      // Disable default R3F click since we synthesize it on pointerup\n      // This prevents double-clicks from firing twice.\n    },\n    onPointerEnter: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('enter', e)\n    },\n    onPointerLeave: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('leave', e)\n    },\n    onPointerMove: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('move', e)\n    },\n    onDoubleClick: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('double-click', e)\n    },\n    onContextMenu: (e: ThreeEvent<PointerEvent>) => {\n      if (useViewer.getState().cameraDragging) return\n      emit('context-menu', e)\n    },\n  }\n}\n"
  },
  {
    "path": "packages/viewer/src/index.ts",
    "content": "export { default as Viewer } from './components/viewer'\nexport { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'\nexport { SCENE_LAYER, ZONE_LAYER } from './lib/layers'\nexport {\n  clearMaterialCache,\n  createDefaultMaterial,\n  createMaterial,\n  DEFAULT_CEILING_MATERIAL,\n  DEFAULT_DOOR_MATERIAL,\n  DEFAULT_ROOF_MATERIAL,\n  DEFAULT_SLAB_MATERIAL,\n  DEFAULT_WALL_MATERIAL,\n  DEFAULT_WINDOW_MATERIAL,\n  disposeMaterial,\n} from './lib/materials'\nexport { default as useViewer } from './store/use-viewer'\nexport { ExportSystem } from './systems/export/export-system'\nexport { InteractiveSystem } from './systems/interactive/interactive-system'\nexport { snapLevelsToTruePositions } from './systems/level/level-utils'\n"
  },
  {
    "path": "packages/viewer/src/lib/asset-url.ts",
    "content": "import { loadAssetUrl } from '@pascal-app/core'\n\nexport const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app'\n\n/**\n * Resolves an asset URL to the appropriate format:\n * - If URL starts with http:// or https://, return as-is (external URL)\n * - If URL starts with asset://, resolve from IndexedDB storage\n * - If URL starts with /, prepend CDN URL (absolute path)\n * - Otherwise, prepend CDN URL (relative path)\n */\nexport async function resolveAssetUrl(url: string | undefined | null): Promise<string | null> {\n  if (!url) return null\n\n  // External URL - use as-is\n  if (url.startsWith('http://') || url.startsWith('https://')) {\n    return url\n  }\n\n  // IndexedDB asset - resolve from storage\n  if (url.startsWith('asset://')) {\n    return loadAssetUrl(url)\n  }\n\n  // Absolute or relative path - prepend CDN URL\n  const normalizedPath = url.startsWith('/') ? url : `/${url}`\n  return `${ASSETS_CDN_URL}${normalizedPath}`\n}\n\n/**\n * Synchronous version for URLs that don't need IndexedDB resolution\n * Only use this if you're sure the URL is not an asset:// URL\n */\nexport function resolveCdnUrl(url: string | undefined | null): string | null {\n  if (!url) return null\n\n  // External URL - use as-is\n  if (url.startsWith('http://') || url.startsWith('https://')) {\n    return url\n  }\n\n  // Don't use this for asset:// URLs - use resolveAssetUrl instead\n  if (url.startsWith('asset://')) {\n    console.warn('Use resolveAssetUrl() for asset:// URLs, not resolveCdnUrl()')\n    return null\n  }\n\n  // Absolute or relative path - prepend CDN URL\n  const normalizedPath = url.startsWith('/') ? url : `/${url}`\n  return `${ASSETS_CDN_URL}${normalizedPath}`\n}\n"
  },
  {
    "path": "packages/viewer/src/lib/layers.ts",
    "content": "/** Default Three.js layer for main scene geometry. */\nexport const SCENE_LAYER = 0\n\n/** Layer used for zone rendering (floor fills and wall borders). */\nexport const ZONE_LAYER = 2\n"
  },
  {
    "path": "packages/viewer/src/lib/materials.ts",
    "content": "import { type MaterialProperties, type MaterialSchema, resolveMaterial } from '@pascal-app/core'\nimport * as THREE from 'three'\n\nconst sideMap: Record<MaterialProperties['side'], THREE.Side> = {\n  front: THREE.FrontSide,\n  back: THREE.BackSide,\n  double: THREE.DoubleSide,\n}\n\nconst materialCache = new Map<string, THREE.MeshStandardMaterial>()\n\nfunction getCacheKey(props: MaterialProperties): string {\n  return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`\n}\n\nexport function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {\n  const props = resolveMaterial(material)\n  const cacheKey = getCacheKey(props)\n\n  if (materialCache.has(cacheKey)) {\n    return materialCache.get(cacheKey)!\n  }\n\n  const threeMaterial = new THREE.MeshStandardMaterial({\n    color: props.color,\n    roughness: props.roughness,\n    metalness: props.metalness,\n    opacity: props.opacity,\n    transparent: props.transparent,\n    side: sideMap[props.side],\n  })\n\n  materialCache.set(cacheKey, threeMaterial)\n  return threeMaterial\n}\n\nexport function createDefaultMaterial(\n  color = '#ffffff',\n  roughness = 0.9,\n): THREE.MeshStandardMaterial {\n  return new THREE.MeshStandardMaterial({\n    color,\n    roughness,\n    metalness: 0,\n    side: THREE.FrontSide,\n  })\n}\n\nexport const DEFAULT_WALL_MATERIAL = createDefaultMaterial('#ffffff', 0.9)\nexport const DEFAULT_SLAB_MATERIAL = createDefaultMaterial('#e5e5e5', 0.8)\nexport const DEFAULT_DOOR_MATERIAL = createDefaultMaterial('#8b4513', 0.7)\nexport const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({\n  color: '#87ceeb',\n  roughness: 0.1,\n  metalness: 0.1,\n  opacity: 0.3,\n  transparent: true,\n  side: THREE.DoubleSide,\n})\nexport const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95)\nexport const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85)\nexport const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9)\n\nexport function disposeMaterial(material: THREE.Material): void {\n  material.dispose()\n}\n\nexport function clearMaterialCache(): void {\n  for (const material of materialCache.values()) {\n    material.dispose()\n  }\n  materialCache.clear()\n}\n"
  },
  {
    "path": "packages/viewer/src/r3f.d.ts",
    "content": "/**\n * R3F JSX intrinsic element declarations for three.js primitives.\n *\n * @react-three/fiber augments JSX.IntrinsicElements globally via module\n * augmentation, but the augmentation doesn't reliably propagate during\n * composite tsc --build in CI because bun resolves @react-three/fiber's\n * peer deps into variant directories where @types/three is unreachable.\n *\n * This file replicates the module augmentation pattern R3F uses, declaring\n * the subset of three.js elements we actually use.\n *\n * The empty export makes this file a module, which is required for\n * `declare module` to augment existing modules rather than replace them.\n */\n\nexport {}\n\ninterface ThreeJSXElements {\n  // Containers\n  group: any\n  scene: any\n  // Geometries\n  boxGeometry: any\n  planeGeometry: any\n  circleGeometry: any\n  cylinderGeometry: any\n  sphereGeometry: any\n  extrudeGeometry: any\n  shapeGeometry: any\n  bufferGeometry: any\n  edgesGeometry: any\n  ringGeometry: any\n  // Meshes & lines\n  mesh: any\n  instancedMesh: any\n  line: any\n  lineSegments: any\n  lineLoop: any\n  points: any\n  // Materials\n  meshStandardMaterial: any\n  meshBasicMaterial: any\n  meshPhongMaterial: any\n  meshLambertMaterial: any\n  meshPhysicalMaterial: any\n  meshNormalMaterial: any\n  shadowMaterial: any\n  lineBasicMaterial: any\n  lineDashedMaterial: any\n  pointsMaterial: any\n  shaderMaterial: any\n  rawShaderMaterial: any\n  spriteMaterial: any\n  // Lights\n  ambientLight: any\n  directionalLight: any\n  pointLight: any\n  spotLight: any\n  hemisphereLight: any\n  rectAreaLight: any\n  // Cameras\n  perspectiveCamera: any\n  orthographicCamera: any\n  // Helpers\n  gridHelper: any\n  axesHelper: any\n  arrowHelper: any\n  // Misc\n  sprite: any\n  lOD: any\n  fog: any\n  color: any\n  // Buffer attribute\n  bufferAttribute: any\n  instancedBufferAttribute: any\n  // Primitive (R3F-specific)\n  primitive: any\n}\n\ndeclare module 'react' {\n  namespace JSX {\n    interface IntrinsicElements extends ThreeJSXElements {}\n  }\n}\n\ndeclare module 'react/jsx-runtime' {\n  namespace JSX {\n    interface IntrinsicElements extends ThreeJSXElements {}\n  }\n}\n\ndeclare module 'react/jsx-dev-runtime' {\n  namespace JSX {\n    interface IntrinsicElements extends ThreeJSXElements {}\n  }\n}\n"
  },
  {
    "path": "packages/viewer/src/store/use-item-light-pool.ts",
    "content": "import type { AnyNodeId, Interactive, LightEffect, SliderControl } from '@pascal-app/core'\nimport { create } from 'zustand'\n\nexport type LightRegistration = {\n  nodeId: AnyNodeId\n  effect: LightEffect\n  toggleIndex: number\n  sliderIndex: number\n  sliderMin: number\n  sliderMax: number\n  hasSlider: boolean\n}\n\ntype ItemLightPoolStore = {\n  registrations: Map<string, LightRegistration>\n  register: (key: string, nodeId: AnyNodeId, effect: LightEffect, interactive: Interactive) => void\n  unregister: (key: string) => void\n}\n\nexport const useItemLightPool = create<ItemLightPoolStore>((set) => ({\n  registrations: new Map(),\n\n  register: (key, nodeId, effect, interactive) => {\n    const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')\n    const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')\n    const sliderControl =\n      sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null\n\n    const registration: LightRegistration = {\n      nodeId,\n      effect,\n      toggleIndex,\n      sliderIndex,\n      hasSlider: sliderControl !== null,\n      sliderMin: sliderControl?.min ?? 0,\n      sliderMax: sliderControl?.max ?? 1,\n    }\n\n    set((s) => {\n      const next = new Map(s.registrations)\n      next.set(key, registration)\n      return { registrations: next }\n    })\n  },\n\n  unregister: (key) => {\n    set((s) => {\n      const next = new Map(s.registrations)\n      next.delete(key)\n      return { registrations: next }\n    })\n  },\n}))\n"
  },
  {
    "path": "packages/viewer/src/store/use-viewer.d.ts",
    "content": "import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'\nimport type { Object3D } from 'three'\ntype SelectionPath = {\n  buildingId: BuildingNode['id'] | null\n  levelId: LevelNode['id'] | null\n  zoneId: ZoneNode['id'] | null\n  selectedIds: BaseNode['id'][]\n}\ntype Outliner = {\n  selectedObjects: Object3D[]\n  hoveredObjects: Object3D[]\n}\ntype ViewerState = {\n  selection: SelectionPath\n  previewSelectedIds: BaseNode['id'][]\n  setPreviewSelectedIds: (ids: BaseNode['id'][]) => void\n  hoverHighlightMode: 'default' | 'delete'\n  setHoverHighlightMode: (mode: 'default' | 'delete') => void\n  hoveredId: AnyNode['id'] | ZoneNode['id'] | null\n  setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void\n  cameraMode: 'perspective' | 'orthographic'\n  setCameraMode: (mode: 'perspective' | 'orthographic') => void\n  levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'\n  setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void\n  wallMode: 'up' | 'cutaway' | 'down'\n  setWallMode: (mode: 'up' | 'cutaway' | 'down') => void\n  showScans: boolean\n  setShowScans: (show: boolean) => void\n  showGuides: boolean\n  setShowGuides: (show: boolean) => void\n  setSelection: (updates: Partial<SelectionPath>) => void\n  resetSelection: () => void\n  outliner: Outliner\n  exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null\n  setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void\n}\ndeclare const useViewer: import('zustand').UseBoundStore<import('zustand').StoreApi<ViewerState>>\nexport default useViewer\n//# sourceMappingURL=use-viewer.d.ts.map\n"
  },
  {
    "path": "packages/viewer/src/store/use-viewer.ts",
    "content": "'use client'\n\nimport type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'\nimport type { Object3D } from 'three'\n\nimport { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\ntype SelectionPath = {\n  buildingId: BuildingNode['id'] | null\n  levelId: LevelNode['id'] | null\n  zoneId: ZoneNode['id'] | null\n  selectedIds: BaseNode['id'][] // For items/assets (multi-select)\n}\n\ntype Outliner = {\n  selectedObjects: Object3D[]\n  hoveredObjects: Object3D[]\n}\n\ntype ViewerState = {\n  selection: SelectionPath\n  previewSelectedIds: BaseNode['id'][]\n  setPreviewSelectedIds: (ids: BaseNode['id'][]) => void\n  hoverHighlightMode: 'default' | 'delete'\n  setHoverHighlightMode: (mode: 'default' | 'delete') => void\n  hoveredId: AnyNode['id'] | ZoneNode['id'] | null\n  setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void\n\n  cameraMode: 'perspective' | 'orthographic'\n  setCameraMode: (mode: 'perspective' | 'orthographic') => void\n\n  theme: 'light' | 'dark'\n  setTheme: (theme: 'light' | 'dark') => void\n\n  unit: 'metric' | 'imperial'\n  setUnit: (unit: 'metric' | 'imperial') => void\n\n  levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'\n  setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void\n\n  wallMode: 'up' | 'cutaway' | 'down'\n  setWallMode: (mode: 'up' | 'cutaway' | 'down') => void\n\n  showScans: boolean\n  setShowScans: (show: boolean) => void\n\n  showGuides: boolean\n  setShowGuides: (show: boolean) => void\n\n  showGrid: boolean\n  setShowGrid: (show: boolean) => void\n\n  projectId: string | null\n  setProjectId: (id: string | null) => void\n  projectPreferences: Record<\n    string,\n    { showScans?: boolean; showGuides?: boolean; showGrid?: boolean }\n  >\n\n  // Smart selection update\n  setSelection: (updates: Partial<SelectionPath>) => void\n  resetSelection: () => void\n\n  outliner: Outliner // No setter as we will manipulate directly the arrays\n\n  // Export functionality\n  exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null\n  setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void\n\n  debugColors: boolean\n  setDebugColors: (enabled: boolean) => void\n\n  cameraDragging: boolean\n  setCameraDragging: (dragging: boolean) => void\n}\n\nconst useViewer = create<ViewerState>()(\n  persist(\n    (set) => ({\n      selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },\n      previewSelectedIds: [],\n      setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),\n      hoverHighlightMode: 'default',\n      setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),\n      hoveredId: null,\n      setHoveredId: (id) => set({ hoveredId: id }),\n\n      cameraMode: 'perspective',\n      setCameraMode: (mode) => set({ cameraMode: mode }),\n\n      theme: 'light',\n      setTheme: (theme) => set({ theme }),\n\n      unit: 'metric',\n      setUnit: (unit) => set({ unit }),\n\n      levelMode: 'stacked',\n      setLevelMode: (mode) => set({ levelMode: mode }),\n\n      wallMode: 'up',\n      setWallMode: (mode) => set({ wallMode: mode }),\n\n      showScans: true,\n      setShowScans: (show) =>\n        set((state) => {\n          const projectPreferences = { ...(state.projectPreferences || {}) }\n          if (state.projectId) {\n            projectPreferences[state.projectId] = {\n              ...(projectPreferences[state.projectId] || {}),\n              showScans: show,\n            }\n          }\n          return { showScans: show, projectPreferences }\n        }),\n\n      showGuides: true,\n      setShowGuides: (show) =>\n        set((state) => {\n          const projectPreferences = { ...(state.projectPreferences || {}) }\n          if (state.projectId) {\n            projectPreferences[state.projectId] = {\n              ...(projectPreferences[state.projectId] || {}),\n              showGuides: show,\n            }\n          }\n          return { showGuides: show, projectPreferences }\n        }),\n\n      showGrid: true,\n      setShowGrid: (show) =>\n        set((state) => {\n          const projectPreferences = { ...(state.projectPreferences || {}) }\n          if (state.projectId) {\n            projectPreferences[state.projectId] = {\n              ...(projectPreferences[state.projectId] || {}),\n              showGrid: show,\n            }\n          }\n          return { showGrid: show, projectPreferences }\n        }),\n\n      projectId: null,\n      setProjectId: (id) =>\n        set((state) => {\n          if (!id) return { projectId: id }\n          const prefs = state.projectPreferences?.[id] || {}\n          return {\n            projectId: id,\n            showScans: prefs.showScans ?? true,\n            showGuides: prefs.showGuides ?? true,\n            showGrid: prefs.showGrid ?? true,\n          }\n        }),\n      projectPreferences: {},\n\n      setSelection: (updates) =>\n        set((state) => {\n          const newSelection = { ...state.selection, ...updates }\n\n          // Hierarchy Guard: If we change a high-level parent, reset the children unless explicitly provided\n          if (updates.buildingId !== undefined) {\n            if (updates.levelId === undefined) newSelection.levelId = null\n            if (updates.zoneId === undefined) newSelection.zoneId = null\n            if (updates.selectedIds === undefined) newSelection.selectedIds = []\n          }\n          if (updates.levelId !== undefined) {\n            if (updates.zoneId === undefined) newSelection.zoneId = null\n            if (updates.selectedIds === undefined) newSelection.selectedIds = []\n          }\n          if (updates.zoneId !== undefined) {\n            if (updates.selectedIds === undefined) newSelection.selectedIds = []\n          }\n\n          return { selection: newSelection, previewSelectedIds: [] }\n        }),\n\n      resetSelection: () =>\n        set({\n          selection: {\n            buildingId: null,\n            levelId: null,\n            zoneId: null,\n            selectedIds: [],\n          },\n          previewSelectedIds: [],\n        }),\n\n      outliner: { selectedObjects: [], hoveredObjects: [] },\n\n      exportScene: null,\n      setExportScene: (fn) => set({ exportScene: fn }),\n\n      debugColors: false,\n      setDebugColors: (enabled) => set({ debugColors: enabled }),\n\n      cameraDragging: false,\n      setCameraDragging: (dragging) => set({ cameraDragging: dragging }),\n    }),\n    {\n      name: 'viewer-preferences',\n      partialize: (state) => ({\n        cameraMode: state.cameraMode,\n        theme: state.theme,\n        unit: state.unit,\n        levelMode: state.levelMode,\n        wallMode: state.wallMode,\n        projectPreferences: state.projectPreferences,\n      }),\n    },\n  ),\n)\n\nexport default useViewer\n"
  },
  {
    "path": "packages/viewer/src/systems/export/export-system.tsx",
    "content": "'use client'\n\nimport { useThree } from '@react-three/fiber'\nimport { useEffect } from 'react'\nimport type { Scene } from 'three'\nimport * as THREE from 'three'\nimport { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'\nimport { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'\nimport { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'\nimport useViewer from '../../store/use-viewer'\n\nconst EDITOR_LAYER = 1 // same constant used across the editor\n\nfunction downloadBlob(blob: Blob, filename: string) {\n  const url = URL.createObjectURL(blob)\n  const a = document.createElement('a')\n  a.href = url\n  a.download = filename\n  document.body.appendChild(a)\n  a.click()\n  document.body.removeChild(a)\n  URL.revokeObjectURL(url)\n}\n\nexport const ExportSystem = () => {\n  const { scene } = useThree()\n  const setExportScene = useViewer((state) => state.setExportScene)\n\n  useEffect(() => {\n    const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {\n      const date = new Date().toISOString().split('T')[0]\n      const filename = `pascal-export-${date}`\n\n      // Clone scene and strip editor-only objects (layer 1 = EDITOR_LAYER)\n      const exportRoot = scene.clone(true) as Scene\n      const toRemove: THREE.Object3D[] = []\n      exportRoot.traverse((obj) => {\n        if (obj.layers.isEnabled(EDITOR_LAYER)) {\n          toRemove.push(obj)\n        }\n      })\n      for (const obj of toRemove) {\n        obj.parent?.remove(obj)\n      }\n\n      if (format === 'glb') {\n        const exporter = new GLTFExporter()\n        const result = await new Promise<ArrayBuffer>((resolve, reject) => {\n          exporter.parse(\n            exportRoot,\n            (output) => resolve(output as ArrayBuffer),\n            (err) => reject(err),\n            { binary: true }\n          )\n        })\n        downloadBlob(new Blob([result], { type: 'model/gltf-binary' }), `${filename}.glb`)\n      } else if (format === 'stl') {\n        const exporter = new STLExporter()\n        const result = exporter.parse(exportRoot, { binary: true }) as DataView\n        downloadBlob(new Blob([result.buffer as ArrayBuffer], { type: 'model/stl' }), `${filename}.stl`)\n      } else if (format === 'obj') {\n        const exporter = new OBJExporter()\n        const result = exporter.parse(exportRoot)\n        downloadBlob(new Blob([result], { type: 'model/obj' }), `${filename}.obj`)\n      }\n    }\n\n    setExportScene(exportFn)\n    return () => setExportScene(null)\n  }, [scene, setExportScene])\n\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/guide/guide-system.tsx",
    "content": "import { sceneRegistry } from '@pascal-app/core'\nimport { useEffect } from 'react'\nimport useViewer from '../../store/use-viewer'\n\nexport const GuideSystem = () => {\n  const showGuides = useViewer((state) => state.showGuides)\n\n  useEffect(() => {\n    const guides = sceneRegistry.byType.guide || new Set()\n    guides.forEach((guideId) => {\n      const node = sceneRegistry.nodes.get(guideId)\n      if (node) {\n        node.visible = showGuides\n      }\n    })\n  }, [showGuides])\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/interactive/interactive-system.tsx",
    "content": "'use client'\n\nimport {\n  type AnyNodeId,\n  type Control,\n  type ControlValue,\n  type ItemNode,\n  pointInPolygon,\n  sceneRegistry,\n  useInteractive,\n  useScene,\n  type ZoneNode,\n} from '@pascal-app/core'\nimport { Html } from '@react-three/drei'\nimport { createPortal, useFrame } from '@react-three/fiber'\nimport { useState } from 'react'\nimport { type Object3D, Vector3 } from 'three'\nimport { useShallow } from 'zustand/react/shallow'\nimport useViewer from '../../store/use-viewer'\n\nconst _tempVec = new Vector3()\n\n// ---- Parent: one overlay per interactive item ----\n\nexport const InteractiveSystem = () => {\n  const interactiveNodeIds = useScene(\n    useShallow((state) =>\n      Object.values(state.nodes)\n        .filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null)\n        .map((n) => n.id),\n    ),\n  )\n\n  return (\n    <>\n      {interactiveNodeIds.map((id) => (\n        <ItemControlsOverlay key={id} nodeId={id} />\n      ))}\n    </>\n  )\n}\n\n// ---- Child: polls sceneRegistry then portals controls into the item group ----\n\nconst ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => {\n  const node = useScene((state) => state.nodes[nodeId] as ItemNode)\n  const [itemObj, setItemObj] = useState<Object3D | null>(null)\n\n  useFrame(() => {\n    if (itemObj) return\n    const obj = sceneRegistry.nodes.get(nodeId)\n    if (obj) setItemObj(obj)\n  })\n\n  const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))\n  const setControlValue = useInteractive((state) => state.setControlValue)\n\n  const zoneId = useViewer((s) => s.selection.zoneId)\n  const zonePolygon = useScene((s) => {\n    if (!zoneId) return null\n    const z = s.nodes[zoneId] as ZoneNode | undefined\n    return z?.polygon ?? null\n  })\n\n  if (!(itemObj && controlValues && node?.asset.interactive)) return null\n\n  const { controls } = node.asset.interactive\n  const [, height] = node.asset.dimensions\n\n  let opacity = 0\n  let pointerEvents: 'auto' | 'none' = 'none'\n  if (zoneId && zonePolygon?.length) {\n    itemObj.getWorldPosition(_tempVec)\n    const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon)\n    opacity = inside ? 1 : 0.1\n    pointerEvents = inside ? 'auto' : 'none'\n  }\n\n  return createPortal(\n    <Html center distanceFactor={8} occlude position={[0, height + 0.3, 0]} zIndexRange={[20, 0]}>\n      <div\n        style={{\n          display: 'flex',\n          flexDirection: 'column',\n          gap: 6,\n          background: 'rgba(0,0,0,0.75)',\n          backdropFilter: 'blur(8px)',\n          borderRadius: 8,\n          padding: '8px 12px',\n          minWidth: 120,\n          pointerEvents,\n          userSelect: 'none',\n          opacity,\n          transition: 'opacity 0.3s ease',\n        }}\n      >\n        {controls.map((control, i) => (\n          <ControlWidget\n            control={control}\n            key={i}\n            onChange={(v) => setControlValue(nodeId, i, v)}\n            value={controlValues[i] ?? false}\n          />\n        ))}\n      </div>\n    </Html>,\n    itemObj,\n  )\n}\n\n// ---- Control widgets ----\n\nconst ControlWidget = ({\n  control,\n  value,\n  onChange,\n}: {\n  control: Control\n  value: ControlValue\n  onChange: (v: ControlValue) => void\n}) => {\n  const labelStyle: React.CSSProperties = {\n    color: 'white',\n    fontSize: 11,\n    fontFamily: 'monospace',\n    display: 'flex',\n    flexDirection: 'column',\n    gap: 2,\n  }\n\n  if (control.kind === 'toggle') {\n    return (\n      <button\n        onClick={() => onChange(!value)}\n        style={{\n          background: value ? '#4ade80' : '#374151',\n          color: 'white',\n          border: 'none',\n          borderRadius: 4,\n          padding: '4px 8px',\n          cursor: 'pointer',\n          fontSize: 12,\n          fontFamily: 'monospace',\n          transition: 'background 0.2s',\n        }}\n      >\n        {control.label ?? (value ? 'On' : 'Off')}\n      </button>\n    )\n  }\n\n  if (control.kind === 'slider') {\n    return (\n      <label style={labelStyle}>\n        <span>\n          {control.label}: {value}\n          {control.unit ? ` ${control.unit}` : ''}\n        </span>\n        <input\n          max={control.max}\n          min={control.min}\n          onChange={(e) => onChange(Number(e.target.value))}\n          onPointerDown={(e) => e.stopPropagation()}\n          step={control.step}\n          type=\"range\"\n          value={value as number}\n        />\n      </label>\n    )\n  }\n\n  if (control.kind === 'temperature') {\n    return (\n      <label style={labelStyle}>\n        <span>\n          {control.label}: {value}°{control.unit}\n        </span>\n        <input\n          max={control.max}\n          min={control.min}\n          onChange={(e) => onChange(Number(e.target.value))}\n          onPointerDown={(e) => e.stopPropagation()}\n          step={1}\n          type=\"range\"\n          value={value as number}\n        />\n      </label>\n    )\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/item-light/item-light-system.tsx",
    "content": "import type { AnyNodeId, LevelNode } from '@pascal-app/core'\nimport { sceneRegistry, useInteractive, useScene } from '@pascal-app/core'\nimport { useFrame } from '@react-three/fiber'\nimport { useRef } from 'react'\nimport { MathUtils, type PointLight, Vector3 } from 'three'\nimport { useItemLightPool } from '../../store/use-item-light-pool'\nimport useViewer from '../../store/use-viewer'\n\nconst POOL_SIZE = 12\n// How often (in seconds) to re-evaluate which items have lights assigned (fallback timer)\nconst REASSIGN_INTERVAL = 0.2\n\n// Hysteresis: a currently-assigned slot keeps its key unless an unassigned\n// candidate beats it by at least this much (prevents flickering at the boundary)\nconst HYSTERESIS = 0.15\n\n// Camera movement thresholds that trigger an early re-evaluation\nconst CAM_MOVE_DIST = 0.5 // units\nconst CAM_ROT_DOT = 0.995 // cos(~5.7°)\n\ntype SlotRuntime = {\n  // The key currently driving this slot (null = idle)\n  key: string | null\n  // A pending reassignment waiting for the fade-out to finish\n  pendingKey: string | null\n  isFadingOut: boolean\n}\n\n// Module-level temp vectors reused every frame (avoids GC pressure)\nconst _dir = new Vector3()\nconst _camPos = new Vector3()\nconst _camFwd = new Vector3()\nconst _itemPos = new Vector3()\n\ntype SceneNodes = ReturnType<typeof useScene.getState>['nodes']\ntype InteractiveState = ReturnType<typeof useInteractive.getState>\n\nfunction scoreRegistration(\n  reg: import('../../store/use-item-light-pool').LightRegistration,\n  nodes: SceneNodes,\n  selectedLevelId: string | null,\n  levelMode: string,\n  interactiveState: InteractiveState,\n): number {\n  // Skip lights that are toggled off — they contribute no illumination\n  if (reg.toggleIndex >= 0) {\n    const values = interactiveState.items[reg.nodeId]?.controlValues\n    const isOn = Boolean(values?.[reg.toggleIndex])\n    if (!isOn) return Number.POSITIVE_INFINITY\n  }\n\n  const { nodeId, effect } = reg\n  const obj = sceneRegistry.nodes.get(nodeId)\n  if (!obj) return Number.POSITIVE_INFINITY\n\n  obj.getWorldPosition(_itemPos)\n  _itemPos.x += effect.offset[0]\n  _itemPos.y += effect.offset[1]\n  _itemPos.z += effect.offset[2]\n\n  _dir.copy(_itemPos).sub(_camPos).normalize()\n  const dot = _camFwd.dot(_dir) // 1 = ahead, -1 = behind\n\n  // Angular component (0 = dead ahead, 2 = directly behind)\n  const angular = 1 - dot\n  // Normalised distance component (assumes scenes < 200 units)\n  const dist = _camPos.distanceTo(_itemPos) / 200\n\n  // ── Level factor ──────────────────────────────────────────────────────────\n  const node = nodes[nodeId]\n  const itemLevelId = node?.parentId ?? null\n\n  let levelPenalty = 0\n  if (selectedLevelId) {\n    if (itemLevelId !== selectedLevelId) {\n      // In solo mode items on other levels are invisible — deprioritize strongly\n      levelPenalty = levelMode === 'solo' ? 100 : 0.8\n    }\n  } else if (itemLevelId) {\n    // No level selected — lightly prefer items on level index 0\n    const levelNode = nodes[itemLevelId as AnyNodeId] as LevelNode | undefined\n    const levelIndex = levelNode?.level ?? 0\n    if (levelIndex !== 0) levelPenalty = 0.3\n  }\n\n  return angular * 0.7 + dist * 0.3 + levelPenalty\n}\n\nexport function ItemLightSystem() {\n  const lightRefs = useRef<Array<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))\n  const slots = useRef<SlotRuntime[]>(\n    Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })),\n  )\n  const reassignTimer = useRef(0)\n\n  // Track camera state at last reassignment to detect meaningful movement\n  const prevReassignCamPos = useRef(new Vector3())\n  const prevReassignCamFwd = useRef(new Vector3(0, 0, -1))\n\n  useFrame(({ camera }, delta) => {\n    const dt = Math.min(delta, 0.1)\n    const { registrations } = useItemLightPool.getState()\n    const interactiveState = useInteractive.getState()\n\n    // ── 1. Throttled priority reassignment ──────────────────────────────────\n    camera.getWorldPosition(_camPos)\n    camera.getWorldDirection(_camFwd)\n\n    const camMoved =\n      _camPos.distanceTo(prevReassignCamPos.current) > CAM_MOVE_DIST ||\n      _camFwd.dot(prevReassignCamFwd.current) < CAM_ROT_DOT\n\n    reassignTimer.current -= delta\n    const shouldReassign = reassignTimer.current <= 0 || camMoved\n\n    if (shouldReassign) {\n      reassignTimer.current = REASSIGN_INTERVAL\n      prevReassignCamPos.current.copy(_camPos)\n      prevReassignCamFwd.current.copy(_camFwd)\n\n      // Read level/scene state once for the whole tick\n      const nodes = useScene.getState().nodes\n      const viewerState = useViewer.getState()\n      const selectedLevelId = viewerState.selection.levelId\n      const levelMode = viewerState.levelMode\n\n      // Score every registration\n      const scored: Array<{ key: string; score: number }> = []\n      for (const [key, reg] of registrations) {\n        scored.push({\n          key,\n          score: scoreRegistration(reg, nodes, selectedLevelId, levelMode, interactiveState),\n        })\n      }\n      scored.sort((a, b) => a.score - b.score)\n\n      // Build the desired assignment (top POOL_SIZE keys)\n      const desired = scored.slice(0, POOL_SIZE).map((s) => s.key)\n\n      // Build a map of currently-assigned keys → slot index for hysteresis\n      const currentlyAssigned = new Map<string, number>()\n      for (let i = 0; i < POOL_SIZE; i++) {\n        const s = slots.current[i]\n        if (!s) continue\n        const k = s.key ?? s.pendingKey\n        if (k) currentlyAssigned.set(k, i)\n      }\n\n      // Assign desired keys to slots — prefer keeping existing assignments\n      const usedSlots = new Set<number>()\n      const assignedKeys = new Set<string>()\n\n      // Pass 1: keep existing slots where the key is still in desired\n      for (const key of desired) {\n        const existingSlot = currentlyAssigned.get(key)\n        if (existingSlot !== undefined && !usedSlots.has(existingSlot)) {\n          usedSlots.add(existingSlot)\n          assignedKeys.add(key)\n        }\n      }\n\n      // Pass 2: assign remaining desired keys to free slots\n      let freeSlot = 0\n      for (const key of desired) {\n        if (assignedKeys.has(key)) continue\n        while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++\n        if (freeSlot >= POOL_SIZE) break\n\n        // Hysteresis: only evict the current occupant if the new key scores\n        // meaningfully better than it\n        const freeSlotData = slots.current[freeSlot]\n        const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null\n        if (currentKey && !desired.includes(currentKey)) {\n          const currentScore =\n            scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY\n          const newScore = scored.find((s) => s.key === key)?.score ?? 0\n          if (currentScore - newScore < HYSTERESIS) {\n            freeSlot++\n            continue\n          }\n        }\n\n        usedSlots.add(freeSlot)\n        assignedKeys.add(key)\n\n        const slot = slots.current[freeSlot]\n        if (slot && slot.key !== key) {\n          slot.pendingKey = key\n          slot.isFadingOut = slot.key !== null\n          if (!slot.isFadingOut) {\n            // Slot was idle — skip fade-out, assign immediately\n            slot.key = key\n            slot.pendingKey = null\n            const light = lightRefs.current[freeSlot]\n            const reg = registrations.get(key)\n            if (light && reg) {\n              light.color.set(reg.effect.color)\n              light.distance = reg.effect.distance ?? 0\n            }\n          }\n        }\n        freeSlot++\n      }\n\n      // Clear slots whose key is no longer in desired and not pending\n      for (let i = 0; i < POOL_SIZE; i++) {\n        if (!usedSlots.has(i)) {\n          const slot = slots.current[i]\n          if (slot?.key && !desired.includes(slot.key)) {\n            slot.pendingKey = null\n            slot.isFadingOut = true\n          }\n        }\n      }\n    }\n\n    // ── 2. Per-frame light updates ───────────────────────────────────────────\n    for (let i = 0; i < POOL_SIZE; i++) {\n      const light = lightRefs.current[i]\n      if (!light) continue\n\n      const slot = slots.current[i]\n      if (!slot) continue\n\n      // Fade-out phase: lerp intensity → 0, then complete the transition\n      if (slot.isFadingOut) {\n        light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)\n        if (light.intensity < 0.01) {\n          light.intensity = 0\n          slot.isFadingOut = false\n          slot.key = slot.pendingKey\n          slot.pendingKey = null\n\n          if (slot.key) {\n            const reg = registrations.get(slot.key)\n            if (reg) {\n              light.color.set(reg.effect.color)\n              light.distance = reg.effect.distance ?? 0\n            }\n          }\n        }\n        continue\n      }\n\n      if (!slot.key) {\n        // Idle slot — keep dark\n        light.intensity = 0\n        continue\n      }\n\n      const reg = registrations.get(slot.key)\n      if (!reg) {\n        slot.key = null\n        light.intensity = 0\n        continue\n      }\n\n      // Snap world position each frame\n      const obj = sceneRegistry.nodes.get(reg.nodeId)\n      if (obj) {\n        obj.getWorldPosition(_itemPos)\n        const [ox, oy, oz] = reg.effect.offset\n        light.position.set(_itemPos.x + ox, _itemPos.y + oy, _itemPos.z + oz)\n      }\n\n      // Compute target intensity\n      const values = interactiveState.items[reg.nodeId]?.controlValues\n      const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true\n      let t = 1\n      if (reg.hasSlider) {\n        const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin\n        t = (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin)\n      }\n      const targetIntensity = isOn\n        ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)\n        : reg.effect.intensityRange[0]\n\n      light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12)\n    }\n  })\n\n  return (\n    <>\n      {Array.from({ length: POOL_SIZE }, (_, i) => (\n        <pointLight\n          castShadow={false}\n          intensity={0}\n          key={i}\n          ref={(el: any) => {\n            lightRefs.current[i] = el\n          }}\n        />\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/level/level-system.d.ts",
    "content": "export declare const LevelSystem: () => null\n//# sourceMappingURL=level-system.d.ts.map\n"
  },
  {
    "path": "packages/viewer/src/systems/level/level-system.tsx",
    "content": "import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'\nimport { useFrame } from '@react-three/fiber'\nimport { lerp } from 'three/src/math/MathUtils.js'\nimport useViewer from '../../store/use-viewer'\nimport { getLevelHeight } from './level-utils'\n\nconst EXPLODED_GAP = 5\n\nexport const LevelSystem = () => {\n  useFrame((_, delta) => {\n    const nodes = useScene.getState().nodes\n    const levelMode = useViewer.getState().levelMode\n    const selectedLevel = useViewer.getState().selection.levelId\n\n    // Collect and sort levels by floor index so we can compute cumulative offsets.\n    // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc.\n    type LevelEntry = {\n      levelId: string\n      index: number\n      obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>\n    }\n    const entries: LevelEntry[] = []\n    sceneRegistry.byType.level.forEach((levelId) => {\n      const obj = sceneRegistry.nodes.get(levelId)\n      const level = nodes[levelId as LevelNode['id']]\n      if (obj && level) {\n        entries.push({ levelId, index: (level as any).level ?? 0, obj })\n      }\n    })\n    entries.sort((a, b) => a.index - b.index)\n\n    // Walk sorted levels, accumulating base Y offsets\n    let cumulativeY = 0\n    for (const { levelId, index, obj } of entries) {\n      const level = nodes[levelId as LevelNode['id']]\n      const baseY = cumulativeY\n      const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0\n      const targetY = baseY + explodedExtra\n\n      obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position\n      obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel\n\n      cumulativeY += getLevelHeight(levelId, nodes)\n    }\n  }, 5) // Using a lower priority so it runs after transforms from other systems have settled\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/level/level-utils.ts",
    "content": "import {\n  type CeilingNode,\n  type LevelNode,\n  sceneRegistry,\n  useScene,\n  type WallNode,\n} from '@pascal-app/core'\n\nexport const DEFAULT_LEVEL_HEIGHT = 2.5\n\n// Cache: levelId → computed height. Invalidated when the nodes reference changes.\n// Zustand produces a new `nodes` object on every mutation, so reference equality\n// is a zero-cost way to detect stale data without any subscription overhead.\nconst heightCache = new Map<string, number>()\nlet lastNodesRef: object | null = null\n\nexport function getLevelHeight(\n  levelId: string,\n  nodes: ReturnType<typeof useScene.getState>['nodes'],\n): number {\n  if (nodes !== lastNodesRef) {\n    heightCache.clear()\n    lastNodesRef = nodes\n  }\n\n  if (heightCache.has(levelId)) return heightCache.get(levelId)!\n\n  const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined\n  if (!level) return DEFAULT_LEVEL_HEIGHT\n\n  let maxTop = 0\n\n  for (const childId of level.children) {\n    const child = nodes[childId as keyof typeof nodes]\n    if (!child) continue\n    if (child.type === 'ceiling') {\n      const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT\n      if (ch > maxTop) maxTop = ch\n    } else if (child.type === 'wall') {\n      let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0\n      if (meshY < 0) meshY = 0\n      const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)\n      if (top > maxTop) maxTop = top\n    }\n  }\n\n  const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT\n  heightCache.set(levelId, height)\n  return height\n}\n\n/**\n * Instantly snaps all level Objects3D to their true stacked Y positions\n * (ignores levelMode — always uses stacked, no exploded gap).\n *\n * Returns a restore function that reverts each level's Y to what it was\n * before the snap, so lerp animations in LevelSystem can continue undisturbed.\n *\n * Usage:\n *   const restore = snapLevelsToTruePositions()\n *   renderer.render(scene, camera)\n *   restore()\n */\nexport function snapLevelsToTruePositions(): () => void {\n  const nodes = useScene.getState().nodes\n\n  type LevelEntry = {\n    obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>\n    levelId: string\n    index: number\n  }\n\n  const entries: LevelEntry[] = []\n  sceneRegistry.byType.level.forEach((levelId) => {\n    const obj = sceneRegistry.nodes.get(levelId)\n    const level = nodes[levelId as LevelNode['id']]\n    if (obj && level) {\n      entries.push({ levelId, index: (level as any).level ?? 0, obj })\n    }\n  })\n  entries.sort((a, b) => a.index - b.index)\n\n  // Snapshot current Y and visibility so we can restore them after the render\n  const snapshot = new Map(\n    entries.map(({ levelId, obj }) => [levelId, { y: obj.position.y, visible: obj.visible }]),\n  )\n\n  // Snap to true stacked positions and make all levels visible\n  let cumulativeY = 0\n  for (const { levelId, obj } of entries) {\n    obj.position.y = cumulativeY\n    obj.visible = true\n    cumulativeY += getLevelHeight(levelId, nodes)\n  }\n\n  return () => {\n    for (const { levelId, obj } of entries) {\n      const saved = snapshot.get(levelId)\n      if (saved !== undefined) {\n        obj.position.y = saved.y\n        obj.visible = saved.visible\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/scan/scan-system.tsx",
    "content": "import { sceneRegistry } from '@pascal-app/core'\nimport { useEffect } from 'react'\nimport useViewer from '../../store/use-viewer'\n\nexport const ScanSystem = () => {\n  const showScans = useViewer((state) => state.showScans)\n\n  useEffect(() => {\n    const scans = sceneRegistry.byType.scan || new Set()\n    scans.forEach((scanId) => {\n      const node = sceneRegistry.nodes.get(scanId)\n      if (node) {\n        node.visible = showScans\n      }\n    })\n  }, [showScans])\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/wall/wall-cutout.tsx",
    "content": "import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'\nimport { useFrame } from '@react-three/fiber'\nimport { useRef } from 'react'\nimport { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'\n\nimport { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'\nimport useViewer from '../../store/use-viewer'\n\nconst tmpVec = new Vector3()\nconst u = new Vector3()\nconst v = new Vector3()\n\nconst dotPattern = Fn(() => {\n  const scale = float(0.1)\n  const dotSize = float(0.3)\n\n  const uv = vec2(positionLocal.x, positionLocal.y).div(scale)\n  const gridUV = fract(uv)\n\n  const dist = length(gridUV.sub(0.5))\n\n  const dots = step(dist, dotSize.mul(0.5))\n\n  const fadeHeight = float(2.5)\n  const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))\n\n  return dots.mul(yFade)\n})\n\ninterface WallMaterials {\n  visible: MeshStandardNodeMaterial\n  invisible: MeshStandardNodeMaterial\n  materialHash: string\n}\n\nconst wallMaterialCache = new Map<string, WallMaterials>()\n\nfunction getMaterialHash(wallNode: WallNode): string {\n  if (!wallNode.material) return 'none'\n  const mat = wallNode.material\n  if (mat.preset && mat.preset !== 'custom') {\n    return `preset-${mat.preset}`\n  }\n  if (mat.properties) {\n    return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`\n  }\n  return 'default'\n}\n\nconst presetColors = {\n  white: '#ffffff',\n  brick: '#8b4513',\n  concrete: '#808080',\n  wood: '#deb887',\n  glass: '#87ceeb',\n  metal: '#c0c0c0',\n  plaster: '#f5f5dc',\n  tile: '#dcdcdc',\n  marble: '#f5f5f5',\n} as const\n\nfunction getPresetColor(preset: string): string {\n  return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'\n}\n\nfunction getMaterialsForWall(wallNode: WallNode): WallMaterials {\n  const cacheKey = wallNode.id\n  const materialHash = getMaterialHash(wallNode)\n\n  const existing = wallMaterialCache.get(cacheKey)\n  if (existing && existing.materialHash === materialHash) {\n    return existing\n  }\n\n  if (existing) {\n    existing.visible.dispose()\n    existing.invisible.dispose()\n  }\n\n  let userColor = '#ffffff'\n  if (wallNode.material?.properties?.color) {\n    userColor = wallNode.material.properties.color\n  } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {\n    userColor = getPresetColor(wallNode.material.preset)\n  }\n\n  const visibleMat = new MeshStandardNodeMaterial({\n    color: userColor,\n    roughness: 1,\n    metalness: 0,\n  })\n\n  const invisibleMat = new MeshStandardNodeMaterial({\n    transparent: true,\n    opacityNode: mix(float(0.0), float(0.24), dotPattern()),\n    color: userColor,\n    depthWrite: false,\n    emissive: userColor,\n  })\n\n  const result: WallMaterials = { visible: visibleMat, invisible: invisibleMat, materialHash }\n  wallMaterialCache.set(cacheKey, result)\n  return result\n}\n\nfunction getWallHideState(\n  wallNode: WallNode,\n  wallMesh: Mesh,\n  wallMode: string,\n  cameraDir: Vector3,\n): boolean {\n  let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior'\n\n  if (wallMode === 'up') {\n    hideWall = false\n  } else if (wallMode === 'down') {\n    hideWall = true\n  } else {\n    wallMesh.getWorldDirection(v)\n    if (v.dot(cameraDir) < 0) {\n      if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') {\n        hideWall = true\n      }\n    } else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {\n      hideWall = true\n    }\n  }\n\n  return hideWall\n}\n\nexport const WallCutout = () => {\n  const lastCameraPosition = useRef(new Vector3())\n  const lastCameraTarget = useRef(new Vector3())\n  const lastUpdateTime = useRef(0)\n  const lastWallMode = useRef<string>(useViewer.getState().wallMode)\n  const lastNumberOfWalls = useRef(0)\n  const lastWallMaterials = useRef<Map<string, WallMaterials>>(new Map())\n\n  useFrame(({ camera, clock }) => {\n    const wallMode = useViewer.getState().wallMode\n    const currentTime = clock.elapsedTime\n    const currentCameraPosition = camera.position\n    camera.getWorldDirection(tmpVec)\n    tmpVec.add(currentCameraPosition)\n\n    const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)\n    const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)\n    const timeSinceUpdate = currentTime - lastUpdateTime.current\n\n    const shouldUpdate =\n      ((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||\n      lastWallMode.current !== wallMode ||\n      sceneRegistry.byType.wall.size !== lastNumberOfWalls.current\n\n    const walls = sceneRegistry.byType.wall\n    const currentWallIds = new Set<string>()\n\n    walls.forEach((wallId) => {\n      const wallMesh = sceneRegistry.nodes.get(wallId)\n      if (!wallMesh) return\n      const wallNode = useScene.getState().nodes[wallId as WallNode['id']]\n      if (!wallNode || wallNode.type !== 'wall') return\n\n      currentWallIds.add(wallId)\n\n      const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)\n\n      if (shouldUpdate) {\n        const materials = getMaterialsForWall(wallNode)\n        ;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible\n      } else {\n        const currentMaterial = (wallMesh as Mesh).material\n        const materials = wallMaterialCache.get(wallId)\n        if (\n          !materials ||\n          currentMaterial !== (hideWall ? materials.invisible : materials.visible)\n        ) {\n          const newMaterials = getMaterialsForWall(wallNode)\n          ;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible\n        }\n      }\n    })\n\n    if (shouldUpdate) {\n      lastCameraPosition.current.copy(currentCameraPosition)\n      lastCameraTarget.current.copy(tmpVec)\n      lastUpdateTime.current = currentTime\n      camera.getWorldDirection(u)\n\n      if (lastWallMode.current !== wallMode) {\n        wallMaterialCache.clear()\n      }\n\n      for (const [wallId, mats] of lastWallMaterials.current) {\n        if (!currentWallIds.has(wallId)) {\n          mats.visible.dispose()\n          mats.invisible.dispose()\n          wallMaterialCache.delete(wallId)\n        }\n      }\n\n      lastWallMaterials.current.clear()\n      for (const [wallId, mats] of wallMaterialCache) {\n        lastWallMaterials.current.set(wallId, mats)\n      }\n\n      lastWallMode.current = wallMode\n      lastNumberOfWalls.current = sceneRegistry.byType.wall.size\n    }\n  })\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/src/systems/zone/zone-system.tsx",
    "content": "import { sceneRegistry, useScene } from '@pascal-app/core'\nimport { useFrame } from '@react-three/fiber'\nimport { useRef } from 'react'\nimport { type Group, MathUtils, type Mesh } from 'three'\nimport type { MeshBasicNodeMaterial } from 'three/webgpu'\nimport useViewer from '../../store/use-viewer'\n\nconst TRANSITION_DURATION = 400 // ms\nconst EXIT_DEBOUNCE_MS = 50 // ignore rapid exit→re-enter within this window\n\nexport const ZoneSystem = () => {\n  const lastHighlightedZoneRef = useRef<string | null>(null)\n  const lastChangeTimeRef = useRef(0)\n  const isTransitioningRef = useRef(false)\n  // Debounce exit-to-null: track the raw pending value and when it last changed\n  const pendingZoneRef = useRef<string | null>(null)\n  const pendingZoneSinceRef = useRef(0)\n\n  useFrame(({ clock }, delta) => {\n    const hoveredId = useViewer.getState().hoveredId\n    let rawZone: string | null = null\n\n    if (hoveredId) {\n      const hoveredNode = useScene.getState().nodes[hoveredId]\n      if (hoveredNode?.type === 'zone') {\n        rawZone = hoveredId\n      }\n    }\n\n    // Update pending zone when the raw value changes\n    if (rawZone !== pendingZoneRef.current) {\n      pendingZoneRef.current = rawZone\n      pendingZoneSinceRef.current = clock.elapsedTime * 1000\n    }\n\n    // Apply non-null immediately; debounce null to filter out brief exits\n    const age = clock.elapsedTime * 1000 - pendingZoneSinceRef.current\n    const highlightedZone =\n      rawZone !== null ? rawZone : age >= EXIT_DEBOUNCE_MS ? null : lastHighlightedZoneRef.current\n\n    // Detect stable zone change\n    if (highlightedZone !== lastHighlightedZoneRef.current) {\n      // Fade out previous zone label-pin\n      if (lastHighlightedZoneRef.current) {\n        const prevLabel = document.getElementById(`${lastHighlightedZoneRef.current}-label`)\n        const pin = prevLabel?.querySelector('.label-pin') as HTMLElement | null\n        if (pin) pin.style.opacity = '0'\n      }\n      // Fade in new zone label-pin\n      if (highlightedZone) {\n        const label = document.getElementById(`${highlightedZone}-label`)\n        const pin = label?.querySelector('.label-pin') as HTMLElement | null\n        if (pin) pin.style.opacity = '1'\n      }\n\n      lastHighlightedZoneRef.current = highlightedZone\n      lastChangeTimeRef.current = clock.elapsedTime * 1000\n      isTransitioningRef.current = true\n    }\n\n    // Skip frame if not transitioning\n    if (!isTransitioningRef.current) return\n\n    const elapsed = clock.elapsedTime * 1000 - lastChangeTimeRef.current\n\n    // Stop transitioning after duration\n    if (elapsed >= TRANSITION_DURATION) {\n      isTransitioningRef.current = false\n    }\n\n    // Lerp speed: complete transition in ~400ms\n    const lerpSpeed = 10 * delta\n\n    sceneRegistry.byType.zone.forEach((zoneId) => {\n      const zone = sceneRegistry.nodes.get(zoneId)\n      if (!zone) return\n\n      const isHighlighted = zoneId === highlightedZone\n      const targetOpacity = isHighlighted ? 1 : 0\n\n      const walls = (zone as Group).getObjectByName('walls') as Mesh | undefined\n      if (walls) {\n        const material = walls.material as MeshBasicNodeMaterial\n        const currentOpacity = material.userData.uOpacity.value\n        material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed)\n      }\n\n      const floor = (zone as Group).getObjectByName('floor') as Mesh | undefined\n      if (floor) {\n        const material = floor.material as MeshBasicNodeMaterial\n        const currentOpacity = material.userData.uOpacity.value\n        material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed)\n      }\n    })\n  })\n\n  return null\n}\n"
  },
  {
    "path": "packages/viewer/tsconfig.json",
    "content": "{\n  \"extends\": \"@pascal/typescript-config/react-library.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"noEmit\": false,\n    \"composite\": true,\n    \"incremental\": true\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"dist\"],\n  \"references\": [{ \"path\": \"../core\" }]\n}\n"
  },
  {
    "path": "tooling/release/android-playstore-release.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Android Play Store Release Script\n# Usage: bash tooling/release/android-playstore-release.sh [track]\n#   track: internal (default), alpha, beta, production\n\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/../..\" && pwd)\"\ncd \"$ROOT_DIR\"\n\nTRACK=\"${1:-internal}\"\nPOLL_SECONDS=\"${POLL_SECONDS:-30}\"\nMAX_POLLS=\"${MAX_POLLS:-40}\"\n\nrequire_cmd() {\n  if ! command -v \"$1\" >/dev/null 2>&1; then\n    echo \"Missing required command: $1\" >&2\n    exit 1\n  fi\n}\n\nrequire_cmd eas\nrequire_cmd python3\nrequire_cmd rg\nrequire_cmd git\n\nif [[ ! -f \"$ROOT_DIR/.env.local\" ]]; then\n  echo \"Missing .env.local at repo root.\" >&2\n  exit 1\nfi\n\nset -a\nsource \"$ROOT_DIR/.env.local\"\nset +a\n\n# Validate Google Play credentials\nGOOGLE_SA_PATH=\"${GOOGLE_SERVICE_ACCOUNT:-}\"\nif [[ -z \"$GOOGLE_SA_PATH\" ]]; then\n  echo \"GOOGLE_SERVICE_ACCOUNT is not set in .env.local.\" >&2\n  exit 1\nfi\n\n# Resolve relative path (relative to apps/native/)\nif [[ \"$GOOGLE_SA_PATH\" == ../../* ]]; then\n  GOOGLE_SA_ABS=\"$ROOT_DIR/${GOOGLE_SA_PATH#../../}\"\nelif [[ \"$GOOGLE_SA_PATH\" != /* ]]; then\n  GOOGLE_SA_ABS=\"$ROOT_DIR/$GOOGLE_SA_PATH\"\nelse\n  GOOGLE_SA_ABS=\"$GOOGLE_SA_PATH\"\nfi\n\nif [[ ! -f \"$GOOGLE_SA_ABS\" ]]; then\n  echo \"Google service account JSON not found: $GOOGLE_SA_ABS\" >&2\n  exit 1\nfi\n\nMAIN_SHA=\"$(git -C \"$ROOT_DIR\" rev-parse main)\"\nWORKTREE_DIR=\"$(mktemp -d /tmp/pascal-release-android-XXXXXX)\"\n\ncleanup() {\n  if git -C \"$ROOT_DIR\" worktree list --porcelain | rg -q \"^worktree ${WORKTREE_DIR}$\"; then\n    git -C \"$ROOT_DIR\" worktree remove --force \"$WORKTREE_DIR\" >/dev/null 2>&1 || true\n  fi\n  rm -rf \"$WORKTREE_DIR\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\necho \"Creating clean detached worktree from main ($MAIN_SHA)...\"\ngit -C \"$ROOT_DIR\" worktree add --detach \"$WORKTREE_DIR\" \"$MAIN_SHA\" >/dev/null\n\ncp \"$ROOT_DIR/.env.local\" \"$WORKTREE_DIR/.env.local\"\ncp \"$GOOGLE_SA_ABS\" \"$WORKTREE_DIR/apps/native/$(basename \"$GOOGLE_SA_ABS\")\"\n\nif [[ -d \"$ROOT_DIR/node_modules\" && ! -e \"$WORKTREE_DIR/node_modules\" ]]; then\n  ln -s \"$ROOT_DIR/node_modules\" \"$WORKTREE_DIR/node_modules\"\nfi\nif [[ -d \"$ROOT_DIR/apps/native/node_modules\" && ! -e \"$WORKTREE_DIR/apps/native/node_modules\" ]]; then\n  ln -s \"$ROOT_DIR/apps/native/node_modules\" \"$WORKTREE_DIR/apps/native/node_modules\"\nfi\n\necho \"Starting EAS Android production build with auto-submit (track: $TRACK)...\"\npushd \"$WORKTREE_DIR/apps/native\" >/dev/null\nset -a\nsource \"$WORKTREE_DIR/.env.local\"\nset +a\n\nBUILD_OUTPUT=\"$(eas build --platform android --profile production --auto-submit --non-interactive --no-wait --json 2>&1)\"\necho \"$BUILD_OUTPUT\"\n\nBUILD_ID=\"$(printf '%s\\n' \"$BUILD_OUTPUT\" | rg -o 'builds/[0-9a-f-]{36}' | head -n1 | cut -d/ -f2 || true)\"\nif [[ -z \"$BUILD_ID\" ]]; then\n  echo \"Could not parse EAS build ID from output.\" >&2\n  exit 1\nfi\npopd >/dev/null\n\necho \"Build started: $BUILD_ID\"\n\nBUILD_STATUS=\"\"\nBUILD_VERSION=\"\"\nBUILD_COMMIT=\"\"\nfor ((i = 1; i <= MAX_POLLS; i++)); do\n  VIEW_OUTPUT=\"$(cd \"$WORKTREE_DIR/apps/native\" && eas build:view \"$BUILD_ID\" --json 2>&1 || true)\"\n  META=\"$(python3 - \"$VIEW_OUTPUT\" <<'PY'\nimport json, sys\ns = sys.argv[1]\nstart = s.find('{')\nend = s.rfind('}')\nif start == -1 or end == -1 or end <= start:\n    print(\"\\n\\n\")\n    raise SystemExit(0)\nobj = json.loads(s[start:end+1])\nprint(obj.get(\"status\", \"\"))\nprint(obj.get(\"appBuildVersion\", \"\") or obj.get(\"appVersion\", \"\"))\nprint(obj.get(\"gitCommitHash\", \"\"))\nPY\n)\"\n  BUILD_STATUS=\"$(printf '%s\\n' \"$META\" | sed -n '1p')\"\n  BUILD_VERSION=\"$(printf '%s\\n' \"$META\" | sed -n '2p')\"\n  BUILD_COMMIT=\"$(printf '%s\\n' \"$META\" | sed -n '3p')\"\n\n  if [[ \"$BUILD_STATUS\" == \"FINISHED\" ]]; then\n    break\n  fi\n  if [[ \"$BUILD_STATUS\" == \"ERRORED\" || \"$BUILD_STATUS\" == \"CANCELED\" ]]; then\n    echo \"Build $BUILD_ID ended with status: $BUILD_STATUS\" >&2\n    exit 1\n  fi\n  echo \"[$i/$MAX_POLLS] EAS build status: ${BUILD_STATUS:-unknown} (waiting ${POLL_SECONDS}s)\"\n  sleep \"$POLL_SECONDS\"\ndone\n\nif [[ \"$BUILD_STATUS\" != \"FINISHED\" ]]; then\n  echo \"Timed out waiting for EAS build completion.\" >&2\n  exit 1\nfi\n\nif [[ -n \"$BUILD_COMMIT\" && \"$BUILD_COMMIT\" != \"$MAIN_SHA\" ]]; then\n  echo \"Warning: build commit $BUILD_COMMIT differs from main $MAIN_SHA\" >&2\nfi\n\necho\necho \"=== Android Release Summary ===\"\necho \"  EAS build id:      $BUILD_ID\"\necho \"  Build version:     ${BUILD_VERSION:-unknown}\"\necho \"  Build commit:      ${BUILD_COMMIT:-unknown}\"\necho \"  Play Store track:  $TRACK\"\necho \"  EAS submissions:   https://expo.dev/accounts/pascalorg/projects/pascal/submissions\"\necho\necho \"EAS auto-submit will upload the AAB to the Play Store '$TRACK' track.\"\necho \"Check the Google Play Console for processing status.\"\necho \"To promote to production: Google Play Console > Release > Production > Create new release\"\n"
  },
  {
    "path": "tooling/release/ios-appstore-release.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/../..\" && pwd)\"\ncd \"$ROOT_DIR\"\n\nVERSION=\"${1:-1.0}\"\nPOLL_SECONDS=\"${POLL_SECONDS:-30}\"\nMAX_POLLS=\"${MAX_POLLS:-40}\"\n\nrequire_cmd() {\n  if ! command -v \"$1\" >/dev/null 2>&1; then\n    echo \"Missing required command: $1\" >&2\n    exit 1\n  fi\n}\n\nrequire_cmd eas\nrequire_cmd asc\nrequire_cmd python3\nrequire_cmd rg\nrequire_cmd git\n\nif [[ ! -f \"$ROOT_DIR/.env.local\" ]]; then\n  echo \"Missing .env.local at repo root.\" >&2\n  exit 1\nfi\n\nset -a\nsource \"$ROOT_DIR/.env.local\"\nset +a\n\nexport ASC_KEY_ID=\"${ASC_KEY_ID:-${ASC_API_KEY_ID:-}}\"\nexport ASC_ISSUER_ID=\"${ASC_ISSUER_ID:-${ASC_API_KEY_ISSUER_ID:-}}\"\nexport ASC_APP_ID=\"${ASC_APP_ID:-}\"\n\nif [[ -z \"${ASC_KEY_ID}\" || -z \"${ASC_ISSUER_ID}\" || -z \"${ASC_APP_ID}\" ]]; then\n  echo \"ASC credentials are incomplete. Expected ASC_APP_ID and ASC_API_KEY_* (or ASC_*).\" >&2\n  exit 1\nfi\n\nif [[ -z \"${ASC_PRIVATE_KEY_PATH:-}\" ]]; then\n  ASC_PRIVATE_KEY_PATH=\"$ROOT_DIR/AuthKey_${ASC_KEY_ID}.p8\"\nfi\nif [[ ! -f \"$ASC_PRIVATE_KEY_PATH\" ]]; then\n  echo \"ASC private key not found: $ASC_PRIVATE_KEY_PATH\" >&2\n  exit 1\nfi\nexport ASC_PRIVATE_KEY_PATH\n\nMAIN_SHA=\"$(git -C \"$ROOT_DIR\" rev-parse main)\"\nWORKTREE_DIR=\"$(mktemp -d /tmp/pascal-release-XXXXXX)\"\n\ncleanup() {\n  if git -C \"$ROOT_DIR\" worktree list --porcelain | rg -q \"^worktree ${WORKTREE_DIR}$\"; then\n    git -C \"$ROOT_DIR\" worktree remove --force \"$WORKTREE_DIR\" >/dev/null 2>&1 || true\n  fi\n  rm -rf \"$WORKTREE_DIR\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\necho \"Creating clean detached worktree from main ($MAIN_SHA)...\"\ngit -C \"$ROOT_DIR\" worktree add --detach \"$WORKTREE_DIR\" \"$MAIN_SHA\" >/dev/null\n\ncp \"$ROOT_DIR/.env.local\" \"$WORKTREE_DIR/.env.local\"\ncp \"$ASC_PRIVATE_KEY_PATH\" \"$WORKTREE_DIR/apps/native/$(basename \"$ASC_PRIVATE_KEY_PATH\")\"\n\nif [[ -d \"$ROOT_DIR/node_modules\" && ! -e \"$WORKTREE_DIR/node_modules\" ]]; then\n  ln -s \"$ROOT_DIR/node_modules\" \"$WORKTREE_DIR/node_modules\"\nfi\nif [[ -d \"$ROOT_DIR/apps/native/node_modules\" && ! -e \"$WORKTREE_DIR/apps/native/node_modules\" ]]; then\n  ln -s \"$ROOT_DIR/apps/native/node_modules\" \"$WORKTREE_DIR/apps/native/node_modules\"\nfi\n\necho \"Starting EAS iOS production build with auto-submit...\"\npushd \"$WORKTREE_DIR/apps/native\" >/dev/null\nset -a\nsource \"$WORKTREE_DIR/.env.local\"\nset +a\n\nBUILD_OUTPUT=\"$(eas build --platform ios --profile production --auto-submit --non-interactive --no-wait --json 2>&1)\"\necho \"$BUILD_OUTPUT\"\n\nBUILD_ID=\"$(printf '%s\\n' \"$BUILD_OUTPUT\" | rg -o 'builds/[0-9a-f-]{36}' | head -n1 | cut -d/ -f2 || true)\"\nif [[ -z \"$BUILD_ID\" ]]; then\n  echo \"Could not parse EAS build ID from output.\" >&2\n  exit 1\nfi\npopd >/dev/null\n\necho \"Build started: $BUILD_ID\"\n\nBUILD_STATUS=\"\"\nBUILD_NUMBER=\"\"\nBUILD_COMMIT=\"\"\nfor ((i = 1; i <= MAX_POLLS; i++)); do\n  VIEW_OUTPUT=\"$(cd \"$WORKTREE_DIR/apps/native\" && eas build:view \"$BUILD_ID\" --json 2>&1 || true)\"\n  META=\"$(python3 - \"$VIEW_OUTPUT\" <<'PY'\nimport json, sys\ns = sys.argv[1]\nstart = s.find('{')\nend = s.rfind('}')\nif start == -1 or end == -1 or end <= start:\n    print(\"\\n\\n\")\n    raise SystemExit(0)\nobj = json.loads(s[start:end+1])\nprint(obj.get(\"status\", \"\"))\nprint(obj.get(\"appBuildVersion\", \"\"))\nprint(obj.get(\"gitCommitHash\", \"\"))\nPY\n)\"\n  BUILD_STATUS=\"$(printf '%s\\n' \"$META\" | sed -n '1p')\"\n  BUILD_NUMBER=\"$(printf '%s\\n' \"$META\" | sed -n '2p')\"\n  BUILD_COMMIT=\"$(printf '%s\\n' \"$META\" | sed -n '3p')\"\n\n  if [[ \"$BUILD_STATUS\" == \"FINISHED\" ]]; then\n    break\n  fi\n  if [[ \"$BUILD_STATUS\" == \"ERRORED\" || \"$BUILD_STATUS\" == \"CANCELED\" ]]; then\n    echo \"Build $BUILD_ID ended with status: $BUILD_STATUS\" >&2\n    exit 1\n  fi\n  echo \"[$i/$MAX_POLLS] EAS build status: ${BUILD_STATUS:-unknown} (waiting ${POLL_SECONDS}s)\"\n  sleep \"$POLL_SECONDS\"\ndone\n\nif [[ \"$BUILD_STATUS\" != \"FINISHED\" ]]; then\n  echo \"Timed out waiting for EAS build completion.\" >&2\n  exit 1\nfi\n\nif [[ -n \"$BUILD_COMMIT\" && \"$BUILD_COMMIT\" != \"$MAIN_SHA\" ]]; then\n  echo \"Warning: build commit $BUILD_COMMIT differs from main $MAIN_SHA\" >&2\nfi\n\nif [[ -z \"$BUILD_NUMBER\" ]]; then\n  echo \"Build completed but build number was not found.\" >&2\n  exit 1\nfi\n\necho \"Build finished. Build number: $BUILD_NUMBER\"\n\nVERSION_JSON=\"$(asc versions list --app \"$ASC_APP_ID\" --version \"$VERSION\" --platform IOS --pretty)\"\nVERSION_ID=\"$(python3 - \"$VERSION_JSON\" <<'PY'\nimport json, sys\nobj = json.loads(sys.argv[1])\ndata = obj.get(\"data\", [])\nprint(data[0][\"id\"] if data else \"\")\nPY\n)\"\nif [[ -z \"$VERSION_ID\" ]]; then\n  echo \"Could not find App Store version ID for version $VERSION.\" >&2\n  exit 1\nfi\n\necho \"Polling App Store Connect for build number $BUILD_NUMBER...\"\nASC_BUILD_ID=\"\"\nASC_BUILD_STATE=\"\"\nfor ((i = 1; i <= MAX_POLLS; i++)); do\n  BUILD_JSON=\"$(asc builds list --app \"$ASC_APP_ID\" --version \"$VERSION\" --build-number \"$BUILD_NUMBER\" --pretty)\"\n  PARSED=\"$(python3 - \"$BUILD_JSON\" <<'PY'\nimport json, sys\nobj = json.loads(sys.argv[1])\ndata = obj.get(\"data\", [])\nif not data:\n    print(\"\\n\")\n    raise SystemExit(0)\nitem = data[0]\nprint(item.get(\"id\", \"\"))\nprint(item.get(\"attributes\", {}).get(\"processingState\", \"\"))\nPY\n)\"\n  ASC_BUILD_ID=\"$(printf '%s\\n' \"$PARSED\" | sed -n '1p')\"\n  ASC_BUILD_STATE=\"$(printf '%s\\n' \"$PARSED\" | sed -n '2p')\"\n\n  if [[ -n \"$ASC_BUILD_ID\" && \"$ASC_BUILD_STATE\" == \"VALID\" ]]; then\n    break\n  fi\n  echo \"[$i/$MAX_POLLS] ASC build visibility/state: ${ASC_BUILD_STATE:-missing} (waiting ${POLL_SECONDS}s)\"\n  sleep \"$POLL_SECONDS\"\ndone\n\nif [[ -z \"$ASC_BUILD_ID\" || \"$ASC_BUILD_STATE\" != \"VALID\" ]]; then\n  echo \"Build $BUILD_NUMBER did not become VALID in ASC within timeout.\" >&2\n  exit 1\nfi\n\necho \"Attaching build $ASC_BUILD_ID to version $VERSION_ID...\"\nasc versions attach-build --version-id \"$VERSION_ID\" --build \"$ASC_BUILD_ID\" --output table\n\necho \"Running final validation...\"\nset +e\nVALIDATE_OUTPUT=\"$(asc validate --app \"$ASC_APP_ID\" --version-id \"$VERSION_ID\" --platform IOS --output table 2>&1)\"\nVALIDATE_EXIT=$?\nset -e\necho \"$VALIDATE_OUTPUT\"\n\necho\necho \"Release Automation Summary\"\necho \"  EAS build id:        $BUILD_ID\"\necho \"  Build number:        $BUILD_NUMBER\"\necho \"  Build commit:        ${BUILD_COMMIT:-unknown}\"\necho \"  ASC build id:        $ASC_BUILD_ID\"\necho \"  ASC version id:      $VERSION_ID\"\necho \"  EAS submissions page: https://expo.dev/accounts/pascalorg/projects/pascal/submissions\"\n\nif [[ \"$VALIDATE_EXIT\" -ne 0 ]]; then\n  if printf '%s' \"$VALIDATE_OUTPUT\" | rg -q 'availability\\.missing'; then\n    echo\n    echo \"Remaining blocker: Pricing and Availability.\"\n    echo \"Set it manually in App Store Connect: App > Pricing and Availability.\"\n    exit 2\n  fi\n  echo\n  echo \"Validation still has blocking issues. Review output above.\"\n  exit \"$VALIDATE_EXIT\"\nfi\n\necho\necho \"Validation passed. You can submit this version for review.\"\n"
  },
  {
    "path": "tooling/release/release.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Unified Release Script — builds and submits to both App Store and Play Store\n# Usage: bash tooling/release/release.sh [version] [platform]\n#   version:  App Store version string (e.g. \"1.0\") — required for iOS\n#   platform: \"all\" (default), \"ios\", \"android\"\n#\n# Examples:\n#   bash tooling/release/release.sh 1.0          # Both platforms\n#   bash tooling/release/release.sh 1.0 ios      # iOS only\n#   bash tooling/release/release.sh \"\" android    # Android only (no ASC version needed)\n#   bun native:release 1.0                        # Via package.json script\n\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/../..\" && pwd)\"\n\nVERSION=\"${1:-}\"\nPLATFORM=\"${2:-all}\"\n\nif [[ \"$PLATFORM\" == \"all\" || \"$PLATFORM\" == \"ios\" ]]; then\n  if [[ -z \"$VERSION\" ]]; then\n    echo \"Error: App Store version is required for iOS release.\" >&2\n    echo \"Usage: $0 <version> [platform]\" >&2\n    echo \"  e.g.: $0 1.0\" >&2\n    exit 1\n  fi\nfi\n\nIOS_PID=\"\"\nANDROID_PID=\"\"\nIOS_EXIT=0\nANDROID_EXIT=0\n\nif [[ \"$PLATFORM\" == \"all\" || \"$PLATFORM\" == \"ios\" ]]; then\n  echo \"=== Starting iOS release (version $VERSION) ===\"\n  bash \"$ROOT_DIR/tooling/release/ios-appstore-release.sh\" \"$VERSION\" &\n  IOS_PID=$!\nfi\n\nif [[ \"$PLATFORM\" == \"all\" || \"$PLATFORM\" == \"android\" ]]; then\n  echo \"=== Starting Android release (internal track) ===\"\n  bash \"$ROOT_DIR/tooling/release/android-playstore-release.sh\" internal &\n  ANDROID_PID=$!\nfi\n\n# Wait for both to finish\nif [[ -n \"$IOS_PID\" ]]; then\n  wait \"$IOS_PID\" || IOS_EXIT=$?\nfi\n\nif [[ -n \"$ANDROID_PID\" ]]; then\n  wait \"$ANDROID_PID\" || ANDROID_EXIT=$?\nfi\n\necho\necho \"=========================================\"\necho \"  Release Summary\"\necho \"=========================================\"\n\nif [[ -n \"$IOS_PID\" ]]; then\n  if [[ \"$IOS_EXIT\" -eq 0 ]]; then\n    echo \"  iOS:     SUCCESS\"\n  elif [[ \"$IOS_EXIT\" -eq 2 ]]; then\n    echo \"  iOS:     NEEDS MANUAL STEP (pricing/availability)\"\n  else\n    echo \"  iOS:     FAILED (exit $IOS_EXIT)\"\n  fi\nfi\n\nif [[ -n \"$ANDROID_PID\" ]]; then\n  if [[ \"$ANDROID_EXIT\" -eq 0 ]]; then\n    echo \"  Android: SUCCESS\"\n  else\n    echo \"  Android: FAILED (exit $ANDROID_EXIT)\"\n  fi\nfi\n\necho \"=========================================\"\n\n# Exit with failure if either platform failed\nif [[ \"$IOS_EXIT\" -ne 0 && \"$IOS_EXIT\" -ne 2 ]] || [[ \"$ANDROID_EXIT\" -ne 0 ]]; then\n  exit 1\nfi\n"
  },
  {
    "path": "tooling/typescript/base.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2022\", \"ESNext\"],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"moduleDetection\": \"force\",\n    \"resolveJsonModule\": true,\n    \"allowJs\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"verbatimModuleSyntax\": true\n  },\n  \"exclude\": [\"node_modules\", \"dist\", \".turbo\"]\n}\n"
  },
  {
    "path": "tooling/typescript/expo.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"jsx\": \"react-jsx\",\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": false,\n    \"declaration\": false,\n    \"declarationMap\": false\n  }\n}\n"
  },
  {
    "path": "tooling/typescript/nextjs.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"jsx\": \"preserve\",\n    \"plugins\": [{ \"name\": \"next\" }],\n    \"incremental\": true,\n    \"declaration\": false,\n    \"declarationMap\": false\n  }\n}\n"
  },
  {
    "path": "tooling/typescript/package.json",
    "content": "{\n  \"name\": \"@pascal/typescript-config\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"exports\": {\n    \"./*.json\": \"./*.json\"\n  }\n}\n"
  },
  {
    "path": "tooling/typescript/react-library.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"jsx\": \"react-jsx\"\n  }\n}\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://v2-8-1.turborepo.dev/schema.json\",\n  \"ui\": \"tui\",\n  \"tasks\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"inputs\": [\"$TURBO_DEFAULT$\", \".env*\"],\n      \"outputs\": [\".next/**\", \"!.next/cache/**\", \"dist/**\"],\n      \"env\": [\n        \"SKIP_ENV_VALIDATION\",\n        \"BETTER_AUTH_SECRET\",\n        \"BETTER_AUTH_URL\",\n        \"GOOGLE_CLIENT_ID\",\n        \"GOOGLE_CLIENT_SECRET\",\n        \"RESEND_API_KEY\",\n        \"POSTGRES_URL\",\n        \"NEXT_PUBLIC_SUPABASE_URL\",\n        \"NEXT_PUBLIC_SUPABASE_ANON_KEY\",\n        \"SUPABASE_SERVICE_ROLE_KEY\"\n      ]\n    },\n    \"lint\": {\n      \"dependsOn\": [\"^lint\"]\n    },\n    \"check-types\": {\n      \"dependsOn\": [\"^check-types\"]\n    },\n    \"dev\": {\n      \"dependsOn\": [\"^build\"],\n      \"cache\": false,\n      \"persistent\": true\n    }\n  }\n}\n"
  }
]