@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,15 +1,4 @@
1
- 'use client'
2
-
3
- import NextImage from 'next/image'
4
- import { useContextualTools } from '../../../hooks/use-contextual-tools'
5
-
6
- import { cn } from '../../../lib/utils'
7
- import useEditor, {
8
- type CatalogCategory,
9
- type StructureTool,
10
- type Tool,
11
- } from '../../../store/use-editor'
12
- import { ActionButton } from './action-button'
1
+ import type { CatalogCategory, StructureTool } from '../../../store/use-editor'
13
2
 
14
3
  export type ToolConfig = {
15
4
  id: StructureTool
@@ -18,86 +7,22 @@ export type ToolConfig = {
18
7
  catalogCategory?: CatalogCategory
19
8
  }
20
9
 
10
+ // Shared structure-tool metadata (icons + labels). The build palette now lives
11
+ // in the community Build sidebar; this list survives only as the lookup table
12
+ // for cursor/floorplan indicators. Roof-mounted accessories are intentionally
13
+ // absent — they're placed from the roof inspector's "Add element" section.
21
14
  export const tools: ToolConfig[] = [
22
15
  { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
23
- // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
24
- // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
25
- { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
26
- { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
27
- { id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
28
- { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
29
- { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
30
16
  { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
31
17
  { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
18
+ { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
19
+ { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
32
20
  { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
21
+ { id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
22
+ { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
23
+ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
24
+ { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
33
25
  { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
34
- { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
26
+ { id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
27
+ { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
35
28
  ]
36
-
37
- export function StructureTools() {
38
- const activeTool = useEditor((state) => state.tool)
39
- const catalogCategory = useEditor((state) => state.catalogCategory)
40
- const structureLayer = useEditor((state) => state.structureLayer)
41
- const setTool = useEditor((state) => state.setTool)
42
- const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
43
-
44
- const contextualTools = useContextualTools()
45
-
46
- // Filter tools based on structureLayer
47
- const visibleTools =
48
- structureLayer === 'zones'
49
- ? tools.filter((t) => t.id === 'zone')
50
- : tools.filter((t) => t.id !== 'zone')
51
-
52
- const hasActiveTool = visibleTools.some(
53
- (t) =>
54
- activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
55
- )
56
-
57
- return (
58
- <div className="flex items-center gap-1.5 px-1">
59
- {visibleTools.map((tool, index) => {
60
- // For item tools with catalog category, check both tool and category match
61
- const isActive =
62
- activeTool === tool.id &&
63
- (tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
64
-
65
- const isContextual = contextualTools.includes(tool.id)
66
-
67
- return (
68
- <ActionButton
69
- className={cn(
70
- 'rounded-lg duration-300',
71
- isActive
72
- ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
73
- : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
74
- )}
75
- key={`${tool.id}-${tool.catalogCategory ?? index}`}
76
- label={tool.label}
77
- onClick={() => {
78
- if (!isActive) {
79
- setTool(tool.id)
80
- setCatalogCategory(tool.catalogCategory ?? null)
81
-
82
- // Automatically switch to build mode if we select a tool
83
- if (useEditor.getState().mode !== 'build') {
84
- useEditor.getState().setMode('build')
85
- }
86
- }
87
- }}
88
- size="icon"
89
- variant="ghost"
90
- >
91
- <NextImage
92
- alt={tool.label}
93
- className="size-full object-contain"
94
- height={28}
95
- src={tool.iconSrc}
96
- width={28}
97
- />
98
- </ActionButton>
99
- )
100
- })}
101
- </div>
102
- )
103
- }
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
13
13
  import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
14
14
  import { useCallback, useRef, useState } from 'react'
15
15
  import { useShallow } from 'zustand/react/shallow'
16
+ import { getLevelDisplayName } from '../../../lib/level-name'
16
17
  import { createLocalGuideImage } from '../../../lib/local-guide-image'
17
18
  import { cn } from '../../../lib/utils'
18
19
  import useEditor, { type GridSnapStep } from '../../../store/use-editor'
@@ -84,10 +85,6 @@ function useLowerReferenceLevels(): LevelNode[] {
84
85
  )
85
86
  }
86
87
 
87
- function getLevelDisplayName(level: LevelNode) {
88
- return level.name || `Level ${level.level}`
89
- }
90
-
91
88
  // ── Shared upload button for dropdowns ──────────────────────────────────────
92
89
 
93
90
  function UploadButton({ onError }: { onError: (message: string | null) => void }) {
@@ -593,6 +590,247 @@ function ScansControl() {
593
590
  )
594
591
  }
595
592
 
593
+ // ── References (merged scans + guides) ──────────────────────────────────────
594
+ // Bottom-bar control that folds the separate Scans and Guides toggles into one
595
+ // "References" split button + a popover holding both, each with its own
596
+ // visibility toggle, upload, and per-item opacity/delete.
597
+
598
+ function ReferenceListSection({
599
+ title,
600
+ iconSrc,
601
+ noun,
602
+ emptyText,
603
+ nodes,
604
+ show,
605
+ setShow,
606
+ onError,
607
+ }: {
608
+ title: string
609
+ iconSrc: string
610
+ noun: string
611
+ emptyText: string
612
+ nodes: (GuideNode | ScanNode)[]
613
+ show: boolean
614
+ setShow: (show: boolean) => void
615
+ onError: (message: string | null) => void
616
+ }) {
617
+ const setSelection = useViewer((state) => state.setSelection)
618
+ const updateNode = useScene((state) => state.updateNode)
619
+ const deleteNode = useScene((state) => state.deleteNode)
620
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
621
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
622
+ const hasItems = nodes.length > 0
623
+
624
+ const handleSelect = useCallback(
625
+ (id: AnyNodeId) => {
626
+ setShow(true)
627
+ setSelectedReferenceId(id)
628
+ setSelection({ selectedIds: [], zoneId: null })
629
+ },
630
+ [setShow, setSelectedReferenceId, setSelection],
631
+ )
632
+
633
+ return (
634
+ <div className="space-y-2">
635
+ <div className="flex items-center gap-2">
636
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
637
+ <img alt="" className="h-4 w-4 object-contain" src={iconSrc} />
638
+ </span>
639
+ <div className="min-w-0 flex-1">
640
+ <p className="font-medium text-foreground text-sm">{title}</p>
641
+ {hasItems && (
642
+ <p className="text-muted-foreground text-xs">
643
+ {nodes.length} {noun}
644
+ {nodes.length !== 1 ? 's' : ''} on this level
645
+ </p>
646
+ )}
647
+ </div>
648
+ <button
649
+ aria-label={show ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
650
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
651
+ onClick={() => setShow(!show)}
652
+ type="button"
653
+ >
654
+ {show ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
655
+ </button>
656
+ <UploadButton onError={onError} />
657
+ </div>
658
+
659
+ {hasItems ? (
660
+ <div className="max-h-40 space-y-2 overflow-y-auto pr-1">
661
+ {nodes.map((node, index) => (
662
+ <div
663
+ className={cn(
664
+ 'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
665
+ selectedReferenceId === node.id
666
+ ? 'border-foreground/35 bg-white/10'
667
+ : 'border-border/45',
668
+ )}
669
+ key={node.id}
670
+ >
671
+ <div className="flex min-w-0 items-center gap-2">
672
+ <button
673
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
674
+ onClick={() => handleSelect(node.id)}
675
+ type="button"
676
+ >
677
+ <img
678
+ alt=""
679
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
680
+ src={iconSrc}
681
+ />
682
+ <p className="truncate font-medium text-foreground text-sm">
683
+ {node.name || `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${index + 1}`}
684
+ </p>
685
+ {selectedReferenceId === node.id && (
686
+ <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
687
+ )}
688
+ </button>
689
+ <button
690
+ aria-label={`Delete ${noun}`}
691
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
692
+ onClick={(event) => {
693
+ event.stopPropagation()
694
+ deleteNode(node.id)
695
+ if (selectedReferenceId === node.id) {
696
+ setSelectedReferenceId(null)
697
+ }
698
+ }}
699
+ type="button"
700
+ >
701
+ <Trash2 className="h-3 w-3" />
702
+ </button>
703
+ </div>
704
+ <SliderControl
705
+ label="Opacity"
706
+ max={100}
707
+ min={0}
708
+ onChange={(value) =>
709
+ updateNode(node.id, { opacity: Math.round(Math.min(100, Math.max(0, value))) })
710
+ }
711
+ precision={0}
712
+ step={1}
713
+ unit="%"
714
+ value={node.opacity}
715
+ />
716
+ </div>
717
+ ))}
718
+ </div>
719
+ ) : (
720
+ <div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-3 text-muted-foreground text-sm">
721
+ {emptyText}
722
+ </div>
723
+ )}
724
+ </div>
725
+ )
726
+ }
727
+
728
+ function ReferencesControl() {
729
+ const showScans = useViewer((state) => state.showScans)
730
+ const setShowScans = useViewer((state) => state.setShowScans)
731
+ const showGuides = useViewer((state) => state.showGuides)
732
+ const setShowGuides = useViewer((state) => state.setShowGuides)
733
+ const [isOpen, setIsOpen] = useState(false)
734
+ const [uploadError, setUploadError] = useState<string | null>(null)
735
+
736
+ const scans = useLevelScans()
737
+ const guides = useLevelGuides()
738
+ const total = scans.length + guides.length
739
+ const anyVisible = showScans || showGuides
740
+
741
+ const toggleAll = useCallback(() => {
742
+ const next = !anyVisible
743
+ setShowScans(next)
744
+ setShowGuides(next)
745
+ }, [anyVisible, setShowScans, setShowGuides])
746
+
747
+ return (
748
+ <Popover onOpenChange={setIsOpen} open={isOpen}>
749
+ <div className="flex items-center">
750
+ <ActionButton
751
+ className={cn(
752
+ 'rounded-r-none p-0',
753
+ anyVisible
754
+ ? 'bg-white/15'
755
+ : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
756
+ )}
757
+ label={`References: ${anyVisible ? 'Visible' : 'Hidden'}`}
758
+ onClick={toggleAll}
759
+ size="icon"
760
+ variant="ghost"
761
+ >
762
+ <div className="relative">
763
+ <img
764
+ alt="References"
765
+ className="h-[28px] w-[28px] object-contain"
766
+ src="/icons/floorplan.png"
767
+ />
768
+ <span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
769
+ {total}
770
+ </span>
771
+ </div>
772
+ </ActionButton>
773
+
774
+ <PopoverTrigger asChild>
775
+ <button
776
+ aria-expanded={isOpen}
777
+ aria-label="Reference settings"
778
+ className={cn(
779
+ 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
780
+ anyVisible
781
+ ? isOpen
782
+ ? 'bg-white/10'
783
+ : 'bg-white/5 hover:bg-white/8'
784
+ : isOpen
785
+ ? 'bg-white/8'
786
+ : 'opacity-60 hover:bg-white/5 hover:opacity-100',
787
+ )}
788
+ type="button"
789
+ >
790
+ <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
791
+ </button>
792
+ </PopoverTrigger>
793
+ </div>
794
+
795
+ <PopoverContent
796
+ align="center"
797
+ className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
798
+ side="top"
799
+ sideOffset={14}
800
+ >
801
+ <div className="space-y-3">
802
+ {uploadError && (
803
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
804
+ {uploadError}
805
+ </div>
806
+ )}
807
+ <ReferenceListSection
808
+ emptyText="No scans on this level yet."
809
+ iconSrc="/icons/mesh.png"
810
+ nodes={scans}
811
+ noun="scan"
812
+ onError={setUploadError}
813
+ setShow={setShowScans}
814
+ show={showScans}
815
+ title="Scans"
816
+ />
817
+ <div className="h-px bg-border/45" />
818
+ <ReferenceListSection
819
+ emptyText="No guide images on this level yet."
820
+ iconSrc="/icons/floorplan.png"
821
+ nodes={guides}
822
+ noun="guide image"
823
+ onError={setUploadError}
824
+ setShow={setShowGuides}
825
+ show={showGuides}
826
+ title="Guide images"
827
+ />
828
+ </div>
829
+ </PopoverContent>
830
+ </Popover>
831
+ )
832
+ }
833
+
596
834
  // ── Reference floor control ────────────────────────────────────────────────────────────────────
597
835
 
598
836
  function ReferenceFloorControl() {
@@ -756,8 +994,7 @@ export { GridSnapControl }
756
994
  export function SecondaryToggles() {
757
995
  return (
758
996
  <div className="flex items-center gap-1">
759
- <ScansControl />
760
- <GuidesControl />
997
+ <ReferencesControl />
761
998
  </div>
762
999
  )
763
1000
  }
@@ -27,6 +27,7 @@ import {
27
27
  PencilLine,
28
28
  Plus,
29
29
  Redo2,
30
+ Sparkles,
30
31
  Square,
31
32
  SquareStack,
32
33
  Sun,
@@ -190,8 +191,11 @@ export function EditorCommands() {
190
191
  const { nodes } = useScene.getState()
191
192
  const building = Object.values(nodes).find((n) => n.type === 'building')
192
193
  if (!building) return
194
+ const levelCount = building.children.filter(
195
+ (childId) => nodes[childId as keyof typeof nodes]?.type === 'level',
196
+ ).length
193
197
  const newLevel = LevelNode.parse({
194
- level: building.children.length,
198
+ level: levelCount,
195
199
  children: [],
196
200
  parentId: building.id,
197
201
  })
@@ -278,19 +282,20 @@ export function EditorCommands() {
278
282
  }),
279
283
  },
280
284
  {
281
- id: 'editor.viewer.theme',
282
- label: () => {
283
- const theme = useViewer.getState().theme
284
- return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
285
- },
285
+ id: 'editor.viewer.shading-solid',
286
+ label: 'Switch to Solid',
286
287
  group: 'Viewer Controls',
287
- icon: <Sun className="h-4 w-4" />, // icon is static; label conveys the action
288
- keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
289
- execute: () =>
290
- run(() => {
291
- const { theme, setTheme } = useViewer.getState()
292
- setTheme(theme === 'dark' ? 'light' : 'dark')
293
- }),
288
+ icon: <Box className="h-4 w-4" />,
289
+ keywords: ['solid', 'shading', 'render', 'mode', 'performance'],
290
+ execute: () => run(() => useViewer.getState().setShading('solid')),
291
+ },
292
+ {
293
+ id: 'editor.viewer.shading-rendered',
294
+ label: 'Switch to Rendered',
295
+ group: 'Viewer Controls',
296
+ icon: <Sparkles className="h-4 w-4" />,
297
+ keywords: ['rendered', 'shading', 'render', 'mode', 'quality'],
298
+ execute: () => run(() => useViewer.getState().setShading('rendered')),
294
299
  },
295
300
  {
296
301
  id: 'editor.viewer.camera-snapshot',
@@ -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
 
@@ -404,7 +405,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
404
405
  <OptionItem
405
406
  isActive={level.id === activeLevelId}
406
407
  key={level.id}
407
- label={level.name ?? `Level ${level.level}`}
408
+ label={getLevelDisplayName(level)}
408
409
  onSelect={() =>
409
410
  run(() => useViewer.getState().setSelection({ levelId: level.id }))
410
411
  }
@@ -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
+ }
@@ -4,7 +4,6 @@ import {
4
4
  getCatalogMaterialById,
5
5
  getLibraryMaterialIdFromRef,
6
6
  getMaterialsForCategory,
7
- getMaterialsForTarget,
8
7
  MATERIAL_CATEGORIES,
9
8
  type MaterialSchema,
10
9
  type MaterialTarget,
@@ -23,6 +22,11 @@ type MaterialPickerProps = {
23
22
  hideSideControl?: boolean
24
23
  }
25
24
 
25
+ function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
26
+ if (category === 'roof') return 'Roofing'
27
+ return category.charAt(0).toUpperCase() + category.slice(1)
28
+ }
29
+
26
30
  export function MaterialPicker({
27
31
  value,
28
32
  selectedMaterialPreset,
@@ -35,7 +39,6 @@ export function MaterialPicker({
35
39
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
36
40
  MATERIAL_CATEGORIES[0],
37
41
  )
38
- const catalogScrollRef = useRef<HTMLDivElement>(null)
39
42
  const categoryScrollRef = useRef<HTMLDivElement>(null)
40
43
  const catalogItems =
41
44
  selectedCategory === 'other'
@@ -69,27 +72,6 @@ export function MaterialPicker({
69
72
  onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
70
73
  }
71
74
 
72
- useEffect(() => {
73
- const container = catalogScrollRef.current
74
- if (!container) return
75
-
76
- const handleWheel = (event: WheelEvent) => {
77
- const deltaX = event.deltaX
78
- const deltaY = event.deltaY
79
- const nextScrollLeft = container.scrollLeft + deltaX + deltaY
80
-
81
- if (nextScrollLeft === container.scrollLeft) return
82
-
83
- event.preventDefault()
84
- container.scrollLeft = nextScrollLeft
85
- }
86
-
87
- container.addEventListener('wheel', handleWheel, { passive: false })
88
- return () => {
89
- container.removeEventListener('wheel', handleWheel)
90
- }
91
- }, [catalogItems.length, onChange, showCustom])
92
-
93
75
  useEffect(() => {
94
76
  const container = categoryScrollRef.current
95
77
  if (!container) return
@@ -157,20 +139,18 @@ export function MaterialPicker({
157
139
  }}
158
140
  type="button"
159
141
  >
160
- {category.charAt(0).toUpperCase() + category.slice(1)}
142
+ {getCategoryLabel(category)}
161
143
  </button>
162
144
  ))}
163
145
  </div>
164
146
  </div>
165
147
  <div
166
- className="w-full max-w-full overflow-x-auto overflow-y-hidden"
167
- ref={catalogScrollRef}
168
- style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
148
+ className="grid gap-1.5 pb-1"
149
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
169
150
  >
170
- <div className="flex min-w-max gap-1.5 pb-1">
171
- {catalogItems.map((item) => (
151
+ {catalogItems.map((item) => (
172
152
  <button
173
- 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 ${
174
154
  selectedCatalogId === toLibraryMaterialRef(item.id)
175
155
  ? 'border-blue-500 ring-2 ring-blue-500/30'
176
156
  : 'border-gray-300 hover:border-gray-400'
@@ -196,7 +176,7 @@ export function MaterialPicker({
196
176
  ))}
197
177
  {selectedCategory === 'other' && onChange ? (
198
178
  <button
199
- className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
179
+ className={`flex aspect-square w-full items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
200
180
  showCustom
201
181
  ? 'border-blue-500 ring-2 ring-blue-500/30'
202
182
  : 'border-gray-300 hover:border-gray-400'
@@ -208,7 +188,6 @@ export function MaterialPicker({
208
188
  Custom
209
189
  </button>
210
190
  ) : null}
211
- </div>
212
191
  </div>
213
192
  </div>
214
193
  )}