@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
@@ -1,4 +1,15 @@
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'
2
13
 
3
14
  export type HistoryCommandState = {
4
15
  canRedo: boolean
@@ -61,29 +72,92 @@ function notifyHistoryCommandListeners() {
61
72
  for (const listener of [...historyCommandListeners]) listener()
62
73
  }
63
74
 
64
- function refreshSceneAfterHistoryJump() {
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
+ }
65
99
  useLiveNodeOverrides.getState().clearAll()
66
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
+ }
67
112
 
68
113
  const state = useScene.getState()
69
- 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
70
120
  state.markDirty(node.id)
121
+ if (node.parentId && state.nodes[node.parentId as AnyNodeId]) {
122
+ state.markDirty(node.parentId as AnyNodeId)
123
+ }
71
124
  }
72
125
  }
73
126
 
127
+ export function shouldCancelDraftOnHistoryJump(): boolean {
128
+ const scope = useInteractionScope.getState().scope
129
+ return registeredDraftingConfig(scope)?.cancelOnHistoryJump === true
130
+ }
131
+
74
132
  export function runUndo(): HistoryCommandResult {
75
- if (historyCommandDelegate) return historyCommandDelegate.undo()
133
+ if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
134
+ if (historyCommandDelegate) {
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
140
+ }
76
141
  if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' }
142
+ markPerfAction('undo')
143
+ const previewLayout = capturePreviewLayout()
77
144
  useScene.temporal.getState().undo()
78
- refreshSceneAfterHistoryJump()
145
+ refreshSceneAfterHistoryJump(previewLayout)
79
146
  return { kind: 'applied', persistence: 'local' }
80
147
  }
81
148
 
82
149
  export function runRedo(): HistoryCommandResult {
83
- if (historyCommandDelegate) return historyCommandDelegate.redo()
150
+ if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
151
+ if (historyCommandDelegate) {
152
+ const result = historyCommandDelegate.redo()
153
+ if (result.kind !== 'empty') markPerfAction('redo')
154
+ return result
155
+ }
84
156
  if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' }
157
+ markPerfAction('redo')
158
+ const previewLayout = capturePreviewLayout()
85
159
  useScene.temporal.getState().redo()
86
- refreshSceneAfterHistoryJump()
160
+ refreshSceneAfterHistoryJump(previewLayout)
87
161
  return { kind: 'applied', persistence: 'local' }
88
162
  }
89
163
 
@@ -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
+ }
@@ -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