@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
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
10
10
  import { create } from 'zustand'
11
11
  import { useShallow } from 'zustand/shallow'
12
12
  import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
13
+ import { getLevelDisplayName } from '../../../lib/level-name'
13
14
  import { useCommandRegistry } from '../../../store/use-command-registry'
14
15
  import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
15
16
 
@@ -27,15 +28,13 @@ interface CommandPaletteStore {
27
28
  setInputValue: (value: string) => void
28
29
  navigateTo: (page: string) => void
29
30
  goBack: () => void
30
- cameraScope: { nodeId: string; label: string } | null
31
- setCameraScope: (scope: { nodeId: string; label: string } | null) => void
32
31
  }
33
32
 
34
33
  export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
35
34
  open: false,
36
35
  setOpen: (open) => {
37
36
  set({ open })
38
- if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
37
+ if (!open) set({ pages: [], inputValue: '', mode: 'command' })
39
38
  },
40
39
  mode: 'command',
41
40
  setMode: (mode) => set({ mode }),
@@ -44,12 +43,8 @@ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
44
43
  setInputValue: (value) => set({ inputValue: value }),
45
44
  navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
46
45
  goBack: () => {
47
- const { pages } = get()
48
- if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
49
46
  set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
50
47
  },
51
- cameraScope: null,
52
- setCameraScope: (scope) => set({ cameraScope: scope }),
53
48
  }))
54
49
 
55
50
  // ---------------------------------------------------------------------------
@@ -157,8 +152,6 @@ const PAGE_LABEL: Record<string, string> = {
157
152
  'level-mode': 'Level Mode',
158
153
  'rename-level': 'Rename Level',
159
154
  'goto-level': 'Go to Level',
160
- 'camera-view': 'Camera Snapshot',
161
- 'camera-scope': '',
162
155
  }
163
156
 
164
157
  // ---------------------------------------------------------------------------
@@ -196,19 +189,8 @@ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
196
189
  // Main component
197
190
  // ---------------------------------------------------------------------------
198
191
  export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
199
- const {
200
- open,
201
- setOpen,
202
- mode,
203
- setMode,
204
- pages,
205
- inputValue,
206
- setInputValue,
207
- navigateTo,
208
- goBack,
209
- cameraScope,
210
- setCameraScope,
211
- } = useCommandPalette()
192
+ const { open, setOpen, mode, setMode, pages, inputValue, setInputValue, navigateTo, goBack } =
193
+ useCommandPalette()
212
194
 
213
195
  const [meta, setMeta] = useState('⌘')
214
196
  const [isFullscreen, setIsFullscreen] = useState(false)
@@ -233,11 +215,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
233
215
  ),
234
216
  )
235
217
 
236
- const cameraScopeNode = useScene((s) =>
237
- cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
238
- )
239
- const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
240
-
241
218
  // Platform detection
242
219
  useEffect(() => {
243
220
  setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
@@ -279,7 +256,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
279
256
  solo: 'Solo',
280
257
  }
281
258
 
282
- // Camera snapshot helpers (used by sub-pages registered via EditorCommands)
283
259
  const confirmRename = () => {
284
260
  if (!(activeLevelId && inputValue.trim())) return
285
261
  run(() => {
@@ -287,29 +263,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
287
263
  })
288
264
  }
289
265
 
290
- const takeSnapshot = () => {
291
- if (!cameraScope) return
292
- import('@pascal-app/core').then(({ emitter }) => {
293
- run(() =>
294
- emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
295
- )
296
- })
297
- }
298
-
299
- const viewSnapshot = () => {
300
- if (!(cameraScope && hasScopeSnapshot)) return
301
- import('@pascal-app/core').then(({ emitter }) => {
302
- run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
303
- })
304
- }
305
-
306
- const clearSnapshot = () => {
307
- if (!(cameraScope && hasScopeSnapshot)) return
308
- run(() => {
309
- useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)
310
- })
311
- }
312
-
313
266
  // ---------------------------------------------------------------------------
314
267
  // Group registered actions by group (preserving insertion order)
