@pascal-app/editor 1.0.0-beta.4 → 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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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.
@@ -1,4 +1,5 @@
1
1
  import type { ToolHint } from '@pascal-app/core'
2
+ import { useMemo, useSyncExternalStore } from 'react'
2
3
  import type { ContinuationContext } from '../../../lib/continuation'
3
4
  import type { SnapContext } from '../../../lib/snapping-mode'
4
5
  import useEditor from '../../../store/use-editor'
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
27
28
  // Live vertex count of an in-progress polygon draft, so hints gated on a
28
29
  // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
29
30
  const draftVertexCount = useEditor((s) => s.draftVertexCount)
31
+ const visibilityStore = useMemo(
32
+ () => ({
33
+ subscribe: (onChange: () => void) => {
34
+ const unsubscribers = hints.flatMap((hint) =>
35
+ hint.visible ? [hint.visible.subscribe(onChange)] : [],
36
+ )
37
+ return () => {
38
+ for (const unsubscribe of unsubscribers) unsubscribe()
39
+ }
40
+ },
41
+ getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
42
+ }),
43
+ [hints],
44
+ )
45
+ useSyncExternalStore(
46
+ visibilityStore.subscribe,
47
+ visibilityStore.getSnapshot,
48
+ visibilityStore.getSnapshot,
49
+ )
30
50
  // Some hints are replaced by live contextual chips, so keep the generic
31
51
  // registry renderer from duplicating stale/static versions.
