@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
@@ -1,45 +1,164 @@
1
- import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ emitter,
5
+ getHistoryDirtyNodeIds,
6
+ useLiveNodeOverrides,
7
+ useLiveTransforms,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { markPerfAction } from '@pascal-app/viewer'
11
+ import useInteractionScope from '../store/use-interaction-scope'
12
+ import { registeredDraftingConfig } from './interaction/registered-drafting'
13
+
14
+ export type HistoryCommandState = {
15
+ canRedo: boolean
16
+ canUndo: boolean
17
+ mode: 'collaborative' | 'standalone'
18
+ status: 'offline' | 'ready' | 'syncing' | 'unavailable'
19
+ }
20
+
21
+ export type HistoryCommandResult =
22
+ | { kind: 'applied'; persistence: 'local' | 'queued' }
23
+ | { kind: 'empty' }
24
+ | { kind: 'unavailable' }
2
25
 
3
26
  export type HistoryCommandDelegate = {
4
- undo: () => void
5
- redo: () => void
27
+ getState: () => HistoryCommandState
28
+ redo: () => HistoryCommandResult
29
+ subscribe: (listener: () => void) => () => void
30
+ undo: () => HistoryCommandResult
6
31
  }
7
32
 
8
33
  let historyCommandDelegate: HistoryCommandDelegate | null = null
34
+ let historyCommandDelegateSubscription: (() => void) | null = null
35
+ const historyCommandListeners = new Set<() => void>()
9
36
 
10
37
  export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void {
38
+ historyCommandDelegateSubscription?.()
11
39
  historyCommandDelegate = delegate
40
+ historyCommandDelegateSubscription = delegate.subscribe(notifyHistoryCommandListeners)
41
+ notifyHistoryCommandListeners()
42
+ return () => {
43
+ if (historyCommandDelegate !== delegate) return
44
+ historyCommandDelegateSubscription?.()
45
+ historyCommandDelegateSubscription = null
46
+ historyCommandDelegate = null
47
+ notifyHistoryCommandListeners()
48
+ }
49
+ }
50
+
51
+ export function getHistoryCommandState(): HistoryCommandState {
52
+ if (historyCommandDelegate) return historyCommandDelegate.getState()
53
+ const temporal = useScene.temporal.getState()
54
+ return {
55
+ canRedo: temporal.futureStates.length > 0,
56
+ canUndo: temporal.pastStates.length > 0,
57
+ mode: 'standalone',
58
+ status: 'ready',
59
+ }
60
+ }
61
+
62
+ export function subscribeHistoryCommandState(listener: () => void): () => void {
63
+ historyCommandListeners.add(listener)
64
+ const unsubscribeTemporal = useScene.temporal.subscribe(listener)
12
65
  return () => {
13
- if (historyCommandDelegate === delegate) historyCommandDelegate = null
66
+ historyCommandListeners.delete(listener)
67
+ unsubscribeTemporal()
14
68
  }
15
69
  }
16
70
 