315
268
  // ---------------------------------------------------------------------------
@@ -363,9 +316,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
363
316
  onClick={goBack}
364
317
  type="button"
365
318
  >
366
- {page === 'camera-scope'
367
- ? (cameraScope?.label ?? 'Snapshot')
368
- : (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
319
+ {PAGE_LABEL[page] ?? views.get(page)?.label ?? page}
369
320
  </button>
370
321
  )}
371
322
  <Command.Input
@@ -454,7 +405,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
454
405
  <OptionItem
455
406
  isActive={level.id === activeLevelId}
456
407
  key={level.id}
457
- label={level.name ?? `Level ${level.level}`}
408
+ label={getLevelDisplayName(level)}
458
409
  onSelect={() =>
459
410
  run(() => useViewer.getState().setSelection({ levelId: level.id }))
460
411
  }
@@ -500,207 +451,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
500
451
  </Command.Item>
501
452
  </Command.Group>
502
453
  )}
503
-
504
- {/* ── Camera Snapshot: scope picker ─────────────────────────── */}
505
- {page === 'camera-view' && (
506
- <Command.Group heading="Camera Snapshot — Select Scope">
507
- <OptionItem
508
- icon={
509
- <svg
510
- className="h-4 w-4"
511
- fill="none"
512
- stroke="currentColor"
513
- strokeWidth={2}
514
- viewBox="0 0 24 24"
515
- >
516
- <path d="M3 3h18v18H3z" strokeLinecap="round" strokeLinejoin="round" />
517
- <path d="M3 9h18M9 21V9" strokeLinecap="round" strokeLinejoin="round" />
518
- </svg>
519
- }
520
- label="Site"
521
- onSelect={() => {
522
- const { rootNodeIds } = useScene.getState()
523
- const siteId = rootNodeIds[0]
524
- if (siteId) {
525
- setCameraScope({ nodeId: siteId, label: 'Site' })
526
- navigateTo('camera-scope')
527
- }
528
- }}
529
- />
530
- <OptionItem
531
- icon={
532
- <svg
533
- className="h-4 w-4"
534
- fill="none"
535
- stroke="currentColor"
536
- strokeWidth={2}
537
- viewBox="0 0 24 24"
538
- >
539
- <path
540
- d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
541
- strokeLinecap="round"
542
- strokeLinejoin="round"
543
- />
544
- <polyline
545
- points="9 22 9 12 15 12 15 22"
546
- strokeLinecap="round"
547
- strokeLinejoin="round"
548
- />
549
- </svg>
550
- }
551
- label="Building"
552
- onSelect={() => {
553
- const building = Object.values(useScene.getState().nodes).find(
554
- (n) => n.type === 'building',
555
- )
556
- if (building) {
557
- setCameraScope({ nodeId: building.id, label: 'Building' })
558
- navigateTo('camera-scope')
559
- }
560
- }}
561
- />
562
- <OptionItem
563
- disabled={!activeLevelId}
564
- icon={
565
- <svg
566
- className="h-4 w-4"
567
- fill="none"
568
- stroke="currentColor"
569
- strokeWidth={2}
570
- viewBox="0 0 24 24"
571
- >
572
- <path
573
- d="M12 2L2 7l10 5 10-5-10-5z"
574
- strokeLinecap="round"
575
- strokeLinejoin="round"
576
- />
577
- <path
578
- d="M2 17l10 5 10-5M2 12l10 5 10-5"
579
- strokeLinecap="round"
580
- strokeLinejoin="round"
581
- />
582
- </svg>
583
- }
584
- label="Level"
585
- onSelect={() => {
586
- if (activeLevelId) {
587
- setCameraScope({ nodeId: activeLevelId, label: 'Level' })
588
- navigateTo('camera-scope')
589
- }
590
- }}
591
- />
592
- <OptionItem
593
- disabled={!useViewer.getState().selection.selectedIds.length}
594
- icon={
595
- <svg
596
- className="h-4 w-4"
597
- fill="none"
598
- stroke="currentColor"
599
- strokeWidth={2}
600
- viewBox="0 0 24 24"
601
- >
602
- <path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" />
603
- </svg>
604
- }
605
- label="Selection"
606
- onSelect={() => {
607
- const firstId = useViewer.getState().selection.selectedIds[0]
608
- if (firstId) {
609
- setCameraScope({ nodeId: firstId, label: 'Selection' })
610
- navigateTo('camera-scope')
611
- }
612
- }}
613
- />
614
- </Command.Group>
615
- )}
616
-
617
- {/* ── Camera Snapshot: actions for selected scope ───────────── */}
618
- {page === 'camera-scope' && cameraScope && (
619
- <Command.Group heading={`${cameraScope.label} Snapshot`}>
620
- <OptionItem
621
- icon={
622
- <svg
623
- className="h-4 w-4"
624
- fill="none"
625
- stroke="currentColor"
626
- strokeWidth={2}
627
- viewBox="0 0 24 24"
628
- >
629
- <path
630
- 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"
631
- strokeLinecap="round"
632
- strokeLinejoin="round"
633
- />
634
- <circle
635
- cx="12"
636
- cy="13"
637
- r="4"
638
- strokeLinecap="round"
639
- strokeLinejoin="round"
640
- />
641
- </svg>
642
- }
643
- label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}
644
- onSelect={takeSnapshot}
645
- />
646
- {hasScopeSnapshot && (
647
- <OptionItem
648
- icon={
649
- <svg
650
- className="h-4 w-4"
651
- fill="none"
652
- stroke="currentColor"
653
- strokeWidth={2}
654
- viewBox="0 0 24 24"
655
- >
656
- <path
657
- d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
658
- strokeLinecap="round"
659
- strokeLinejoin="round"
660
- />
661
- <circle
662
- cx="12"
663
- cy="12"
664
- r="3"
665
- strokeLinecap="round"
666
- strokeLinejoin="round"
667
- />
668
- </svg>
669
- }
670
- label="View Snapshot"
671
- onSelect={viewSnapshot}
672
- />
673
- )}
674
- {hasScopeSnapshot && (
675
- <OptionItem
676
- icon={
677
- <svg
678
- className="h-4 w-4"
679
- fill="none"
680
- stroke="currentColor"
681
- strokeWidth={2}
682
- viewBox="0 0 24 24"
683
- >
684
- <polyline
685
- points="3 6 5 6 21 6"
686
- strokeLinecap="round"
687
- strokeLinejoin="round"
688
- />
689
- <path
690
- d="M19 6l-1 14H6L5 6"
691
- strokeLinecap="round"
692
- strokeLinejoin="round"
693
- />
694
- <path d="M10 11v6M14 11v6" strokeLinecap="round" strokeLinejoin="round" />
695
- <path d="M9 6V4h6v2" strokeLinecap="round" strokeLinejoin="round" />
696
- </svg>
697
- }
698
- label="Clear Snapshot"
699
- onSelect={clearSnapshot}
700
- />
701
- )}
702
- </Command.Group>
703
- )}
704
454
  </Command.List>
