@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -56,13 +56,20 @@ export function focusTreeNode(nodeId: AnyNodeId) {
56
56
  import { cn } from '../../../../../lib/utils'
57
57
  import { BuildingTreeNode } from './building-tree-node'
58
58
  import { CeilingTreeNode } from './ceiling-tree-node'
59
+ import { ChimneyTreeNode } from './chimney-tree-node'
59
60
  import { ColumnTreeNode } from './column-tree-node'
60
61
  import { DoorTreeNode } from './door-tree-node'
62
+ import { DormerTreeNode } from './dormer-tree-node'
63
+ import { ElevatorTreeNode } from './elevator-tree-node'
61
64
  import { FenceTreeNode } from './fence-tree-node'
65
+ import { GutterTreeNode } from './gutter-tree-node'
62
66
  import { ItemTreeNode } from './item-tree-node'
63
67
  import { LevelTreeNode } from './level-tree-node'
68
+ import { RegistryTreeNode } from './registry-tree-node'
64
69
  import { RoofTreeNode } from './roof-tree-node'
70
+ import { ShelfTreeNode } from './shelf-tree-node'
65
71
  import { SlabTreeNode } from './slab-tree-node'
72
+ import { SolarPanelTreeNode } from './solar-panel-tree-node'
66
73
  import { SpawnTreeNode } from './spawn-tree-node'
67
74
  import { StairTreeNode } from './stair-tree-node'
68
75
  import { WallTreeNode } from './wall-tree-node'
@@ -75,43 +82,70 @@ interface TreeNodeProps {
75
82
  isLast?: boolean
76
83
  }
77
84
 
85
+ // Per-kind tree-node components keyed by `node.type`. Lookup replaces
86
+ // the legacy switch — adding a kind to this map is now the only edit
87
+ // needed in this file (the switch's `case '<kind>':` clauses were
88
+ // flagged by the Phase 6 grep gate as the last per-kind dispatch
89
+ // outside the registry; future work moves these to a
90
+ // `def.presentation`-driven generic tree-node and removes this map
91
+ // entirely).
92
+ const treeNodeByType: Record<
93
+ string,
94
+ React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
95
+ > = {
96
+ building: BuildingTreeNode as React.ComponentType<{
97
+ depth: number
98
+ isLast?: boolean
99
+ nodeId: AnyNodeId
100
+ }>,
101
+ 'box-vent': RegistryTreeNode,
102
+ ceiling: CeilingTreeNode,
103
+ chimney: ChimneyTreeNode,
104
+ dormer: DormerTreeNode,
105
+ 'solar-panel': SolarPanelTreeNode,
106
+ column: ColumnTreeNode,
107
+ elevator: ElevatorTreeNode,
108
+ level: LevelTreeNode as React.ComponentType<{
109
+ depth: number
110
+ isLast?: boolean
111
+ nodeId: AnyNodeId
112
+ }>,
113
+ shelf: ShelfTreeNode as React.ComponentType<{
114
+ depth: number
115
+ isLast?: boolean
116
+ nodeId: AnyNodeId
117
+ }>,
118
+ slab: SlabTreeNode,
119
+ spawn: SpawnTreeNode as React.ComponentType<{
120
+ depth: number
121
+ isLast?: boolean
122
+ nodeId: AnyNodeId
123
+ }>,
124
+ wall: WallTreeNode,
125
+ fence: FenceTreeNode,
126
+ gutter: GutterTreeNode,
127
+ 'ridge-vent': RegistryTreeNode,
128
+ 'turbine-vent': RegistryTreeNode,
129
+ cupola: RegistryTreeNode,
130
+ 'eyebrow-vent': RegistryTreeNode,
131
+ roof: RoofTreeNode,
132
+ stair: StairTreeNode,
133
+ door: DoorTreeNode,
134
+ window: WindowTreeNode,
135
+ zone: ZoneTreeNode as React.ComponentType<{
136
+ depth: number
137
+ isLast?: boolean
138
+ nodeId: AnyNodeId
139
+ }>,
140
+ item: ItemTreeNode,
141
+ }
142
+
78
143
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
79
144
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
80
-
81
145
  if (!nodeType) return null
82
-
83
- switch (nodeType) {
84
- case 'building':
85
- return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
86
- case 'ceiling':
87
- return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
88
- case 'column':
89
- return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
90
- case 'level':
91
- return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
92
- case 'slab':
93
- return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
94
- case 'spawn':
95
- return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
96
- case 'wall':
97
- return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
98
- case 'fence':
99
- return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
100
- case 'roof':
101
- return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
102
- case 'stair':
103
- return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
104
- case 'item':
105
- return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
106
- case 'door':
107
- return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
108
- case 'window':
109
- return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
110
- case 'zone':
111
- return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
112
- default:
113
- return null
114
- }
146
+ const Component = treeNodeByType[nodeType]
147
+ if (!Component) return null
148
+ return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
115
149
  })