17
- function refreshSceneAfterHistoryJump() {
71
+ function notifyHistoryCommandListeners() {
72
+ for (const listener of [...historyCommandListeners]) listener()
73
+ }
74
+
75
+ function capturePreviewLayout() {
76
+ const overrides = useLiveNodeOverrides.getState().overrides
77
+ if (overrides.size === 0) return null
78
+ const nodes = { ...useScene.getState().nodes }
79
+ for (const [id, values] of overrides) {
80
+ const node = nodes[id as AnyNodeId]
81
+ if (node) nodes[node.id] = { ...node, ...values } as AnyNode
82
+ }
83
+ return nodes
84
+ }
85
+
86
+ function refreshSceneAfterHistoryJump(previewLayout: Record<string, AnyNode> | null) {
87
+ const target = useScene.getState().nodes
88
+ const previewDirty = previewLayout
89
+ ? getHistoryDirtyNodeIds(previewLayout, target)
90
+ : new Set<AnyNodeId>()
91
+ const previewIds = new Set([
92
+ ...useLiveTransforms.getState().transforms.keys(),
93
+ ...useLiveNodeOverrides.getState().overrides.keys(),
94
+ ])
95
+ const currentPreviewLayout = capturePreviewLayout()
96
+ if (currentPreviewLayout) {
97
+ for (const id of getHistoryDirtyNodeIds(currentPreviewLayout, target)) previewDirty.add(id)
98
+ }
18
99
  useLiveNodeOverrides.getState().clearAll()
19
100
  useLiveTransforms.getState().clearAll()
101
+ // Clearing overrides can republish stair holes while a live transform still
102
+ // exists. Capture that final publication before clearing it too.
103
+ const remainingOverrides = useLiveNodeOverrides.getState().overrides
104
+ if (remainingOverrides.size > 0) {
105
+ for (const id of remainingOverrides.keys()) previewIds.add(id)
106
+ const remainingLayout = capturePreviewLayout()
107
+ if (remainingLayout) {
108
+ for (const id of getHistoryDirtyNodeIds(remainingLayout, target)) previewDirty.add(id)
109
+ }
110
+ useLiveNodeOverrides.getState().clearAll()
111
+ }
20
112
 
21
113
  const state = useScene.getState()
22
- for (const node of Object.values(state.nodes)) {
114
+ for (const id of previewDirty) {
115
+ if (state.nodes[id]) state.markDirty(id)
116
+ }
117
+ for (const id of previewIds) {
118
+ const node = state.nodes[id as AnyNodeId]
119
+ if (!node) continue
23
120
  state.markDirty(node.id)
121
+ if (node.parentId && state.nodes[node.parentId as AnyNodeId]) {
122
+ state.markDirty(node.parentId as AnyNodeId)
123
+ }
24
124
  }
25
125
  }
26
126
 
27
- export function runUndo() {
127
+ export function shouldCancelDraftOnHistoryJump(): boolean {
128
+ const scope = useInteractionScope.getState().scope
129
+ return registeredDraftingConfig(scope)?.cancelOnHistoryJump === true
130
+ }
131
+
132
+ export function runUndo(): HistoryCommandResult {
133
+ if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
28
134
  if (historyCommandDelegate) {
29
- historyCommandDelegate.undo()
30
- return
135
+ const result = historyCommandDelegate.undo()
136
+ // Mark only real jumps: a no-op undo must not open a receipt (or
137
+ // interrupt one that is still settling).
138
+ if (result.kind !== 'empty') markPerfAction('undo')
139
+ return result
31
140
  }
141
+ if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' }
142
+ markPerfAction('undo')
143
+ const previewLayout = capturePreviewLayout()
32
144
  useScene.temporal.getState().undo()
33
- refreshSceneAfterHistoryJump()
145
+ refreshSceneAfterHistoryJump(previewLayout)
146
+ return { kind: 'applied', persistence: 'local' }
34
147
  }
35
148
 
36
- export function runRedo() {
149
+ export function runRedo(): HistoryCommandResult {
150
+ if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
37
151
  if (historyCommandDelegate) {
38
- historyCommandDelegate.redo()
39
- return
152
+ const result = historyCommandDelegate.redo()
153
+ if (result.kind !== 'empty') markPerfAction('redo')
154
+ return result
40
155
  }
156
+ if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' }
157
+ markPerfAction('redo')
158
+ const previewLayout = capturePreviewLayout()
41
159
  useScene.temporal.getState().redo()
42
- refreshSceneAfterHistoryJump()
160
+ refreshSceneAfterHistoryJump(previewLayout)
161
+ return { kind: 'applied', persistence: 'local' }
43
162
  }
44
163
 
45
164
  /**
@@ -0,0 +1,27 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ editorHostTreeChildrenRegistry,
4
+ registerEditorHostTreeChildren,
5
+ } from './host-tree-children'
6
+
7
+ describe('editorHostTreeChildrenRegistry', () => {
8
+ afterEach(() => editorHostTreeChildrenRegistry.reset())
9
+
10
+ test('exposes host children by scene node kind and notifies mounted trees', () => {
11
+ let notifications = 0
12
+ const unsubscribe = editorHostTreeChildrenRegistry.subscribe(() => {
13
+ notifications += 1
14
+ })
15
+
16
+ registerEditorHostTreeChildren({
17
+ kind: 'scan',
18
+ component: () => null,
19
+ hasChildren: (node) => node.type === 'scan',
20
+ })
21
+
22
+ expect(editorHostTreeChildrenRegistry.childrenForKind('scan')).toBeDefined()
23
+ expect(editorHostTreeChildrenRegistry.childrenForKind('wall')).toBeUndefined()
24
+ expect(notifications).toBe(1)
25
+ unsubscribe()
26
+ })
27
+ })
@@ -0,0 +1,79 @@
1
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
2
+ import type { ComponentType } from 'react'
3
+
4
+ export type EditorHostTreeChildrenProps = {
5
+ nodeId: AnyNodeId
6
+ depth: number
7
+ parentVisible: boolean
8
+ }
9
+
10
+ export type EditorHostTreeChildren = {
11
+ kind: string
12
+ component: ComponentType<EditorHostTreeChildrenProps>
13
+ hasChildren: (node: AnyNode) => boolean
14
+ }
15
+
16
+ function isDevMode(): boolean {
17
+ try {
18
+ const meta = import.meta as { env?: { DEV?: boolean } }
19
+ if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
20
+ } catch {
21
+ // import.meta unavailable in some CJS contexts — fall through.
22
+ }
23
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
24
+ return process.env.NODE_ENV !== 'production'
25
+ }
26
+ return false
27
+ }
28
+
29
+ class EditorHostTreeChildrenRegistryImpl {
30
+ private readonly entries = new Map<string, EditorHostTreeChildren>()
31
+ private readonly listeners = new Set<() => void>()
32
+ private revision = 0
33
+
34
+ subscribe = (onChange: () => void): (() => void) => {
35
+ this.listeners.add(onChange)
36
+ return () => {
37
+ this.listeners.delete(onChange)
38
+ }
39
+ }
40
+
41
+ getSnapshot = (): number => this.revision
42
+
43
+ childrenForKind = (kind: string): EditorHostTreeChildren | undefined => this.entries.get(kind)
44
+
45
+ reset(): void {
46
+ this.entries.clear()
47
+ this.emit()
48
+ }
49
+
50
+ register(entry: EditorHostTreeChildren): void {
51
+ if (typeof entry.kind !== 'string' || entry.kind.length === 0) {
52
+ throw new Error('[editor:host-tree-children] kind must be a non-empty string')
53
+ }
54
+ if (this.entries.has(entry.kind)) {
55
+ if (isDevMode()) {
56
+ console.warn(
57
+ `[editor:host-tree-children] re-registering children for "${entry.kind}" (HMR)`,
58
+ )
59
+ } else {
60
+ throw new Error(
61
+ `[editor:host-tree-children] duplicate kind: "${entry.kind}" already registered`,
62
+ )
63
+ }
64
+ }
65
+ this.entries.set(entry.kind, entry)
66
+ this.emit()
67
+ }
68
+
69
+ private emit(): void {
70
+ this.revision += 1
71
+ for (const listener of this.listeners) listener()
72
+ }
73
+ }
74
+
75
+ export const editorHostTreeChildrenRegistry = new EditorHostTreeChildrenRegistryImpl()
76
+
77
+ export function registerEditorHostTreeChildren(entry: EditorHostTreeChildren): void {
78
+ editorHostTreeChildrenRegistry.register(entry)
79
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type InspectorCardMode,
4
+ resolveActiveExtension,
5
+ toggleCard,
6
+ toggleExtension,
7
+ } from './inspector-card-mode'
8
+
9
+ const collapsed: InspectorCardMode = { collapsed: true, activeExtensionId: null }
10
+ const regular: InspectorCardMode = { collapsed: false, activeExtensionId: null }
11
+ const engineering: InspectorCardMode = { collapsed: false, activeExtensionId: 'bones:eng' }
12
+
13
+ // EITHER/OR contract (replaces #667's appended-section behavior): the
14
+ // expanded card shows the regular controls OR one extension's content,
15
+ // never both. These gates encode every transition of the mode machine.
16
+
17
+ describe('toggleCard (chevron / header press)', () => {
18
+ test('folded card expands to the REGULAR controls — no extension', () => {
19
+ expect(toggleCard(collapsed)).toEqual(regular)
20
+ })
21
+
22
+ test('regular expanded card folds', () => {
23
+ expect(toggleCard(regular)).toEqual(collapsed)
24
+ })
25
+
26
+ test('extension mode returns to the regular controls, staying expanded', () => {
27
+ // The chevron exits extension mode first; it does NOT fold from there.
28
+ expect(toggleCard(engineering)).toEqual(regular)
29
+ })
30
+ })
31
+
32
+ describe('toggleExtension (header icon press)', () => {
33
+ test('folded card opens straight into extension-only mode', () => {
34
+ expect(toggleExtension(collapsed, 'bones:eng')).toEqual(engineering)
35
+ })
36
+
37
+ test('regular expanded card swaps to extension-only mode', () => {
38
+ expect(toggleExtension(regular, 'bones:eng')).toEqual(engineering)
39
+ })
40
+
41
+ test('pressing the ACTIVE extension icon again returns to regular mode', () => {
42
+ expect(toggleExtension(engineering, 'bones:eng')).toEqual(regular)
43
+ })
44
+
45
+ test('pressing another extension icon switches extension modes directly', () => {
46
+ expect(toggleExtension(engineering, 'other:ext')).toEqual({
47
+ collapsed: false,
48
+ activeExtensionId: 'other:ext',
49
+ })
50
+ })
51
+ })
52
+
53
+ describe('resolveActiveExtension', () => {
54
+ const bones = { id: 'bones:eng' }
55
+ const other = { id: 'other:ext' }
56
+ const extensions = [bones, other]
57
+
58
+ test('null id → regular mode (no extension)', () => {
59
+ expect(resolveActiveExtension(null, extensions)).toBeNull()
60
+ })
61
+
62
+ test('matching id → that extension fills the body', () => {
63
+ expect(resolveActiveExtension('bones:eng', extensions)).toBe(bones)
64
+ expect(resolveActiveExtension('other:ext', extensions)).toBe(other)
65
+ })
66
+
67
+ test('stale id (kind changed / plugin gated off) falls back to regular', () => {
68
+ expect(resolveActiveExtension('bones:eng', [])).toBeNull()
69
+ expect(resolveActiveExtension('gone:ext', extensions)).toBeNull()
70
+ })
71
+ })
72
+
73
+ describe('full user flows (QA script)', () => {
74
+ test('chevron → regular only; fold; icon → extension only; icon again → regular', () => {
75
+ let mode = collapsed
76
+ mode = toggleCard(mode) // chevron
77
+ expect(mode).toEqual(regular)
78
+ mode = toggleCard(mode) // fold
79
+ expect(mode).toEqual(collapsed)
80
+ mode = toggleExtension(mode, 'bones:eng') // bones icon
81
+ expect(mode).toEqual(engineering)
82
+ mode = toggleExtension(mode, 'bones:eng') // bones icon again
83
+ expect(mode).toEqual(regular)
84
+ })
85
+
86
+ test('extension mode exits via the chevron too', () => {
87
+ let mode = toggleExtension(collapsed, 'bones:eng')
88
+ expect(mode).toEqual(engineering)
89
+ mode = toggleCard(mode) // chevron
90
+ expect(mode).toEqual(regular)
91
+ })
92
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Mode machine for the floating inspector card (`PanelWrapper`).
3
+ *
4
+ * The card is in exactly one of three modes — the two expanded modes are
5
+ * EITHER/OR, never combined:
6
+ *
7
+ * - collapsed: header only;
8
+ * - regular: the node kind's own controls (`children`) — no plugin
9
+ * inspector-extension sections appended;
10
+ * - extension: ONE plugin inspector-extension's content fills the body
11
+ * (its own section chrome), the regular controls are hidden.
12
+ *
13
+ * Transitions:
14
+ * - chevron / header press ({@link toggleCard}): collapsed → regular,
15
+ * regular → collapsed, extension → regular (exit extension mode first,
16
+ * stay expanded);
17
+ * - extension icon press ({@link toggleExtension}): enters that
18
+ * extension's mode from anywhere (expanding a folded card); pressing
19
+ * the ACTIVE extension's icon again returns to regular.
20
+ */
21
+
22
+ export interface InspectorCardMode {
23
+ /** Card folded to its header. */
24
+ collapsed: boolean
25
+ /** Extension whose content fills the body; null = regular controls. */
26
+ activeExtensionId: string | null
27
+ }
28
+
29
+ /** Chevron / header press. */
30
+ export function toggleCard(mode: InspectorCardMode): InspectorCardMode {
31
+ // Folded → expand to the regular controls.
32
+ if (mode.collapsed) return { collapsed: false, activeExtensionId: null }
33
+ // Extension mode → back to the regular controls (stay expanded).
34
+ if (mode.activeExtensionId !== null) return { collapsed: false, activeExtensionId: null }
35
+ // Regular expanded → fold.
36
+ return { collapsed: true, activeExtensionId: null }
37
+ }
38
+
39
+ /** Header extension-icon press. */
40
+ export function toggleExtension(mode: InspectorCardMode, extensionId: string): InspectorCardMode {
41
+ // The active extension's icon pressed again → back to the regular controls.
42
+ if (!mode.collapsed && mode.activeExtensionId === extensionId) {
43
+ return { collapsed: false, activeExtensionId: null }
44
+ }
45
+ // Anywhere else (folded, regular, another extension) → this extension only.
46
+ return { collapsed: false, activeExtensionId: extensionId }
47
+ }
48
+
49
+ /**
50
+ * The extension whose content should fill the card body, or null for the
51
+ * regular controls. A stale `activeExtensionId` (selection changed kind,
52
+ * plugin uninstalled, registry reset) safely falls back to regular mode.
53
+ */
54
+ export function resolveActiveExtension<E extends { id: string }>(
55
+ activeExtensionId: string | null,
56
+ extensions: readonly E[],
57
+ ): E | null {
58
+ if (activeExtensionId === null) return null
59
+ return extensions.find((extension) => extension.id === activeExtensionId) ?? null
60
+ }
@@ -22,6 +22,13 @@ const wall: HotSetCandidate = {
22
22
  exposesTop: false,
23
23
  attachClass: 'surface',
24
24
  }
25
+ const block: HotSetCandidate = {
26
+ type: 'block',
27
+ isFloorLike: false,
28
+ exposesTop: true,
29
+ exposesSides: true,
30
+ attachClass: 'surface',
31
+ }
25
32
  const ceiling: HotSetCandidate = {
26
33
  type: 'ceiling',
27
34
  isFloorLike: false,
@@ -63,8 +70,9 @@ describe('attachClassOf', () => {
63
70
  })
64
71
 
65
72
  describe('isPickableForAttach — wall-mounted (window)', () => {
66
- test('only walls are eligible; floor/ceiling/tops are not', () => {
73
+ test('walls and block faces are eligible; floor/ceiling/tops are not', () => {
67
74
  expect(isPickableForAttach('wall', wall)).toBe(true)
75
+ expect(isPickableForAttach('wall', block)).toBe(true)
68
76
  expect(isPickableForAttach('wall', floor)).toBe(false)
69
77
  expect(isPickableForAttach('wall', ceiling)).toBe(false)
70
78
  expect(isPickableForAttach('wall', table)).toBe(false)
@@ -109,6 +117,7 @@ describe('isCandidateInHotSet — by scope', () => {
109
117
  nodeType: 'item',
110
118
  view: '3d' as const,
111
119
  pressDrag: false,
120
+ driver: 'move-tool' as const,
112
121
  }
113
122
  expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
114
123
  expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
@@ -30,6 +30,8 @@ export type HotSetCandidate = {
30
30
  // The candidate exposes a usable top surface (registry
31
31
  // `capabilities.surfaces.top`) — a table, a shelf, a slab.
32
32
  exposesTop: boolean
33
+ // The candidate declares wall-like side faces through registry surfaces.
34
+ exposesSides?: boolean
33
35
  // The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
34
36
  // the ceiling and must never act as a host top (Track E).
35
37
  attachClass: AttachClass
@@ -38,7 +40,7 @@ export type HotSetCandidate = {
38
40
  // For a node whose attach class is `placed`, is `candidate` a valid
39
41
  // host/surface to pick during placement or move?
40
42
  export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
41
- if (placed === 'wall') return candidate.type === 'wall'
43
+ if (placed === 'wall') return candidate.type === 'wall' || candidate.exposesSides === true
42
44
  if (placed === 'ceiling') return candidate.type === 'ceiling'
43
45
  // Surface-resting: the floor, or any host that exposes a top surface — but
44
46
  // never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import type { AnyNode } from '@pascal-app/core'
3
- import { resolveOverlayPolicy } from './overlay-policy'
3
+ import {
4
+ resolveFloatingActionMenuVisibility,
5
+ resolveOverlayPolicy,
6
+ shouldShowEditingControls,
7
+ } from './overlay-policy'
4
8
  import type { ActiveInteractionScope } from './scope'
5
9
 
6
10
  const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
@@ -13,6 +17,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
13
17
  nodeType: 'item',
14
18
  view: '3d',
15
19
  pressDrag: false,
20
+ driver: 'move-tool',
16
21
  },
17
22
  { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
18
23
  { kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
@@ -50,3 +55,30 @@ describe('resolveOverlayPolicy', () => {
50
55
  }
51
56
  })
52
57
  })
58
+
59
+ describe('shouldShowEditingControls', () => {
60
+ test('hides controls that can mutate a read-only scene', () => {
61
+ expect(shouldShowEditingControls(false)).toBe(true)
62
+ expect(shouldShowEditingControls(true)).toBe(false)
63
+ })
64
+ })
65
+
66
+ describe('resolveFloatingActionMenuVisibility', () => {
67
+ test('keeps the active measurement pill while hiding action buttons during a height drag', () => {
68
+ const visibility = resolveFloatingActionMenuVisibility(
69
+ { kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' },
70
+ true,
71
+ )
72
+
73
+ expect(visibility).toEqual({ root: true, actions: false })
74
+ })
75
+
76
+ test('hides the whole menu during interactions without an active measurement pill', () => {
77
+ const visibility = resolveFloatingActionMenuVisibility(
78
+ { kind: 'handle-drag', nodeId: 'wall_1', handle: 'elevation' },
79
+ false,
80
+ )
81
+
82
+ expect(visibility).toEqual({ root: false, actions: false })
83
+ })
84
+ })
@@ -57,3 +57,19 @@ const ACTIVE_POLICY: OverlayPolicy = {
57
57
  export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
58
58
  return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
59
59
  }
60
+
61
+ export function resolveFloatingActionMenuVisibility(
62
+ scope: InteractionScope,
63
+ hasActiveMeasurementPill: boolean,
64
+ ): { root: boolean; actions: boolean } {
65
+ const policy = resolveOverlayPolicy(scope)
66
+ const actions = policy.conflictingControls === 'shown'
67
+ return {
68
+ root: actions || (hasActiveMeasurementPill && policy.activeAffordances === 'shown'),
69
+ actions,
70
+ }
71
+ }
72
+
73
+ export function shouldShowEditingControls(readOnly: boolean): boolean {
74
+ return !readOnly
75
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeDefinition,
4
+ type GridEvent,
5
+ nodeRegistry,
6
+ } from '@pascal-app/core'
7
+ import type { InteractionScope } from './scope'
8
+
9
+ type RegisteredDraftingConfig = NonNullable<AnyNodeDefinition['drafting']>
10
+ type SurfaceHit = NonNullable<GridEvent['surfaceHit']>
11
+
12
+ export const DRAFTING_SURFACE_EXTENSION_KEY = 'pascal:editor/drafting-surface'
13
+
14
+ export type DraftingSurfaceExtension = {
15
+ kind: SurfaceHit['kind']
16
+ raycast?: 'underside'
17
+ classifyFace?: (
18
+ node: AnyNode | undefined,
19
+ localNormal: readonly [number, number, number],
20
+ ) => Pick<SurfaceHit, 'face' | 'side'> | null
21
+ }
22
+
23
+ export function registeredDraftingConfig(scope: InteractionScope): RegisteredDraftingConfig | null {
24
+ if (scope.kind !== 'drafting') return null
25
+ return nodeRegistry.get(scope.tool)?.drafting ?? null
26
+ }
27
+
28
+ export function registeredDraftingSurface(
29
+ definition: AnyNodeDefinition,
30
+ ): DraftingSurfaceExtension | null {
31
+ return (
32
+ (definition.extensions?.[DRAFTING_SURFACE_EXTENSION_KEY] as
33
+ | DraftingSurfaceExtension
34
+ | undefined) ?? null
35
+ )
36
+ }
@@ -35,11 +35,31 @@ export type InteractionScope =
35
35
  nodeType: string
36
36
  view: InteractionView
37
37
  pressDrag: boolean
38
+ driver: 'move-tool' | 'registry-tool'
38
39
  }
39
40
  // Moving an existing node.
40
41
  | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
41
42
  // Dragging a resize/translate/rotate handle of a selected node.
42
43
  | { kind: 'handle-drag'; nodeId: string; handle: string }
44
+ // Editing the internal topology of one block. This scope remains
45
+ // active for the whole edit-mode session so scene selection and whole-node
46
+ // movement cannot claim the same pointer stream.
47
+ | {
48
+ kind: 'mesh-editing'
49
+ nodeId: string
50
+ phase: 'selecting' | 'operating'
51
+ operator?:
52
+ | 'translate'
53
+ | 'rotate'
54
+ | 'scale'
55
+ | 'extrude'
56
+ | 'inset'
57
+ | 'merge'
58
+ | 'dissolve'
59
+ | 'loop-cut'
60
+ | 'bevel'
61
+ | 'delete'
62
+ }
43
63
  // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
44
64
  | { kind: 'drafting'; tool: string }
45
65
  // Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
@@ -88,6 +108,7 @@ export function scopeNodeId(scope: InteractionScope): string | null {
88
108
  case 'placing':
89
109
  case 'moving':
90
110
  case 'handle-drag':
111
+ case 'mesh-editing':
91
112
  case 'reshaping':
92
113
  return scope.nodeId
93
114
  default:
@@ -111,6 +132,16 @@ export function selectionEnabled(scope: InteractionScope): boolean {
111
132
  return scope.kind === 'idle'
112
133
  }
113
134
 
135
+ export function meshEditScope(
136
+ nodeId: string,
137
+ phase: 'selecting' | 'operating' = 'selecting',
138
+ operator?: Extract<InteractionScope, { kind: 'mesh-editing' }>['operator'],
139
+ ): ActiveInteractionScope {
140
+ return operator
141
+ ? { kind: 'mesh-editing', nodeId, phase, operator }
142
+ : { kind: 'mesh-editing', nodeId, phase }
143
+ }
144
+
114
145
  // Derived views of the scope that mirror the legacy `useEditor` flags they
115
146
  // replaced. Each returns null unless that exact interaction is active, so a
116
147
  // stale payload is unrepresentable: the value is a pure function of the single