@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,22 +1,31 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ generateSceneMaterialId,
6
+ type SceneMaterialId,
7
+ toSceneMaterialRef,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import { useViewer } from '@pascal-app/viewer'
5
- import { Eraser, RotateCcw } from 'lucide-react'
6
- import { useEffect } from 'react'
11
+ import { Eraser, Plus, RotateCcw } from 'lucide-react'
12
+ import { useEffect, useState } from 'react'
7
13
  import {
8
14
  buildResetSurfaceMaterialUpdates,
9
15
  resolvePaintTargetFromSelection,
10
16
  } from './../../../lib/material-paint'
11
17
  import useEditor from './../../../store/use-editor'
12
18
  import { Button } from '../primitives/button'
19
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
13
20
  import { MaterialPicker } from './material-picker'
21
+ import { SceneMaterialList } from './scene-material-list'
14
22
 
15
23
  /**
16
24
  * Material picker for paint mode. Embedders render this wherever paint controls
17
25
  * 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.
26
+ * is active). It fills its container's height and lays out as three bands: a
27
+ * fixed control/category header, a single scrolling catalog grid, and a fixed
28
+ * scene-material footer (always visible, with a `+` to add a custom material).
20
29
  */
21
30
  export function MaterialPaintPanel() {
22
31
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
@@ -25,8 +34,11 @@ export function MaterialPaintPanel() {
25
34
  const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
26
35
  const paintEraser = useEditor((state) => state.paintEraser)
27
36
  const setPaintEraser = useEditor((state) => state.setPaintEraser)
37
+ // Id of a just-created scene material whose inline editor should open on mount.
38
+ const [autoEditMaterialId, setAutoEditMaterialId] = useState<SceneMaterialId | null>(null)
28
39
  const selectedIds = useViewer((state) => state.selection.selectedIds)
29
40
  const nodes = useScene((state) => state.nodes)
41
+ const materialCount = useScene((state) => Object.keys(state.materials).length)
30
42
  const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
31
43
  const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
32
44
  const canResetSelection =
@@ -44,9 +56,34 @@ export function MaterialPaintPanel() {
44
56
  useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
45
57
  }
46
58
 
59
+ // Create a blank custom scene material, select it as the brush (`scene:` ref so
60
+ // edits propagate), and open its inline editor. Available from any category.
61
+ const createCustomMaterial = () => {
62
+ const id = generateSceneMaterialId()
63
+ const count = Object.keys(useScene.getState().materials).length
64
+ useScene.getState().addSceneMaterial({
65
+ id,
66
+ name: `Material ${count + 1}`,
67
+ material: {
68
+ preset: 'custom',
69
+ properties: {
70
+ color: '#ffffff',
71
+ roughness: 0.5,
72
+ metalness: 0,
73
+ opacity: 1,
74
+ transparent: false,
75
+ side: 'front',
76
+ },
77
+ },
78
+ })
79
+ setActivePaintMaterial({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
80
+ setAutoEditMaterialId(id)
81
+ }
82
+
47
83
  return (
48
- <div className="w-full space-y-2">
49
- <div className="flex items-center gap-2">
84
+ <div className="flex h-full min-h-0 w-full flex-col">
85
+ {/* Fixed: eraser / reset. */}
86
+ <div className="flex shrink-0 items-center gap-2 pb-2">
50
87
  <Button
51
88
  aria-pressed={paintEraser}
52
89
  className="flex-1"
@@ -68,16 +105,48 @@ export function MaterialPaintPanel() {
68
105
  Reset all
69
106
  </Button>
70
107
  </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
- />
108
+
109
+ {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
110
+ <div className="min-h-0 flex-1">
111
+ <MaterialPicker
112
+ onSelectMaterialPreset={(materialPreset) => {
113
+ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
114
+ }}
115
+ selectedMaterialPreset={activePaintMaterial?.materialPreset}
116
+ />
117
+ </div>
118
+
119
+ {/* Fixed footer: scene materials, always visible, with a `+` to add one. */}
120
+ <div className="mt-2 shrink-0 space-y-1.5 border-border/60 border-t pt-2">
121
+ <div className="flex items-center justify-between">
122
+ <span className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
123
+ Scene materials
124
+ </span>
125
+ <Tooltip>
126
+ <TooltipTrigger asChild>
127
+ <Button
128
+ aria-label="Add material"
129
+ onClick={createCustomMaterial}
130
+ size="icon-sm"
131
+ type="button"
132
+ variant="outline"
133
+ >
134
+ <Plus />
135
+ </Button>
136
+ </TooltipTrigger>
137
+ <TooltipContent>Add material</TooltipContent>
138
+ </Tooltip>
139
+ </div>
140
+ <div className="subtle-scrollbar max-h-56 overflow-y-auto">
141
+ {materialCount > 0 ? (
142
+ <SceneMaterialList autoEditId={autoEditMaterialId} />
143
+ ) : (
144
+ <p className="px-0.5 py-1 text-muted-foreground text-xs">
145
+ No custom materials yet — add one with +.
146
+ </p>
147
+ )}
148
+ </div>
149
+ </div>
81
150
  </div>
82
151
  )
83
152
  }
@@ -5,17 +5,14 @@ import {
5
5
  getLibraryMaterialIdFromRef,
6
6
  getMaterialsForCategory,
7
7
  MATERIAL_CATEGORIES,
8
- type MaterialSchema,
9
8
  type MaterialTarget,
10
9
  toLibraryMaterialRef,
11
10
  } from '@pascal-app/core'
12
- import { useEffect, useRef, useState } from 'react'
13
- import useEditor from '../../../store/use-editor'
11
+ import { useEffect, useState } from 'react'
12
+ import { triggerSFX } from '../../../lib/sfx-bus'
14
13
 
15
14
  type MaterialPickerProps = {
16
- value?: MaterialSchema
17
15
  selectedMaterialPreset?: string
18
- onChange?: (material: MaterialSchema) => void
19
16
  onSelectMaterialPreset?: (materialPreset: string) => void
20
17
  disabled?: boolean
21
18
  nodeType?: MaterialTarget
@@ -23,174 +20,108 @@ type MaterialPickerProps = {
23
20
  }
24
21
 
25
22
  function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
26
- if (category === 'roof') return 'Roofing'
27
23
  return category.charAt(0).toUpperCase() + category.slice(1)
28
24
  }
29
25
 
26
+ /**
27
+ * Catalog material picker: a fixed row of category tabs over a scrollable grid
28
+ * of swatches. Custom-material creation lives in the scene-material section
29
+ * (the host's `+` action), not here, so it's available from any category.
30
+ */
30
31
  export function MaterialPicker({
31
- value,
32
32
  selectedMaterialPreset,
33
- onChange,
34
33
  onSelectMaterialPreset,
35
34
  disabled = false,
36
35
  }: MaterialPickerProps) {
37
- const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
38
- const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
39
36
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
40
37
  MATERIAL_CATEGORIES[0],
41
38
  )
42
- const categoryScrollRef = useRef<HTMLDivElement>(null)
43
- const catalogItems =
44
- selectedCategory === 'other'
45
- ? getMaterialsForCategory('other')
46
- : getMaterialsForCategory(selectedCategory)
47
-
48
- useEffect(() => {
49
- setShowCustom(!!value?.properties && !selectedMaterialPreset)
50
- }, [selectedMaterialPreset, value?.properties])
39
+ const availableCategories = MATERIAL_CATEGORIES.filter(
40
+ (category) => getMaterialsForCategory(category).length > 0,
41
+ )
42
+ const catalogItems = getMaterialsForCategory(selectedCategory)
51
43
 
44
+ // Keep the visible category in sync with the externally-selected catalog
45
+ // material (a `scene:` ref matches no catalog entry, so the tab stays put).
52
46
  useEffect(() => {
53
- if (!selectedMaterialPreset && value?.properties) {
54
- setSelectedCategory('other')
55
- return
56
- }
57
-
58
- const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
59
- const selectedCatalogEntry = getCatalogMaterialById(catalogId)
60
- if (selectedCatalogEntry?.category) {
61
- setSelectedCategory(selectedCatalogEntry.category)
62
- }
63
- }, [selectedMaterialPreset, value?.id])
64
-
65
- const selectedCatalogId =
66
- selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
47
+ const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
48
+ const entry = getCatalogMaterialById(catalogId)
49
+ if (entry?.category) setSelectedCategory(entry.category)
50
+ }, [selectedMaterialPreset])
67
51
 
68
52
  const handleCatalogSelect = (materialId: string) => {
69
53
  if (disabled) return
70
- setShowCustom(false)
71
- setPaintPanelOpen(false)
72
54
  onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
73
55
  }
74
56
 
75
- useEffect(() => {
76
- const container = categoryScrollRef.current
77
- if (!container) return
78
-
79
- const handleWheel = (event: WheelEvent) => {
80
- const deltaX = event.deltaX
81
- const deltaY = event.deltaY
82
- const nextScrollLeft = container.scrollLeft + deltaX + deltaY
83
-
84
- if (nextScrollLeft === container.scrollLeft) return
85
-
86
- event.preventDefault()
87
- container.scrollLeft = nextScrollLeft
88
- }
89
-
90
- container.addEventListener('wheel', handleWheel, { passive: false })
91
- return () => {
92
- container.removeEventListener('wheel', handleWheel)
93
- }
94
- }, [])
95
-
96
- const handleCustomOpen = () => {
97
- if (disabled) return
98
- setShowCustom(true)
99
- setPaintPanelOpen(true)
100
- onChange?.({
101
- preset: 'custom',
102
- properties: {
103
- color: value?.properties?.color || '#ffffff',
104
- roughness: value?.properties?.roughness ?? 0.5,
105
- metalness: value?.properties?.metalness ?? 0,
106
- opacity: value?.properties?.opacity ?? 1,
107
- transparent: value?.properties?.transparent ?? false,
108
- side: value?.properties?.side ?? 'front',
109
- },
110
- })
111
- }
112
-
113
57
  return (
114
- <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
115
- {(catalogItems.length > 0 || onChange) && (
116
- <div className="min-w-0 space-y-1">
117
- <div
118
- className="w-full max-w-full overflow-x-auto overflow-y-hidden"
119
- ref={categoryScrollRef}
120
- style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
121
- >
122
- <div className="flex min-w-max gap-1 pb-1">
123
- {MATERIAL_CATEGORIES.map((category) => (
124
- <button
125
- className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
126
- selectedCategory === category
127
- ? 'bg-transparent text-foreground'
128
- : 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
129
- }`}
130
- key={category}
131
- onClick={() => {
132
- setSelectedCategory(category)
133
- if (showCustom) {
134
- setShowCustom(false)
135
- }
136
- if (category !== 'other') {
137
- setPaintPanelOpen(false)
138
- }
139
- }}
140
- type="button"
141
- >
142
- {getCategoryLabel(category)}
143
- </button>
144
- ))}
145
- </div>
146
- </div>
147
- <div
148
- className="grid gap-1.5 pb-1"
149
- style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
58
+ <div
59
+ className={`flex h-full min-h-0 flex-col gap-2 ${disabled ? 'pointer-events-none opacity-50' : ''}`}
60
+ >
61
+ {/* Fixed category tabs — outside the scroll region. */}
62
+ <div className="flex shrink-0 flex-wrap gap-1">
63
+ {availableCategories.map((category) => (
64
+ <button
65
+ className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${
66
+ selectedCategory === category
67
+ ? 'bg-foreground text-background'
68
+ : 'text-muted-foreground hover:text-foreground'
69
+ }`}
70
+ key={category}
71
+ onClick={() => {
72
+ setSelectedCategory(category)
73
+ // Auto-select the first material in the category so the brush is
74
+ // immediately ready (and the swatch shows as selected).
75
+ const first = getMaterialsForCategory(category)[0]
76
+ if (first) handleCatalogSelect(first.id)
77
+ }}
78
+ type="button"
150
79
  >
151
- {catalogItems.map((item) => (
152
- <button
153
- className={`relative aspect-square w-full overflow-hidden rounded-lg border transition-all ${
154
- selectedCatalogId === toLibraryMaterialRef(item.id)
155
- ? 'border-blue-500 ring-2 ring-blue-500/30'
156
- : 'border-gray-300 hover:border-gray-400'
157
- }`}
158
- key={item.id}
159
- onClick={() => handleCatalogSelect(item.id)}
160
- title={item.label}
161
- type="button"
162
- >
163
- <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
164
- {item.previewThumbnailUrl ? (
165
- <img
166
- alt={item.label}
167
- className="h-full w-full object-cover"
168
- src={item.previewThumbnailUrl}
169
- />
170
- ) : item.previewColor ? (
171
- <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
172
- ) : (
173
- <div className="h-full w-full bg-gray-100" />
174
- )}
175
- </button>
176
- ))}
177
- {selectedCategory === 'other' && onChange ? (
178
- <button
179
- className={`flex aspect-square w-full items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
180
- showCustom
181
- ? 'border-blue-500 ring-2 ring-blue-500/30'
182
- : 'border-gray-300 hover:border-gray-400'
183
- }`}
184
- onClick={handleCustomOpen}
185
- title="Custom"
186
- type="button"
187
- >
188
- Custom
189
- </button>
190
- ) : null}
191
- </div>
192
- </div>
193
- )}
80
+ {getCategoryLabel(category)}
81
+ </button>
82
+ ))}
83
+ </div>
84
+ {/* The only scrolling region. */}
85
+ <div
86
+ className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1"
87
+ style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
88
+ >
89
+ {catalogItems.map((item) => {
90
+ const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
91
+ return (
92
+ <button
93
+ className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
94
+ isSelected ? 'bg-sidebar-accent ring-1 ring-primary ring-inset' : ''
95
+ }`}
96
+ key={item.id}
97
+ onClick={() => {
98
+ triggerSFX('sfx:menu-click')
99
+ handleCatalogSelect(item.id)
100
+ }}
101
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
102
+ type="button"
103
+ >
104
+ <div className="relative aspect-square w-full overflow-hidden rounded-lg">
105
+ {item.previewThumbnailUrl ? (
106
+ <img
107
+ alt={item.label}
108
+ className="h-full w-full object-cover"
109
+ src={item.previewThumbnailUrl}
110
+ />
111
+ ) : (
112
+ <div
113
+ className="h-full w-full"
114
+ style={{ backgroundColor: item.previewColor ?? '#f3f4f6' }}
115
+ />
116
+ )}
117
+ </div>
118
+ <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
119
+ {item.label}
120
+ </span>
121
+ </button>
122
+ )
123
+ })}
124
+ </div>
194
125
  </div>
195
126
  )
196
127
  }
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import type { MaterialProperties, MaterialSchema } from '@pascal-app/core'
4
+ import { Input } from '../primitives/input'
5
+ import { SliderControl } from './slider-control'
6
+
7
+ const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = {
8
+ color: '#ffffff',
9
+ roughness: 0.5,
10
+ metalness: 0,
11
+ opacity: 1,
12
+ transparent: false,
13
+ side: 'front',
14
+ }
15
+
16
+ export function MaterialPropertiesEditor({
17
+ value,
18
+ onChange,
19
+ }: {
20
+ value: MaterialSchema
21
+ onChange: (next: MaterialSchema) => void
22
+ }) {
23
+ const currentProps = value.properties ?? DEFAULT_MATERIAL_PROPERTIES
24
+
25
+ const updateMaterial = (
26
+ updates: Partial<MaterialProperties>,
27
+ nextTransparent = currentProps.transparent,
28
+ ) => {
29
+ onChange({
30
+ ...value,
31
+ preset: value.preset ?? 'custom',
32
+ properties: {
33
+ ...currentProps,
34
+ ...updates,
35
+ transparent: nextTransparent,
36
+ },
37
+ })
38
+ }
39
+
40
+ return (
41
+ <div className="space-y-3">
42
+ <div className="space-y-2">
43
+ <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
44
+ Color
45
+ </label>
46
+ <div className="flex items-center gap-2">
47
+ <input
48
+ className="h-9 w-9 shrink-0 cursor-pointer rounded-md border border-input bg-transparent p-0 [&::-moz-color-swatch]:rounded-[5px] [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-[5px] [&::-webkit-color-swatch]:border-none"
49
+ onChange={(e) => updateMaterial({ color: e.target.value })}
50
+ type="color"
51
+ value={currentProps.color}
52
+ />
53
+ <Input
54
+ onChange={(e) => updateMaterial({ color: e.target.value })}
55
+ value={currentProps.color}
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <SliderControl
61
+ label="Roughness"
62
+ max={1}
63
+ min={0}
64
+ onChange={(value) => updateMaterial({ roughness: value })}
65
+ precision={2}
66
+ step={0.01}
67
+ value={currentProps.roughness}
68
+ />
69
+
70
+ <SliderControl
71
+ label="Metalness"
72
+ max={1}
73
+ min={0}
74
+ onChange={(value) => updateMaterial({ metalness: value })}
75
+ precision={2}
76
+ step={0.01}
77
+ value={currentProps.metalness}
78
+ />
79
+
80
+ <SliderControl
81
+ label="Opacity"
82
+ max={1}
83
+ min={0}
84
+ onChange={(value) => updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)}
85
+ precision={2}
86
+ step={0.01}
87
+ value={currentProps.opacity}
88
+ />
89
+
90
+ <div className="space-y-2">
91
+ <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
92
+ Side
93
+ </label>
94
+ <select
95
+ className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
96
+ onChange={(e) =>
97
+ updateMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
98
+ }
99
+ value={currentProps.side}
100
+ >
101
+ <option value="front">Front</option>
102
+ <option value="back">Back</option>
103
+ <option value="double">Double</option>
104
+ </select>
105
+ </div>
106
+ </div>
107
+ )
108
+ }