705
455
 
706
456
  {/* Footer hint */}
@@ -0,0 +1,83 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Eraser, RotateCcw } from 'lucide-react'
6
+ import { useEffect } from 'react'
7
+ import {
8
+ buildResetSurfaceMaterialUpdates,
9
+ resolvePaintTargetFromSelection,
10
+ } from './../../../lib/material-paint'
11
+ import useEditor from './../../../store/use-editor'
12
+ import { Button } from '../primitives/button'
13
+ import { MaterialPicker } from './material-picker'
14
+
15
+ /**
16
+ * Material picker for paint mode. Embedders render this wherever paint controls
17
+ * belong (the community editor places it in the Build sidebar while paint mode
18
+ * is active). It owns the paint-target/material wiring so the host only needs
19
+ * to mount it; it fills its container's width.
20
+ */
21
+ export function MaterialPaintPanel() {
22
+ const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
23
+ const activePaintTarget = useEditor((state) => state.activePaintTarget)
24
+ const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
25
+ const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
26
+ const paintEraser = useEditor((state) => state.paintEraser)
27
+ const setPaintEraser = useEditor((state) => state.setPaintEraser)
28
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
29
+ const nodes = useScene((state) => state.nodes)
30
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
31
+ const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
32
+ const canResetSelection =
33
+ selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null
34
+
35
+ useEffect(() => {
36
+ const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
37
+ if (selectedPaintTarget) {
38
+ setActivePaintTarget(selectedPaintTarget)
39
+ }
40
+ }, [nodes, selectedId, setActivePaintTarget])
41
+
42
+ const resetSelection = () => {
43
+ if (!selectedNode) return
44
+ useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
45
+ }
46
+
47
+ return (
48
+ <div className="w-full space-y-2">
49
+ <div className="flex items-center gap-2">
50
+ <Button
51
+ aria-pressed={paintEraser}
52
+ className="flex-1"
53
+ onClick={() => setPaintEraser(!paintEraser)}
54
+ size="sm"
55
+ variant={paintEraser ? 'default' : 'outline'}
56
+ >
57
+ <Eraser />
58
+ Erase
59
+ </Button>
60
+ <Button
61
+ className="flex-1"
62
+ disabled={!canResetSelection}
63
+ onClick={resetSelection}
64
+ size="sm"
65
+ variant="outline"
66
+ >
67
+ <RotateCcw />
68
+ Reset all
69
+ </Button>
70
+ </div>
71
+ <MaterialPicker
72
+ onChange={(material) => {
73
+ setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
74
+ }}
75
+ onSelectMaterialPreset={(materialPreset) => {
76
+ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
77
+ }}
78
+ selectedMaterialPreset={activePaintMaterial?.materialPreset}
79
+ value={activePaintMaterial?.material}
80
+ />
81
+ </div>
82
+ )
83
+ }
@@ -5,8 +5,9 @@ import {
5
5
  getLibraryMaterialIdFromRef,
6
6
  getMaterialsForCategory,
7
7
  MATERIAL_CATEGORIES,
8
- toLibraryMaterialRef,
9
8
  type MaterialSchema,
9
+ type MaterialTarget,
10
+ toLibraryMaterialRef,
10
11
  } from '@pascal-app/core'