116
150
 
117
151
  interface TreeNodeWrapperProps {
@@ -44,7 +44,9 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
44
44
  depth={depth}
45
45
  expanded={false}
46
46
  hasChildren={false}
47
- icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
47
+ icon={
48
+ <ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />
49
+ }
48
50
  isHovered={isHovered}
49
51
  isLast={isLast}
50
52
  isSelected={isSelected}
@@ -1,7 +1,14 @@
1
- import { emitter, useScene, type ZoneNode } from '@pascal-app/core'
1
+ import {
2
+ type AnyNodeId,
3
+ emitter,
4
+ useScene,
5
+ type ZoneNode,
6
+ } from '@pascal-app/core'
2
7
  import { useViewer } from '@pascal-app/viewer'
3
- import { Camera, Hexagon, Trash2 } from 'lucide-react'
8
+ import { Camera, Hexagon, Save, Trash2 } from 'lucide-react'
4
9
  import { useState } from 'react'
10
+ import { sfxEmitter } from './../../../../../lib/sfx-bus'
11
+ import { collectZoneContentIds } from './../../../../../lib/zone-content'
5
12
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
6
13
  import {
7
14
  Popover,
@@ -10,6 +17,8 @@ import {
10
17
  } from './../../../../../components/ui/primitives/popover'
11
18
  import { cn } from './../../../../../lib/utils'
12
19
  import useEditor from './../../../../../store/use-editor'
20
+ import { ActionButton } from '../../../controls/action-button'
21
+ import { PanelSection } from '../../../controls/panel-section'
13
22
 
14
23
  function ZoneItem({ zone }: { zone: ZoneNode }) {
15
24
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
@@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
26
35
 
27
36
  const handleDelete = (e: React.MouseEvent) => {
28
37
  e.stopPropagation()
38
+ sfxEmitter.emit('sfx:structure-delete')
29
39
  deleteNode(zone.id)
30
40
  if (isSelected) {
31
41
  setSelection({ zoneId: null })
@@ -41,7 +51,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
41
51
  className={cn(
42
52
  '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',
43
53
  isSelected
44
- ? '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'
54
+ ? 'border-neutral-200/60 bg-white text-foreground shadow-elevation-0 ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
45
55
  : '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',
46
56
  )}
47
57
  onClick={handleClick}
@@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
125
135
  export function ZonePanel() {
126
136
  const nodes = useScene((state) => state.nodes)
127
137
  const currentLevelId = useViewer((state) => state.selection.levelId)
138
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
139
+ const setSelection = useViewer((state) => state.setSelection)
128
140
  const setPhase = useEditor((state) => state.setPhase)
129
141
  const setMode = useEditor((state) => state.setMode)
130
142
  const setTool = useEditor((state) => state.setTool)
@@ -133,6 +145,7 @@ export function ZonePanel() {
133
145
  const levelZones = Object.values(nodes).filter(
134
146
  (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
135
147
  )
148
+ const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId)
136
149
 
137
150
  const handleAddZone = () => {
138
151
  if (currentLevelId) {
@@ -142,6 +155,17 @@ export function ZonePanel() {
142
155
  }
143
156
  }
144
157
 
158
+ const deleteSelectedZone = (withContent: boolean) => {
159
+ if (!selectedZone) return
160
+ const scene = useScene.getState()
161
+ const ids = withContent
162
+ ? [selectedZone.id as AnyNodeId, ...collectZoneContentIds(scene.nodes, selectedZone)]
163
+ : [selectedZone.id as AnyNodeId]
164
+ sfxEmitter.emit('sfx:structure-delete')
165
+ scene.deleteNodes(Array.from(new Set(ids)))
166
+ setSelection({ selectedIds: [], zoneId: null })
167
+ }
168
+
145
169
  if (!currentLevelId) {
146
170
  return (
147
171
  <div className="px-3 py-4 text-muted-foreground text-sm">
@@ -162,6 +186,31 @@ export function ZonePanel() {
162
186
  ) : (
163
187
  levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
164
188
  )}
189
+ {selectedZone ? (
190
+ <PanelSection className="mt-2 border-t" title="Actions">
191
+ <ActionButton
192
+ className="w-full flex-none"
193
+ icon={<Save className="h-4 w-4" />}
194
+ label="Save to catalog"
195
+ onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })}
196
+ type="button"
197
+ />
198
+ <ActionButton
199
+ className="w-full flex-none"
200
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
201
+ label="Delete"
202
+ onClick={() => deleteSelectedZone(false)}
203
+ type="button"
204
+ />
205
+ <ActionButton
206
+ className="w-full flex-none"
207
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
208
+ label="Delete with contents"
209
+ onClick={() => deleteSelectedZone(true)}
210
+ type="button"
211
+ />
212
+ </PanelSection>
213
+ ) : null}
165
214
  </div>
166
215
  )
167
216
  }
@@ -1,13 +1,17 @@
1
1
  'use client'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
+ import { triggerSFX } from './../../../lib/sfx-bus'
4
5
  import { cn } from './../../../lib/utils'
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
5
7
 
6
8
  export type SidebarTab = {
7
9
  id: string
8
10
  label: string
9
11
  mobileDefaultSnap?: number
10
12
  mobileIcon?: ReactNode
13
+ /** Desktop icon shown in the vertical rail (v2 layout). */
14
+ icon?: ReactNode
11
15
  }
12
16
 
13
17
  interface TabBarProps {
@@ -30,7 +34,11 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
30
34
  : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
31
35
  )}
32
36
  key={tab.id}
33
- onClick={() => onTabChange(tab.id)}
37
+ onClick={() => {
38
+ triggerSFX('sfx:menu-click')
39
+ onTabChange(tab.id)
40
+ }}
41
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
34
42
  type="button"
35
43
  >
36
44
  {tab.label}
@@ -40,3 +48,55 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
40
48
  </div>
41
49
  )
42
50
  }
51
+
52
+ interface IconRailProps {
53
+ tabs: SidebarTab[]
54
+ /** Highlighted tab. Stays highlighted while the panel is collapsed. */
55
+ activeTab: string
56
+ /** True when the panel beside the rail is collapsed. */
57
+ collapsed: boolean
58
+ /** Clicking a rail icon: switch tab, or toggle the panel (see layout). */
59
+ onIconClick: (id: string) => void
60
+ }
61
+
62
+ /**
63
+ * Vertical icon rail for the v2 left column. Always visible (even when the
64
+ * panel is collapsed) so the user can reopen the panel by clicking an icon.
65
+ * The label renders as a hover tooltip on the right.
66
+ */
67
+ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
68
+ return (
69
+ <TooltipProvider delayDuration={0} disableHoverableContent>
70
+ <div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
71
+ {tabs.map((tab) => {
72
+ // Only show the active highlight while the panel is open. When
73
+ // collapsed nothing is "open", so every icon reads as unselected.
74
+ const showActive = activeTab === tab.id && !collapsed
75
+ return (
76
+ <Tooltip key={tab.id}>
77
+ <TooltipTrigger asChild>
78
+ <button
79
+ className={cn(
80
+ 'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
81
+ showActive
82
+ ? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
83
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
84
+ )}
85
+ onClick={() => {
86
+ triggerSFX('sfx:menu-click')
87
+ onIconClick(tab.id)
88
+ }}
89
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
90
+ type="button"
91
+ >
92
+ {tab.icon ?? tab.label.charAt(0)}
93
+ </button>
94
+ </TooltipTrigger>
95
+ <TooltipContent side="right">{tab.label}</TooltipContent>
96
+ </Tooltip>
97
+ )
98
+ })}
99
+ </div>
100
+ </TooltipProvider>
101
+ )
102
+ }
@@ -17,7 +17,7 @@ const sliderVariants = cva(
17
17
  [&_[data-slot=slider-track]]:border
18
18
  [&_[data-slot=slider-track]]:border-neutral-300
19
19
  [&_[data-slot=slider-track]]:bg-white/50
20
- [&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]
20
+ [&_[data-slot=slider-track]]:shadow-elevation-0
21
21
  [&_[data-slot=slider-track]]:ring-1
22
22
  [&_[data-slot=slider-track]]:ring-white
23
23
  [&_[data-slot=slider-track]]:ring-inset
@@ -10,13 +10,35 @@ import {
10
10
  useScene,
11
11
  type ZoneNode,
12
12
  } from '@pascal-app/core'
13
- import { useViewer } from '@pascal-app/viewer'
14
- import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
15
- import { motion } from 'motion/react'
13
+ import {
14
+ CLAY_PALETTE,
15
+ type EdgeMode,
16
+ getSceneTheme,
17
+ SCENE_THEMES,
18
+ useViewer,
19
+ } from '@pascal-app/viewer'
20
+ import {
21
+ ArrowLeft,
22
+ Box,
23
+ Camera,
24
+ Check,
25
+ ChevronRight,
26
+ Diamond,
27
+ Layers,
28
+ PenLine,
29
+ Sparkles,
30
+ } from 'lucide-react'
16
31
  import Link from 'next/link'
17
32
  import { useShallow } from 'zustand/react/shallow'
33
+ import { getLevelDisplayName } from '../lib/level-name'
18
34
  import { cn } from '../lib/utils'
19
35
  import { ActionButton } from './ui/action-menu/action-button'
36
+ import {
37
+ DropdownMenu,
38
+ DropdownMenuContent,
39
+ DropdownMenuItem,
40
+ DropdownMenuTrigger,
41
+ } from './ui/primitives/dropdown-menu'
20
42
  import { TooltipProvider } from './ui/primitives/tooltip'
21
43
 
22
44
  type ProjectOwner = {
@@ -60,6 +82,143 @@ const wallModeConfig = {
60
82
  },
61
83
  }
62
84
 
85
+ const SHADING_OPTIONS = [
86
+ { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
87
+ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
88
+ ] as const
89
+
90
+ function RenderModeMenu() {
91
+ const shading = useViewer((s) => s.shading)
92
+ const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
93
+ const ActiveIcon = active.icon
94
+ return (
95
+ <DropdownMenu>
96
+ <DropdownMenuTrigger asChild>
97
+ <ActionButton
98
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
99
+ label={`Render: ${active.name}`}
100
+ size="icon"
101
+ tooltipSide="top"
102
+ variant="ghost"
103
+ >
104
+ <ActiveIcon className="h-6 w-6" />
105
+ </ActionButton>
106
+ </DropdownMenuTrigger>
107
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
108
+ {SHADING_OPTIONS.map((option) => {
109
+ const OptionIcon = option.icon
110
+ return (
111
+ <DropdownMenuItem
112
+ key={option.id}
113
+ onSelect={() => useViewer.getState().setShading(option.id)}
114
+ >
115
+ <OptionIcon />
116
+ <div className="flex flex-col">
117
+ <span className="text-foreground">{option.name}</span>
118
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
119
+ </div>
120
+ {shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
121
+ </DropdownMenuItem>
122
+ )
123
+ })}
124
+ </DropdownMenuContent>
125
+ </DropdownMenu>
126
+ )
127
+ }
128
+
129
+ function SceneThemeMenu() {
130
+ const sceneTheme = useViewer((s) => s.sceneTheme)
131
+ const active = getSceneTheme(sceneTheme)
132
+ return (
133
+ <DropdownMenu>
134
+ <DropdownMenuTrigger asChild>
135
+ <ActionButton
136
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
137
+ label={`Theme: ${active.name}`}
138
+ size="icon"
139
+ tooltipSide="top"
140
+ variant="ghost"
141
+ >
142
+ <Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
143
+ </ActionButton>
144
+ </DropdownMenuTrigger>
145
+ <DropdownMenuContent align="center" className="min-w-48" side="top">
146
+ {SCENE_THEMES.map((sceneThemeOption) => {
147
+ const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
148
+ (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
149
+ )
150
+ return (
151
+ <DropdownMenuItem
152
+ key={sceneThemeOption.id}
153
+ onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
154
+ >
155
+ <span
156
+ className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
157
+ style={{ backgroundColor: sceneThemeOption.background }}
158
+ >
159
+ {swatches.map((color, index) => (
160
+ <span
161
+ key={`${sceneThemeOption.id}-${index}`}
162
+ style={{ backgroundColor: color }}
163
+ />
164
+ ))}
165
+ </span>
166
+ <span className="text-foreground">{sceneThemeOption.name}</span>
167
+ {sceneTheme === sceneThemeOption.id ? (
168
+ <Check className="ml-auto text-foreground" />
169
+ ) : null}
170
+ </DropdownMenuItem>
171
+ )
172
+ })}
173
+ </DropdownMenuContent>
174
+ </DropdownMenu>
175
+ )
176
+ }
177
+
178
+ const EDGE_OPTIONS = [
179
+ { id: 'off', name: 'Off', detail: 'No edge lines' },
180
+ { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
181
+ { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
182
+ ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
183
+
184
+ function EdgesMenu() {
185
+ const edges = useViewer((s) => s.edges)
186
+ const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
187
+ return (
188
+ <DropdownMenu>
189
+ <DropdownMenuTrigger asChild>
190
+ <ActionButton
191
+ className={
192
+ edges === 'off'
193
+ ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
194
+ : 'bg-white/10 text-foreground'
195
+ }
196
+ label={`Edges: ${active.name}`}
197
+ size="icon"
198
+ tooltipSide="top"
199
+ variant="ghost"
200
+ >
201
+ <PenLine className="h-6 w-6" />
202
+ </ActionButton>
203
+ </DropdownMenuTrigger>
204
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
205
+ {EDGE_OPTIONS.map((option) => (
206
+ <DropdownMenuItem
207
+ key={option.id}
208
+ onSelect={() => useViewer.getState().setEdges(option.id)}
209
+ >
210
+ <div className="flex flex-col">
211
+ <span className="text-foreground">{option.name}</span>
212
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
213
+ </div>
214
+ {edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
215
+ </DropdownMenuItem>
216
+ ))}
217
+ </DropdownMenuContent>
218
+ </DropdownMenu>
219
+ )
220
+ }
221
+
63
222
  const getNodeName = (node: AnyNode): string => {
64
223
  if ('name' in node && node.name) return node.name
65
224
  if (node.type === 'wall') return 'Wall'
@@ -93,7 +252,6 @@ export const ViewerOverlay = ({
93
252
  const cameraMode = useViewer((s) => s.cameraMode)
94
253
  const levelMode = useViewer((s) => s.levelMode)
95
254
  const wallMode = useViewer((s) => s.wallMode)
96
- const theme = useViewer((s) => s.theme)
97
255
 
98
256
  // Subscribe only to the specific nodes we read so that creating an unrelated
99
257
  // node elsewhere in the scene doesn't re-render this overlay.
@@ -206,7 +364,7 @@ export const ViewerOverlay = ({
206
364
  className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
207
365
  onClick={() => handleBreadcrumbClick('level')}
208
366
  >
209
- {level.name || `Level ${level.level}`}
367
+ {getLevelDisplayName(level)}
210
368
  </button>
211
369
  </>
212
370
  )}
@@ -280,53 +438,6 @@ export const ViewerOverlay = ({
280
438
  <div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
281
439
  <TooltipProvider delayDuration={0}>
282
440
  <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">
283
- {/* Theme Toggle */}
284
- <button
285
- aria-label="Toggle theme"
286
- className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
287
- onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
288
- type="button"
289
- >
290
- <div className="relative flex">
291
- {/* Sliding Background */}
292
- <motion.div
293
- animate={{
294
- x: theme === 'light' ? '100%' : '0%',
295
- }}
296
- className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
297
- initial={false}
298
- style={{ width: '50%' }}
299
- transition={{
300
- type: 'spring',
301
- stiffness: 500,
302
- damping: 35,
303
- }}
304
- />
305
-
306
- {/* Dark Mode Icon */}
307
- <div
308
- className={cn(
309
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
310
- theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
311
- )}
312
- >
313
- <Moon className="h-4 w-4" />
314
- </div>
315
-
316
- {/* Light Mode Icon */}
317
- <div
318
- className={cn(
319
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
320
- theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
321
- )}
322
- >
323
- <Sun className="h-4 w-4" />
324
- </div>
325
- </div>
326
- </button>
327
-
328
- <div className="mx-1 h-5 w-px bg-border/40" />
329
-
330
441
  {/* Scans and Guides Visibility */}
331
442
  {canShowScans && (
332
443
  <ActionButton
@@ -392,6 +503,12 @@ export const ViewerOverlay = ({
392
503
  <Camera className="h-6 w-6" />
393
504
  </ActionButton>
394
505
 
506
+ <RenderModeMenu />
507
+
508
+ <SceneThemeMenu />
509
+
510
+ <EdgesMenu />
511
+
395
512
  {/* Level Mode */}
396
513
  <ActionButton
397
514
  className={cn(
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
12
12
  const structureLayer = useEditor.getState().structureLayer
13
13
  const nodes = useScene.getState().nodes
14
14
 
15
- sceneRegistry.byType.zone.forEach((id) => {
15
+ sceneRegistry.byType.zone!.forEach((id) => {
16
16
  const obj = sceneRegistry.nodes.get(id)
17
17
  if (!obj) return
18
18