32
52
  const visible = hints.filter(
33
53
  (hint) =>
34
54
  !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
55
+ hint.visible?.value() !== false &&
35
56
  (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
36
57
  )
37
58
  if (visible.length === 0 && !snapContext && !continuationContext) return null
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import {
4
+ resolveHomogeneousSelection,
5
+ resolveUniqueSelectionIds,
6
+ } from './homogeneous-selection'
7
+
8
+ function node(
9
+ id: string,
10
+ type: string,
11
+ metadata?: Record<string, unknown>,
12
+ ): AnyNode {
13
+ return {
14
+ object: 'node',
15
+ id: id as AnyNodeId,
16
+ type,
17
+ parentId: null,
18
+ visible: true,
19
+ metadata: metadata ?? {},
20
+ children: [],
21
+ } as unknown as AnyNode
22
+ }
23
+
24
+ describe('resolveHomogeneousSelection', () => {
25
+ test('mixed selection is null', () => {
26
+ const nodes = {
27
+ wall_a: node('wall_a', 'wall'),
28
+ slab_a: node('slab_a', 'slab'),
29
+ }
30
+ expect(resolveHomogeneousSelection(['wall_a', 'slab_a'], nodes)).toBeNull()
31
+ })
32
+
33
+ test('three walls share the wall type', () => {
34
+ const nodes = {
35
+ wall_a: node('wall_a', 'wall'),
36
+ wall_b: node('wall_b', 'wall'),
37
+ wall_c: node('wall_c', 'wall'),
38
+ }
39
+ expect(resolveHomogeneousSelection(['wall_a', 'wall_b', 'wall_c'], nodes)).toBe('wall')
40
+ })
41
+
42
+ test('proxy-promoted children resolve to the parent type', () => {
43
+ const nodes = {
44
+ cabinet_run: node('cabinet_run', 'cabinet'),
45
+ 'cabinet-module_a': node('cabinet-module_a', 'cabinet-module', {
46
+ nodeSelectionProxyId: 'cabinet_run',
47
+ }),
48
+ 'cabinet-module_b': node('cabinet-module_b', 'cabinet-module', {
49
+ nodeSelectionProxyId: 'cabinet_run',
50
+ }),
51
+ 'cabinet-module_c': node('cabinet-module_c', 'cabinet-module', {
52
+ nodeSelectionProxyId: 'cabinet_run',
53
+ }),
54
+ cabinet_run_b: node('cabinet_run_b', 'cabinet'),
55
+ 'cabinet-module_d': node('cabinet-module_d', 'cabinet-module', {
56
+ nodeSelectionProxyId: 'cabinet_run_b',
57
+ }),
58
+ }
59
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_d'], nodes)).toBe(
60
+ 'cabinet',
61
+ )
62
+ expect(resolveUniqueSelectionIds(['cabinet-module_a', 'cabinet-module_b', 'cabinet-module_c'], nodes)).toEqual(
63
+ ['cabinet_run'],
64
+ )
65
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_b'], nodes)).toBeNull()
66
+ })
67
+
68
+ test('stale ids are skipped without breaking a homogeneous remainder', () => {
69
+ const nodes = {
70
+ wall_a: node('wall_a', 'wall'),
71
+ wall_b: node('wall_b', 'wall'),
72
+ }
73
+ expect(resolveHomogeneousSelection(['wall_a', 'gone', 'wall_b'], nodes)).toBe('wall')
74
+ })
75
+
76
+ test('a single live node after skips is not homogeneous', () => {
77
+ const nodes = { wall_a: node('wall_a', 'wall') }
78
+ expect(resolveHomogeneousSelection(['wall_a', 'gone'], nodes)).toBeNull()
79
+ })
80
+ })
@@ -0,0 +1,47 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ resolveSelectionProxyId,
5
+ } from '@pascal-app/core'
6
+
7
+ /**
8
+ * Resolve selection proxies and drop duplicates / missing ids. Session groups
9
+ * are just selections — mixed-type groups stay mixed after this pass, and a
10
+ * pair of children that both proxy to the same parent collapse to one id.
11
+ */
12
+ export function resolveUniqueSelectionIds(
13
+ ids: readonly string[],
14
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
15
+ ): AnyNodeId[] {
16
+ const resolved: AnyNodeId[] = []
17
+ const seen = new Set<string>()
18
+ for (const id of ids) {
19
+ const node = nodes[id]
20
+ if (!node) continue
21
+ const resolvedId = resolveSelectionProxyId(node, nodes)
22
+ if (seen.has(resolvedId)) continue
23
+ seen.add(resolvedId)
24
+ resolved.push(resolvedId)
25
+ }
26
+ return resolved
27
+ }
28
+
29
+ /**
30
+ * Shared type when every resolved id is the same kind and at least two nodes
31
+ * remain. Otherwise null — including mixed session groups and proxy-collapsed
32
+ * selections that shrink below two distinct nodes.
33
+ */
34
+ export function resolveHomogeneousSelection(
35
+ ids: readonly string[],
36
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
37
+ ): AnyNode['type'] | null {
38
+ const resolvedIds = resolveUniqueSelectionIds(ids, nodes)
39
+ if (resolvedIds.length < 2) return null
40
+ const first = nodes[resolvedIds[0] ?? '']
41
+ if (!first) return null
42
+ for (let i = 1; i < resolvedIds.length; i++) {
43
+ const node = nodes[resolvedIds[i] ?? '']
44
+ if (!node || node.type !== first.type) return null
45
+ }
46
+ return first.type
47
+ }
@@ -8,7 +8,9 @@ import { cn } from '../../../lib/utils'
8
8
  import { getNodeDisplay } from './node-display'
9
9
 