11
12
  import { useEffect, useRef, useState } from 'react'
12
13
  import useEditor from '../../../store/use-editor'
@@ -17,6 +18,13 @@ type MaterialPickerProps = {
17
18
  onChange?: (material: MaterialSchema) => void
18
19
  onSelectMaterialPreset?: (materialPreset: string) => void
19
20
  disabled?: boolean
21
+ nodeType?: MaterialTarget
22
+ hideSideControl?: boolean
23
+ }
24
+
25
+ function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
26
+ if (category === 'roof') return 'Roofing'
27
+ return category.charAt(0).toUpperCase() + category.slice(1)
20
28
  }
21
29
 
22
30
  export function MaterialPicker({
@@ -31,7 +39,6 @@ export function MaterialPicker({
31
39
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
32
40
  MATERIAL_CATEGORIES[0],
33
41
  )
34
- const catalogScrollRef = useRef<HTMLDivElement>(null)
35
42
  const categoryScrollRef = useRef<HTMLDivElement>(null)
36
43
  const catalogItems =
37
44
  selectedCategory === 'other'
@@ -48,8 +55,7 @@ export function MaterialPicker({
48
55
  return
49
56
  }
50
57
 
51
- const catalogId =
52
- getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
58
+ const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
53
59
  const selectedCatalogEntry = getCatalogMaterialById(catalogId)
54
60
  if (selectedCatalogEntry?.category) {
55
61
  setSelectedCategory(selectedCatalogEntry.category)
@@ -66,27 +72,6 @@ export function MaterialPicker({
66
72
  onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
67
73
  }
68
74
 
69
- useEffect(() => {
70
- const container = catalogScrollRef.current
71
- if (!container) return
72
-
73
- const handleWheel = (event: WheelEvent) => {
74
- const deltaX = event.deltaX
75
- const deltaY = event.deltaY
76
- const nextScrollLeft = container.scrollLeft + deltaX + deltaY
77
-
78
- if (nextScrollLeft === container.scrollLeft) return
79
-
80
- event.preventDefault()
81
- container.scrollLeft = nextScrollLeft
82
- }
83
-
84
- container.addEventListener('wheel', handleWheel, { passive: false })
85
- return () => {
86
- container.removeEventListener('wheel', handleWheel)
87
- }
88
- }, [catalogItems.length, onChange, showCustom])
89
-
90
75
  useEffect(() => {
91
76
  const container = categoryScrollRef.current
92
77
  if (!container) return
@@ -154,20 +139,18 @@ export function MaterialPicker({
154
139
  }}
155
140
  type="button"
156
141
  >
157
- {category.charAt(0).toUpperCase() + category.slice(1)}
142
+ {getCategoryLabel(category)}
158
143
  </button>
159
144
  ))}
160
145
  </div>
161
146
  </div>
162
147
  <div
163
- className="w-full max-w-full overflow-x-auto overflow-y-hidden"
164
- ref={catalogScrollRef}
165
- style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
148
+ className="grid gap-1.5 pb-1"
149
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
166
150
  >
167
- <div className="flex min-w-max gap-1.5 pb-1">
168
- {catalogItems.map((item) => (
151
+ {catalogItems.map((item) => (
169
152
  <button
170
- className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
153
+ className={`relative aspect-square w-full overflow-hidden rounded-lg border transition-all ${
171
154
  selectedCatalogId === toLibraryMaterialRef(item.id)
172
155
  ? 'border-blue-500 ring-2 ring-blue-500/30'
173
156
  : 'border-gray-300 hover:border-gray-400'
@@ -177,7 +160,7 @@ export function MaterialPicker({
177
160
  title={item.label}
178
161
  type="button"
179
162
  >
180
- <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
163
+ <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
181
164
  {item.previewThumbnailUrl ? (
182
165
  <img
183
166
  alt={item.label}
@@ -193,7 +176,7 @@ export function MaterialPicker({
193
176
  ))}
194
177
  {selectedCategory === 'other' && onChange ? (
195
178
  <button
196
- className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
179
+ className={`flex aspect-square w-full items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
197
180
  showCustom
198
181
  ? 'border-blue-500 ring-2 ring-blue-500/30'
199
182
  : 'border-gray-300 hover:border-gray-400'
@@ -205,7 +188,6 @@ export function MaterialPicker({
205
188
  Custom
206
189
  </button>
207
190
  ) : null}
208
- </div>
209
191
  </div>
210
192
  </div>
211
193
  )}
@@ -9,24 +9,28 @@ interface MetricControlProps {
9
9
  label: React.ReactNode
10
10
  value: number
11
11
  onChange: (value: number) => void
12
+ onCommit?: (value: number) => void
12
13
  min?: number
13
14
  max?: number
14
15
  precision?: number
15
16
  step?: number
16
17
  className?: string
17
18
  unit?: string
19
+ restoreOnCommit?: boolean
18
20
  }
19
21
 
20
22
  export function MetricControl({
21
23
  label,
22
24
  value,
23
25
  onChange,
26
+ onCommit,
24
27
  min = Number.NEGATIVE_INFINITY,
25
28
  max = Number.POSITIVE_INFINITY,
26
29
  precision = 2,
27
30
  step = 1,
28
31
  className,
29
32
  unit = '',
33
+ restoreOnCommit = true,
30
34
  }: MetricControlProps) {
31
35
  const viewerUnit = useViewer((state) => state.unit)
32
36
  const isImperial = viewerUnit === 'imperial' && unit === 'm'
@@ -53,6 +57,17 @@ export function MetricControl({
53
57
  [min, max],
54
58
  )
55
59
 
60
+ const applyCommittedValue = useCallback(
61
+ (nextValue: number) => {
62
+ if (onCommit) {
63
+ onCommit(nextValue)
64
+ } else {
65
+ onChange(nextValue)
66
+ }
67
+ },
68
+ [onChange, onCommit],
69
+ )
70
+
56
71
  useEffect(() => {
57
72
  if (!isEditing) {
58
73
  setInputValue(displayValue.toFixed(precision))
@@ -77,13 +92,13 @@ export function MetricControl({
77
92
  const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
78
93
 
79
94
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
80
- onChange(finalValue)
95
+ applyCommittedValue(finalValue)
81
96
  }
82
97
  }
83
98
 
84
99
  container.addEventListener('wheel', handleWheel, { passive: false })
85
100
  return () => container.removeEventListener('wheel', handleWheel)
86
- }, [isEditing, step, clamp, onChange, precision, multiplier])
101
+ }, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
87
102
 
88
103
  useEffect(() => {
89
104
  if (!isHovered || isEditing) return
@@ -104,14 +119,14 @@ export function MetricControl({
104
119
  Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
105
120
 
106
121
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
107
- onChange(finalValue)
122
+ applyCommittedValue(finalValue)
108
123
  }
109
124
  }
110
125
  }
111
126
 
112
127
  window.addEventListener('keydown', handleKeyDown)
113
128
  return () => window.removeEventListener('keydown', handleKeyDown)
114
- }, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
129
+ }, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
115
130
 
116
131
  const handlePointerDown = useCallback(
117
132
  (e: React.PointerEvent) => {
@@ -148,7 +163,14 @@ export function MetricControl({
148
163
  document.removeEventListener('pointermove', handlePointerMove)
149
164
  document.removeEventListener('pointerup', handlePointerUp)
150
165
 
151
- if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
166
+ const changed = Math.abs(finalValue - startValueRef.current) > 1e-6
167
+ if (onCommit) {
168
+ if (changed && restoreOnCommit) {
169
+ onChange(startValueRef.current)
170
+ }
171
+ useScene.temporal.getState().resume()
172
+ onCommit(finalValue)
173
+ } else if (changed) {
152
174
  onChange(startValueRef.current)
153
175
  useScene.temporal.getState().resume()
154
176
  onChange(finalValue)
@@ -160,7 +182,7 @@ export function MetricControl({
160
182
  document.addEventListener('pointermove', handlePointerMove)
161
183
  document.addEventListener('pointerup', handlePointerUp)
162
184
  },
163
- [isEditing, value, onChange, clamp, precision, step, multiplier],
185
+ [isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
164
186
  )
165
187
 
166
188
  const handleValueClick = useCallback(() => {
@@ -177,10 +199,10 @@ export function MetricControl({
177
199
  if (Number.isNaN(numValue)) {
178
200
  setInputValue((value * multiplier).toFixed(precision))
179
201
  } else {
180
- onChange(clamp(numValue / multiplier))
202
+ applyCommittedValue(clamp(numValue / multiplier))
181
203
  }
182
204
  setIsEditing(false)
183
- }, [inputValue, onChange, clamp, multiplier, value, precision])
205
+ }, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
184
206
 
185
207
  const handleInputBlur = useCallback(() => {
186
208
  submitValue()
@@ -196,16 +218,16 @@ export function MetricControl({
196
218
  } else if (e.key === 'ArrowUp') {
197
219
  e.preventDefault()
198
220
  const newV = clamp(value + step / multiplier)
199
- onChange(newV)
221
+ applyCommittedValue(newV)
200
222
  setInputValue((newV * multiplier).toFixed(precision))
201
223
  } else if (e.key === 'ArrowDown') {
202
224
  e.preventDefault()
203
225
  const newV = clamp(value - step / multiplier)
204
- onChange(newV)
226
+ applyCommittedValue(newV)
205
227
  setInputValue((newV * multiplier).toFixed(precision))
206
228
  }
207
229
  },
208
- [submitValue, value, multiplier, precision, step, clamp, onChange],
230
+ [submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
209
231
  )
210
232
 
211
233
  return (