@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -35,7 +35,7 @@ export type MaterialPaintPanelProps = {
35
35
  export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
36
36
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
37
37
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
38
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
38
+ const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
39
39
  const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
40
40
  const paintEraser = useEditor((state) => state.paintEraser)
41
41
  const setPaintEraser = useEditor((state) => state.setPaintEraser)
@@ -81,7 +81,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
81
81
  },
82
82
  },
83
83
  })
84
- setActivePaintMaterial({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
84
+ armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
85
85
  setAutoEditMaterialId(id)
86
86
  }
87
87
 
@@ -112,11 +112,13 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
112
112
  </div>
113
113
 
114
114
  {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
115
- <div className="min-h-0 flex-1">
115
+ {/* A stable hook for host-app onboarding to point at. Static, and read
116
+ only from outside: nothing here depends on it. */}
117
+ <div className="min-h-0 flex-1" data-guide-target="paint-material">
116
118
  <MaterialPicker
117
119
  onCreateMaterialRequest={onCreateMaterialRequest}
118
120
  onSelectMaterialPreset={(materialPreset) => {
119
- setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
121
+ armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
120
122
  }}
121
123
  selectedMaterialPreset={activePaintMaterial?.materialPreset}
122
124
  />
@@ -17,7 +17,7 @@ import { Plus } from 'lucide-react'
17
17
  import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
18
18
  import { triggerSFX } from '../../../lib/sfx-bus'
19
19
 
20
- export type MaterialSourceFilter = 'all' | MaterialSource
20
+ export type MaterialSourceFilter = MaterialSource
21
21
 
22
22
  export type MaterialPickerProps = {
23
23
  selectedMaterialPreset?: string
@@ -28,8 +28,9 @@ export type MaterialPickerProps = {
28
28
  onCreateMaterialRequest?: () => void
29
29
  }
30
30
 
31
+ // No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default
32
+ // to the Pascal library — the combined list buried the curated set.
31
33
  const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
32
- { id: 'all', label: 'All' },
33
34
  { id: 'pascal', label: 'Pascal' },
34
35
  { id: 'mine', label: 'Mine' },
35
36
  { id: 'workspace', label: 'Workspace' },
@@ -41,7 +42,6 @@ function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
41
42
  }
42
43
 
43
44
  function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
44
- if (filter === 'all') return items
45
45
  return items.filter((item) => (item.source ?? 'pascal') === filter)
46
46
  }
47
47
 
@@ -60,7 +60,7 @@ export function MaterialPicker({
60
60
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
61
61
  MATERIAL_CATEGORIES[0],
62
62
  )
63
- const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('all')
63
+ const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
64
64
  // Version counter so host registrations/unregistrations re-render the picker.
65
65
  const libraryVersion = useSyncExternalStore(
66
66
  subscribeLibraryMaterials,
@@ -33,7 +33,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
33
33
  const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
34
34
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
35
35
  const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
36
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
36
+ const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
37
37
 
38
38
  const materialEntries = useMemo(
39
39
  () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][],
@@ -76,7 +76,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
76
76
  key={id}
77
77
  removeSceneMaterial={removeSceneMaterial}
78
78
  sceneMaterial={sceneMaterial}
79
- setActivePaintMaterial={setActivePaintMaterial}
79
+ armMaterialPaint={armMaterialPaint}
80
80
  updateSceneMaterial={updateSceneMaterial}
81
81
  usageCount={usageCounts.get(id) ?? 0}
82
82
  />
@@ -95,7 +95,7 @@ function SceneMaterialRow({
95
95
  addSceneMaterial,
96
96
  updateSceneMaterial,
97
97
  removeSceneMaterial,
98
- setActivePaintMaterial,
98
+ armMaterialPaint,
99
99
  }: {
100
100
  id: SceneMaterialId
101
101
  sceneMaterial: SceneMaterial
@@ -106,7 +106,7 @@ function SceneMaterialRow({
106
106
  addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
107
107
  updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
108
108
  removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
109
- setActivePaintMaterial: ReturnType<typeof useEditor.getState>['setActivePaintMaterial']
109
+ armMaterialPaint: ReturnType<typeof useEditor.getState>['armMaterialPaint']
110
110
  }) {
111
111
  // A freshly-created material (via "+ Custom") mounts with its editor open.
112
112
  const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit)
@@ -141,6 +141,7 @@ function SceneMaterialRow({
141
141
  className={`rounded-md border border-border/60 bg-background/40 p-2 ${
142
142
  isActive ? 'ring-1 ring-primary ring-inset' : ''
143
143
  }`}
144
+ data-testid={`scene-material-row-${id}`}
144
145
  >
145
146
  <div className="flex items-center gap-2">
146
147
  <span
@@ -174,7 +175,7 @@ function SceneMaterialRow({
174
175
  <Button
175
176
  aria-label="Paint with"
176
177
  onClick={() =>
177
- setActivePaintMaterial({
178
+ armMaterialPaint({
178
179
  materialPreset: toSceneMaterialRef(id),
179
180
  sourceTarget: activePaintTarget,
180
181
  })
@@ -7,6 +7,8 @@ interface SegmentedControlProps<T extends string> {
7
7
  onChange: (value: T) => void
8
8
  options: { label: React.ReactNode; value: T }[]
9
9
  className?: string
10
+ disabled?: boolean
11
+ mixed?: boolean
10
12
  }
11
13
 
12
14
  export function SegmentedControl<T extends string>({
@@ -14,16 +16,19 @@ export function SegmentedControl<T extends string>({
14
16
  onChange,
15
17
  options,
16
18
  className,
19
+ disabled = false,
20
+ mixed = false,
17
21
  }: SegmentedControlProps<T>) {
18
22
  return (
19
23
  <div
20
24
  className={cn(
21
25
  'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
26
+ disabled && 'opacity-60',
22
27
  className,
23
28
  )}
24
29
  >
25
30
  {options.map((option) => {
26
- const isSelected = value === option.value
31
+ const isSelected = !mixed && value === option.value
27
32
  return (
28
33
  <button
29
34
  className={cn(
@@ -31,7 +36,9 @@ export function SegmentedControl<T extends string>({
31
36
  isSelected
32
37
  ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
33
38
  : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
39
+ disabled && 'cursor-not-allowed hover:bg-transparent hover:text-muted-foreground',
34
40
  )}
41
+ disabled={disabled}
35
42
  key={option.value}
36
43
  onClick={() => onChange(option.value)}
37
44
  type="button"
@@ -22,6 +22,7 @@ interface SliderControlProps {
22
22
  className?: string
23
23
  unit?: string
24
24
  restoreOnCommit?: boolean
25
+ mixed?: boolean
25
26
  }
26
27
 
27
28
  function stepPrecision(s: number): number {
@@ -64,6 +65,7 @@ export function SliderControl({
64
65
  className,
65
66
  unit = '',
66
67
  restoreOnCommit = true,
68
+ mixed = false,
67
69
  }: SliderControlProps) {
68
70
  // Display/storage conversion so the value honors the metric/imperial toggle.
69
71
  // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
@@ -76,6 +78,10 @@ export function SliderControl({
76
78
  const [isDragging, setIsDragging] = useState(false)
77
79
  const [isHovered, setIsHovered] = useState(false)
78
80
  const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
81
+ // Live readout while dragging. Multi-edit previews write live overrides
82
+ // instead of the scene, so `value` would otherwise stay frozen even though
83
+ // the meshes are moving.
84
+ const [dragDisplay, setDragDisplay] = useState<number | null>(null)
79
85
 
80
86
  const dragRef = useRef<{
81
87
  // Original value at drag start — preserved across modifier re-anchors so
@@ -89,8 +95,9 @@ export function SliderControl({
89
95
  stepMultiplier: number
90
96
  } | null>(null)
91
97
  const labelRef = useRef<HTMLDivElement>(null)
92
- const valueRef = useRef(value)
93
- valueRef.current = value
98
+ const shown = dragDisplay ?? value
99
+ const valueRef = useRef(shown)
100
+ valueRef.current = shown
94
101
 
95
102
  const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
96
103
  // Apply a signed display-unit delta to a stored value, rounding in the
@@ -185,6 +192,7 @@ export function SliderControl({
185
192
  const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s)
186
193
  if (newValue !== valueRef.current) {
187
194
  valueRef.current = newValue
195
+ setDragDisplay(newValue)
188
196
  onChange(newValue)
189
197
  }
190
198
  },
@@ -209,6 +217,7 @@ export function SliderControl({
209
217
  useScene.temporal.getState().resume()
210
218
  onCommit?.(finalVal)
211
219
  }
220
+ setDragDisplay(null)
212
221
  },
213
222
  [onChange, onCommit, restoreOnCommit],
214
223
  )
@@ -272,7 +281,7 @@ export function SliderControl({
272
281
  [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
273
282
  )
274
283
 
275
- const displayValue = toDisplay(value)
284
+ const displayValue = toDisplay(shown)
276
285
 
277
286
  return (
278
287
  <div
@@ -340,6 +349,13 @@ export function SliderControl({
340
349
  </span>
341
350
  )}
342
351
  </>
352
+ ) : mixed && !isDragging ? (
353
+ <div
354
+ className="flex cursor-text items-center text-muted-foreground transition-colors hover:text-foreground"
355
+ onClick={handleValueClick}
356
+ >
357
+ <span className="font-mono tracking-tight">Mixed</span>
358
+ </div>
343
359
  ) : (
344
360
  <div
345
361
  className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
@@ -8,9 +8,16 @@ interface ToggleControlProps {
8
8
  checked: boolean
9
9
  onChange: (checked: boolean) => void
10
10
  className?: string
11
+ mixed?: boolean
11
12
  }
12
13
 
13
- export function ToggleControl({ label, checked, onChange, className }: ToggleControlProps) {
14
+ export function ToggleControl({
15
+ label,
16
+ checked,
17
+ onChange,
18
+ className,
19
+ mixed = false,
20
+ }: ToggleControlProps) {
14
21
  return (
15
22
  <div
16
23
  className={cn(
@@ -26,12 +33,18 @@ export function ToggleControl({ label, checked, onChange, className }: ToggleCon
26
33
  <div
27
34
  className={cn(
28
35
  'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',
29
- checked
30
- ? 'border-primary bg-primary text-primary-foreground'
31
- : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
36
+ mixed
37
+ ? 'border-muted-foreground/70 bg-black/20 text-muted-foreground'
38
+ : checked
39
+ ? 'border-primary bg-primary text-primary-foreground'
40
+ : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
32
41
  )}
33
42
  >
34
- <Check className="h-3.5 w-3.5" strokeWidth={3} />
43
+ {mixed ? (
44
+ <div className="h-0.5 w-2.5 rounded-full bg-current" />
45
+ ) : (
46
+ <Check className="h-3.5 w-3.5" strokeWidth={3} />
47
+ )}
35
48
  </div>
36
49
  </div>
37
50
  )
@@ -0,0 +1,126 @@
1
+ 'use client'
2
+
3
+ import { nodeRegistry, type ToolOption } from '@pascal-app/core'
4
+ import Image from 'next/image'
5
+ import { useSyncExternalStore } from 'react'
6
+ import { cn } from '../../../lib/utils'
7
+ import { triggerSFX } from '../../../lib/sfx-bus'
8
+
9
+ const ALWAYS_VISIBLE = {
10
+ subscribe: () => () => {},
11
+ value: () => true,
12
+ }
13
+
14
+ function ToolOptionRow({
15
+ option,
16
+ onSelect,
17
+ getChoiceThumbnail,
18
+ active = true,
19
+ }: {
20
+ option: ToolOption
21
+ onSelect?: (option: ToolOption, value: string) => void
22
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
23
+ active?: boolean
24
+ }) {
25
+ const visibility = option.visible ?? ALWAYS_VISIBLE
26
+ const visible = useSyncExternalStore(visibility.subscribe, visibility.value, visibility.value)
27
+ const value = useSyncExternalStore(option.subscribe, option.value, option.value)
28
+ if (
29
+ !visible ||
30
+ (!active && !option.choices.some((choice) => getChoiceThumbnail?.(option, choice.value)))
31
+ ) return null
32
+
33
+ const activeChoice = active && option.choices.find((choice) => choice.value === value)
34
+ return (
35
+ <div className="flex flex-col gap-2">
36
+ <div className="px-0.5 font-medium text-muted-foreground text-xs">{option.label}</div>
37
+ <div
38
+ className="grid gap-1.5"
39
+ style={{
40
+ gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
41
+ }}
42
+ >
43
+ {option.choices.map((choice) => {
44
+ const selected = active && choice.value === value
45
+ const thumbnail = getChoiceThumbnail?.(option, choice.value)
46
+ return (
47
+ <button
48
+ aria-pressed={selected}
49
+ className={cn(
50
+ 'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
51
+ selected
52
+ ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
53
+ : 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
54
+ )}
55
+ key={choice.value}
56
+ onClick={() => {
57
+ triggerSFX('sfx:menu-click')
58
+ option.set(choice.value)
59
+ onSelect?.(option, choice.value)
60
+ }}
61
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
62
+ type="button"
63
+ >
64
+ {thumbnail && (
65
+ <Image
66
+ alt=""
67
+ className={cn(
68
+ 'mx-auto mb-1 size-14 object-contain',
69
+ !selected && 'opacity-70 grayscale',
70
+ )}
71
+ height={56}
72
+ src={thumbnail}
73
+ width={56}
74
+ />
75
+ )}
76
+ {choice.label}
77
+ </button>
78
+ )
79
+ })}
80
+ </div>
81
+ {activeChoice && activeChoice.description ? (
82
+ <p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
83
+ {activeChoice.description}
84
+ </p>
85
+ ) : null}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /**
91
+ * The pick-one option rows a kind declares via `def.toolOptions` (e.g. the
92
+ * roof's 'Create from: Draw / Room'), for whichever sidebar the host mounts
93
+ * it in — the standalone Build tab and the community Build sidebar both get
94
+ * every kind's options with no per-kind wiring. Renders nothing for kinds
95
+ * without options. Selecting a choice only writes the kind's own state;
96
+ * hosts that want selection to also arm the tool pass `onSelect`.
97
+ */
98
+ export function ToolOptionsPanel({
99
+ kind,
100
+ className,
101
+ onSelect,
102
+ getChoiceThumbnail,
103
+ active = true,
104
+ }: {
105
+ kind: string | null | undefined
106
+ className?: string
107
+ onSelect?: (option: ToolOption, value: string) => void
108
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
109
+ active?: boolean
110
+ }) {
111
+ const options = (kind ? nodeRegistry.get(kind)?.toolOptions : undefined) ?? []
112
+ if (options.length === 0) return null
113
+ return (
114
+ <div className={cn('flex flex-col gap-3', className)}>
115
+ {options.map((option) => (
116
+ <ToolOptionRow
117
+ active={active}
118
+ getChoiceThumbnail={getChoiceThumbnail}
119
+ key={option.id}
120
+ onSelect={onSelect}
121
+ option={option}
122
+ />
123
+ ))}
124
+ </div>
125
+ )
126
+ }
@@ -27,7 +27,7 @@ import {
27
27
  LevelNode,
28
28
  useScene,
29
29
  } from '@pascal-app/core'
30
- import { useViewer } from '@pascal-app/viewer'
30
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
31
  import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
32
32
  import {
33
33
  type ButtonHTMLAttributes,
@@ -150,6 +150,9 @@ function LevelRow({
150
150
  const storeyHeight = getStoredLevelHeight(level)
151
151
  // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
152
  const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
153
+ // Same rule as the site panel and command palette: the ordinal-0 ground
154
+ // floor is the vertical model's zero anchor and must never be deletable.
155
+ const canDeleteLevel = level.level !== 0
153
156
 
154
157
  // Clean preset values per display system; imperial stores exact meters
155
158
  // for whole-foot storey heights.
@@ -304,11 +307,13 @@ function LevelRow({
304
307
  </button>
305
308
  )}
306
309
  <button
307
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
310
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-white/10 enabled:hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
311
+ disabled={!canDeleteLevel}
308
312
  onClick={(e) => {
309
313
  e.stopPropagation()
310
314
  onRequestDelete()
311
315
  }}
316
+ title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
312
317
  type="button"
313
318
  >
314
319
  <Trash2 className="h-3 w-3" />
@@ -572,6 +577,9 @@ export function FloatingLevelSelector() {
572
577
  {!draggingLevelId && (
573
578
  <button
574
579
  className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
580
+ // A stable hook for host-app onboarding to point at. Static, and
581
+ // read only from outside: nothing here depends on it.
582
+ data-guide-target="level-add"
575
583
  onClick={handleAddAbove}
576
584
  title="Add level above"
577
585
  type="button"
@@ -608,20 +616,29 @@ export function FloatingLevelSelector() {
608
616
  const showGapBelow = i < reversedLevels.length - 1
609
617
 
610
618
  return (
611
- <div className="relative" key={level.id}>
619
+ <div
620
+ className="relative"
621
+ // A stable hook for host-app onboarding to point at, on
622
+ // the ground floor only — the one level a guide can name
623
+ // without knowing the building. Static, and read only
624
+ // from outside: nothing here depends on it.
625
+ data-guide-target={level.level === 0 ? 'level-ground' : undefined}
626
+ key={level.id}
627
+ >
612
628
  <SortableLevelRow
613
629
  isSelected={isSelected}
614
630
  level={level}
615
631
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
616
632
  onPaste={() => handlePasteToLevel(level)}
617
633
  onRequestDelete={() => setDeletingLevel(level)}
618
- onSelect={() =>
634
+ onSelect={() => {
635
+ if (!isSelected) markPerfAction('level-switch', level.id)
619
636
  setSelection(
620
637
  resolvedBuildingId
621
638
  ? { buildingId: resolvedBuildingId, levelId: level.id }
622
639
  : { levelId: level.id },
623
640
  )
624
- }
641
+ }}
625
642
  />
626
643
 
627
644
  {showGapBelow && !draggingLevelId && (
@@ -90,6 +90,7 @@ function ShortcutSequence({
90
90
  function ChipRow({
91
91
  ariaLabel,
92
92
  disabled = false,
93
+ guideTarget,
93
94
  icon,
94
95
  label,
95
96
  onClick,
@@ -98,6 +99,11 @@ function ChipRow({
98
99
  }: {
99
100
  ariaLabel?: string
100
101
  disabled?: boolean
102
+ /**
103
+ * A static hook for a host app's first-run tour to point at, written to
104
+ * `data-guide-target`. Nothing here reads it.
105
+ */
106
+ guideTarget?: string
101
107
  icon?: string
102
108
  label: string
103
109
  onClick?: () => void
@@ -130,6 +136,7 @@ function ChipRow({
130
136
  'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
131
137
  disabled && 'opacity-45 saturate-0',
132
138
  )}
139
+ data-guide-target={guideTarget}
133
140
  onClick={onClick}
134
141
  type="button"
135
142
  >
@@ -181,6 +188,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
181
188
  <>
182
189
  <ChipRow
183
190
  ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
191
+ guideTarget="snap-mode"
184
192
  icon={SNAPPING_MODE_ICONS[snappingMode]}
185
193
  label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
186
194
  onClick={() => {
@@ -193,6 +201,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
193
201
  {gridActive ? (
194
202
  <ChipRow
195
203
  ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
204
+ guideTarget="snap-grid-step"
196
205
  label={`Grid: ${gridSnapStep.toFixed(2)} m`}
197
206
  onClick={() => {
198
207
  setGridSnapStep(nextGridSnapStep(gridSnapStep))
@@ -8,7 +8,7 @@ import {
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
10
  import { useViewer } from '@pascal-app/viewer'
11
- import { useEffect, useMemo, useState } from 'react'
11
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
12
12
  import { useShallow } from 'zustand/react/shallow'
13
13
  import { useIsMobile } from '../../../hooks/use-mobile'
14
14
  import {
@@ -19,6 +19,7 @@ import {
19
19
  resolveRotateHandleHelpHints,
20
20
  resolveSelectModeHelpHints,
21
21
  } from '../../../lib/contextual-help'
22
+ import { getContextualHelpNodeExtension } from '../../../lib/contextual-help-extension'
22
23
  import { continuationContextOf } from '../../../lib/continuation'
23
24
  import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
24
25
  import type { ReshapeKind } from '../../../lib/interaction/scope'
@@ -33,7 +34,6 @@ import { BuildingHelper } from './building-helper'
33
34
  import { ContextualHelperPanel } from './contextual-helper-panel'
34
35
  import { ItemHelper } from './item-helper'
35
36
  import { RegisteredToolHelper } from './registered-tool-helper'
36
- import { RoofHelper } from './roof-helper'
37
37
 
38
38
  // Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
39
39
  // snapping chip is the main control; these just name the gesture + Esc.
@@ -88,6 +88,9 @@ type ActiveModifierKeys = {
88
88
  shift: boolean
89
89
  }
90
90
 
91
+ const EMPTY_CONTEXTUAL_HINTS: ContextualShortcutHint[] = []
92
+ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
93
+
91
94
  function useActiveModifierKeys(): ActiveModifierKeys {
92
95
  const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
93
96
  command: false,
@@ -143,6 +146,21 @@ export function HelperManager() {
143
146
  .filter((node): node is AnyNode => node !== undefined),
144
147
  ),
145
148
  )
149
+ const contextualHelpNode =
150
+ scope.kind === 'mesh-editing'
151
+ ? selectedNodes.find((node) => node.id === scope.nodeId) ?? null
152
+ : null
153
+ const contextualHelpExtension = contextualHelpNode
154
+ ? getContextualHelpNodeExtension(nodeRegistry.get(contextualHelpNode.type))
155
+ : undefined
156
+ const contextualEditHints = useSyncExternalStore(
157
+ contextualHelpExtension?.subscribe ?? NO_CONTEXTUAL_HELP_SUBSCRIPTION,
158
+ () =>
159
+ contextualHelpNode
160
+ ? (contextualHelpExtension?.getHints(contextualHelpNode.id) ?? EMPTY_CONTEXTUAL_HINTS)
161
+ : EMPTY_CONTEXTUAL_HINTS,
162
+ () => EMPTY_CONTEXTUAL_HINTS,
163
+ )
146
164
  // The snapping context for whatever's active (wall / item / polygon) — drives
147
165
  // which snapping chips the HUD shows, derived once and shared by every branch.
148
166
  const snapContext = useMemo(
@@ -152,6 +170,10 @@ export function HelperManager() {
152
170
  mode,
153
171
  tool,
154
172
  profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
173
+ profileOfNode: (nodeId) => {
174
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
175
+ return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
176
+ },
155
177
  draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
156
178
  }),
157
179
  [scope, mode, tool],
@@ -212,6 +234,22 @@ export function HelperManager() {
212
234
  )
213
235
  }
214
236
 
237
+ // A single-node resize arrow is still an active snapping interaction. Roof
238
+ // width/depth handles opt into grid snapping, so keep the mode and grid-step
239
+ // controls visible for the whole drag instead of falling through to idle
240
+ // selection hints.
241
+ if (activeHandleDrag) {
242
+ return (
243
+ <ContextualHelperPanel
244
+ hints={[
245
+ { keys: ['Drag'], label: 'Resize' },
246
+ { keys: ['Esc'], label: 'Cancel' },
247
+ ]}
248
+ snapContext={snapContext}
249
+ />
250
+ )
251
+ }
252
+
215
253
  // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
216
254
  // before the select branch so the idle "drag selected / add objects" hints
217
255
  // never leak over an in-progress reshape — and it gets its own snapping chip.
@@ -227,13 +265,13 @@ export function HelperManager() {
227
265
  const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
228
266
  ? continuationContextOf(movingNode.type)
229
267
  : null
230
- // Force-place only makes sense for kinds that collision-validate their drop;
231
- // structural kinds (wall/slab/…) never reject, so don't advertise Alt.
268
+ const collisionValidatesDrop =
269
+ nodeRegistry.get(movingNode.type)?.capabilities.floorPlaced?.collides === true
232
270
  return (
233
271
  <ItemHelper
234
272
  continuationContext={movingContinuationContext}
235
273
  showEsc
236
- showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'}
274
+ showForce={collisionValidatesDrop}
237
275
  snapContext={snapContext}
238
276
  />
239
277
  )
@@ -255,6 +293,10 @@ export function HelperManager() {
255
293
  return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
256
294
  }
257
295
 
296
+ if (scope.kind === 'mesh-editing') {
297
+ return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
298
+ }
299
+
258
300
  // Idle select only — an active scope (handle-drag, box-select, …) must not show
259
301
  // the idle selection hints.
260
302
  if (mode === 'select' && scope.kind === 'idle') {
@@ -273,12 +315,6 @@ export function HelperManager() {
273
315
  )
274
316
  }
275
317
 
276
- // Legacy fallback — only `roof` remains because it hasn't migrated to
277
- // `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
278
- // generic tool branch so the snap-context fallback below doesn't capture it
279
- // and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
280
- if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
281
-
282
318
  // Registry-first: a kind renders the generic `RegisteredToolHelper` when it
283
319
  // declares `def.toolHints`, OR whenever its draft resolves to a snap /
284
320
  // continuation context — so a snappable tool with NO hand-written hints (e.g.
@@ -6,7 +6,7 @@ interface ItemHelperProps {
6
6
  showEsc?: boolean
7
7
  snapContext?: SnapContext | null
8
8
  // Whether to advertise Alt = force-place. Only meaningful for kinds that
9
- // collision-validate their drop (structural kinds never reject, so it's hidden).
9
+ // collision-validate their drop.
10
10
  showForce?: boolean
11
11
  // Set for a fresh point-kind placement (e.g. a positioned preset) so the
12
12
  // once/repeat continuation chip shows; null for an existing-node move.