10
10
  interface MobileSelectionBarProps {
11
- node: AnyNode
11
+ node: AnyNode | null
12
+ label?: string
13
+ icon?: string
12
14
  onMove: () => void
13
15
  onDuplicate: () => void
14
16
  onDelete: () => void
@@ -20,19 +22,23 @@ const ACTION_BTN =
20
22
 
21
23
  export function MobileSelectionBar({
22
24
  node,
25
+ label,
26
+ icon,
23
27
  onMove,
24
28
  onDuplicate,
25
29
  onDelete,
26
30
  onEdit,
27
31
  }: MobileSelectionBarProps) {
28
- const { icon, label } = getNodeDisplay(node)
32
+ const display = getNodeDisplay(node)
33
+ const resolvedLabel = label ?? display.label
34
+ const resolvedIcon = icon ?? display.icon
29
35
 
30
36
  const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
31
37
 
32
38
  return (
33
39
  <div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
34
40
  <button
35
- aria-label={`Edit ${label}`}
41
+ aria-label={`Edit ${resolvedLabel}`}
36
42
  className={cn(
37
43
  'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
38
44
  )}
@@ -43,10 +49,10 @@ export function MobileSelectionBar({
43
49
  alt=""
44
50
  className="shrink-0 rounded object-contain"
45
51
  height={20}
46
- src={icon}
52
+ src={resolvedIcon}
47
53
  width={20}
48
54
  />
49
- <span className="truncate font-medium text-foreground text-sm">{label}</span>
55
+ <span className="truncate font-medium text-foreground text-sm">{resolvedLabel}</span>
50
56
  </button>
51
57
 
52
58
  <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
@@ -0,0 +1,209 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ clearSceneHistory,
7
+ LevelNode,
8
+ useLiveNodeOverrides,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import {
13
+ buildMultiNodePatches,
14
+ commitMultiNodeFields,
15
+ fieldVisibleForAll,
16
+ reduceFieldValue,
17
+ reduceHeightBoundMode,
18
+ } from './multi-field-value'
19
+
20
+ type RafFn = (cb: (time: number) => void) => number
21
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
22
+ cb(0)
23
+ return 0
24
+ }
25
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
26
+ () => {}
27
+
28
+ const BUILDING_ID = 'building_multi_edit' as AnyNodeId
29
+ const LEVEL_ID = 'level_multi_edit' as AnyNodeId
30
+ const WALL_A = 'wall_multi_a' as AnyNodeId
31
+ const WALL_B = 'wall_multi_b' as AnyNodeId
32
+ const WALL_C = 'wall_multi_c' as AnyNodeId
33
+
34
+ function makeNode(id: string, type: string, fields: Record<string, unknown> = {}): AnyNode {
35
+ return {
36
+ object: 'node',
37
+ id: id as AnyNodeId,
38
+ type,
39
+ parentId: null,
40
+ visible: true,
41
+ metadata: {},
42
+ children: [],
43
+ ...fields,
44
+ } as unknown as AnyNode
45
+ }
46
+
47
+ describe('reduceFieldValue', () => {
48
+ test('same values collapse to one', () => {
49
+ const nodes = {
50
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
51
+ b: makeNode('b', 'wall', { height: 2.4, thickness: 0.15 }),
52
+ }
53
+ expect(reduceFieldValue(['a', 'b'], 'height', nodes)).toEqual({ kind: 'same', value: 2.4 })
54
+ expect(reduceFieldValue(['a', 'b'], 'thickness', nodes)).toEqual({ kind: 'mixed' })
55
+ })
56
+
57
+ test('vec3 compares elementwise', () => {
58
+ const nodes = {
59
+ a: makeNode('a', 'item', { position: [1, 0, 2] }),
60
+ b: makeNode('b', 'item', { position: [1, 0, 2] }),
61
+ c: makeNode('c', 'item', { position: [1, 0, 3] }),
62
+ }
63
+ expect(reduceFieldValue(['a', 'b'], 'position', nodes)).toEqual({
64
+ kind: 'same',
65
+ value: [1, 0, 2],
66
+ })
67
+ expect(reduceFieldValue(['a', 'c'], 'position', nodes)).toEqual({ kind: 'mixed' })
68
+ })
69
+ })
70
+
71
+ describe('reduceHeightBoundMode', () => {
72
+ test('absent height is follows-level, present height is custom', () => {
73
+ const nodes = {
74
+ a: makeNode('a', 'wall'),
75
+ b: makeNode('b', 'wall'),
76
+ c: makeNode('c', 'wall', { height: 3 }),
77
+ }
78
+ expect(reduceHeightBoundMode(['a', 'b'], nodes)).toEqual({ kind: 'same', value: 'storey' })
79
+ expect(reduceHeightBoundMode(['c'], nodes)).toEqual({ kind: 'same', value: 'custom' })
80
+ expect(reduceHeightBoundMode(['a', 'c'], nodes)).toEqual({ kind: 'mixed' })
81
+ })
82
+ })
83
+
84
+ describe('fieldVisibleForAll', () => {
85
+ test('hides when any node would hide the field', () => {
86
+ const nodes = {
87
+ a: makeNode('a', 'slab', { recessed: false }),
88
+ b: makeNode('b', 'slab', { recessed: true }),
89
+ }
90
+ const visibleIf = (n: AnyNode) => !(n as { recessed?: boolean }).recessed
91
+ expect(fieldVisibleForAll(['a'], visibleIf, nodes)).toBe(true)
92
+ expect(fieldVisibleForAll(['a', 'b'], visibleIf, nodes)).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('buildMultiNodePatches', () => {
97
+ test('a mixed field that is never edited produces no patch for it', () => {
98
+ const nodes = {
99
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
100
+ b: makeNode('b', 'wall', { height: 3, thickness: 0.15 }),
101
+ }
102
+ const patches = buildMultiNodePatches(
103
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
104
+ () => ({ thickness: 0.3 }),
105
+ nodes,
106
+ )
107
+ expect(patches).toEqual([
108
+ { id: 'a' as AnyNodeId, data: { thickness: 0.3 } },
109
+ { id: 'b' as AnyNodeId, data: { thickness: 0.3 } },
110
+ ])
111
+ for (const patch of patches) {
112
+ expect(patch.data).not.toHaveProperty('height')
113
+ }
114
+ })
115
+
116
+ test('derive and reconcile fan out per node into one batch', () => {
117
+ const nodes = {
118
+ a: makeNode('a', 'wall', { height: 2 }),
119
+ b: makeNode('b', 'wall', { height: 2 }),
120
+ }
121
+ const patches = buildMultiNodePatches(
122
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
123
+ () => ({ height: 4 }),
124
+ nodes,
125
+ {
126
+ derive: (next) => ({ thickness: (next as { height: number }).height / 10 }),
127
+ reconcile: (prev) => [
128
+ { id: `${prev.id}_follow` as AnyNodeId, data: { height: 4 } },
129
+ ],
130
+ },
131
+ )
132
+ expect(patches).toEqual([
133
+ { id: 'a' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
134
+ { id: 'b' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
135
+ { id: 'a_follow' as AnyNodeId, data: { height: 4 } },
136
+ { id: 'b_follow' as AnyNodeId, data: { height: 4 } },
137
+ ])
138
+ })
139
+ })
140
+
141
+ describe('commitMultiNodeFields', () => {
142
+ beforeEach(() => {
143
+ const wallA = WallNode.parse({
144
+ id: WALL_A,
145
+ parentId: LEVEL_ID,
146
+ start: [0, 0],
147
+ end: [4, 0],
148
+ height: 2.4,
149
+ })
150
+ const wallB = WallNode.parse({
151
+ id: WALL_B,
152
+ parentId: LEVEL_ID,
153
+ start: [4, 0],
154
+ end: [4, 3],
155
+ height: 3,
156
+ })
157
+ const wallC = WallNode.parse({
158
+ id: WALL_C,
159
+ parentId: LEVEL_ID,
160
+ start: [4, 3],
161
+ end: [0, 3],
162
+ height: 2.7,
163
+ })
164
+ const level = LevelNode.parse({
165
+ id: LEVEL_ID,
166
+ parentId: BUILDING_ID,
167
+ children: [WALL_A, WALL_B, WALL_C],
168
+ level: 0,
169
+ })
170
+ const building = BuildingNode.parse({
171
+ id: BUILDING_ID,
172
+ parentId: null,
173
+ children: [LEVEL_ID],
174
+ })
175
+ useScene.setState({
176
+ nodes: {
177
+ [BUILDING_ID]: building,
178
+ [LEVEL_ID]: level,
179
+ [WALL_A]: wallA,
180
+ [WALL_B]: wallB,
181
+ [WALL_C]: wallC,
182
+ },
183
+ rootNodeIds: [BUILDING_ID],
184
+ dirtyNodes: new Set<AnyNodeId>(),
185
+ collections: {},
186
+ materials: {},
187
+ readOnly: false,
188
+ } as never)
189
+ clearSceneHistory()
190
+ useLiveNodeOverrides.getState().clearAll()
191
+ })
192
+
193
+ afterEach(() => {
194
+ useLiveNodeOverrides.getState().clearAll()
195
+ })
196
+
197
+ test('dragging height onto three walls is one undo step', () => {
198
+ commitMultiNodeFields([WALL_A, WALL_B, WALL_C], () => ({ height: 4 }))
199
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(4)
200
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(4)
201
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(4)
202
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
203
+
204
+ useScene.temporal.getState().undo()
205
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(2.4)
206
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(3)
207
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(2.7)
208
+ })
209
+ })