@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
@@ -9,9 +9,12 @@ import {
9
9
  import { z } from 'zod'
10
10
  import {
11
11
  canDirectMoveNode,
12
+ EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY,
13
+ pointerEventHitsEditorHandle,
12
14
  resolveDirectManipulationNode,
13
15
  resolveDirectRotationDragDelta,
14
16
  resolveMoveActionNode,
17
+ shouldStartDirectMoveDrag,
15
18
  snapDirectRotationDelta,
16
19
  } from './direct-manipulation'
17
20
 
@@ -116,6 +119,91 @@ describe('canDirectMoveNode', () => {
116
119
  })
117
120
  })
118
121
 
122
+ describe('shouldStartDirectMoveDrag', () => {
123
+ test('arms a plain drag for a kind that opts into direct dragging', () => {
124
+ expect(
125
+ shouldStartDirectMoveDrag({
126
+ allowPlainDrag: true,
127
+ commandModifier: false,
128
+ handleOwnsPointer: false,
129
+ nodeId: 'cabinet_existing',
130
+ selectedIds: [],
131
+ }),
132
+ ).toBe(true)
133
+ })
134
+
135
+ test('keeps modifier dragging limited to the sole selected node', () => {
136
+ expect(
137
+ shouldStartDirectMoveDrag({
138
+ allowPlainDrag: false,
139
+ commandModifier: true,
140
+ handleOwnsPointer: false,
141
+ nodeId: 'item_selected',
142
+ selectedIds: ['item_selected'],
143
+ }),
144
+ ).toBe(true)
145
+ expect(
146
+ shouldStartDirectMoveDrag({
147
+ allowPlainDrag: false,
148
+ commandModifier: true,
149
+ handleOwnsPointer: false,
150
+ nodeId: 'item_other',
151
+ selectedIds: ['item_selected'],
152
+ }),
153
+ ).toBe(false)
154
+ })
155
+
156
+ test('does not arm body dragging when a resize handle owns the pointer', () => {
157
+ expect(
158
+ shouldStartDirectMoveDrag({
159
+ allowPlainDrag: true,
160
+ commandModifier: false,
161
+ handleOwnsPointer: true,
162
+ nodeId: 'cabinet_selected',
163
+ selectedIds: ['cabinet_selected'],
164
+ }),
165
+ ).toBe(false)
166
+ })
167
+ })
168
+
169
+ describe('pointerEventHitsEditorHandle', () => {
170
+ test('keeps a visible resize handle from falling through to a nearer cabinet body', () => {
171
+ expect(
172
+ pointerEventHitsEditorHandle({
173
+ intersections: [
174
+ { object: { userData: {} } },
175
+ {
176
+ object: {
177
+ userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true },
178
+ },
179
+ },
180
+ ],
181
+ }),
182
+ ).toBe(true)
183
+ })
184
+
185
+ test('recognises a handle when it is the nearest R3F intersection', () => {
186
+ expect(
187
+ pointerEventHitsEditorHandle({
188
+ intersections: [
189
+ {
190
+ object: {
191
+ userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true },
192
+ },
193
+ },
194
+ { object: { userData: {} } },
195
+ ],
196
+ }),
197
+ ).toBe(true)
198
+ })
199
+
200
+ test('does not claim ordinary scene intersections', () => {
201
+ expect(pointerEventHitsEditorHandle({ intersections: [{ object: { userData: {} } }] })).toBe(
202
+ false,
203
+ )
204
+ })
205
+ })
206
+
119
207
  describe('resolveDirectManipulationNode', () => {
120
208
  test('routes proxied members to their assembly for direct transforms', () => {
121
209
  const group = {
@@ -272,4 +360,48 @@ describe('resolveMoveActionNode', () => {
272
360
  }),
273
361
  ).toBe(child)
274
362
  })
363
+
364
+ test('routes a parent-frame child move to a rotatable assembly parent', () => {
365
+ const parentKind = 'move-action-rotatable-parent-kind-test'
366
+ const childKind = 'move-action-rotatable-child-kind-test'
367
+ registerTestDefinition(parentKind, {
368
+ capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
369
+ })
370
+ registerTestDefinition(childKind, {
371
+ capabilities: {
372
+ movable: {
373
+ axes: ['x', 'z'],
374
+ gridSnap: true,
375
+ parentFrame: {
376
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
377
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
378
+ parentRotationY: () => 0,
379
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
380
+ local[0],
381
+ local[1],
382
+ local[2],
383
+ ],
384
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
385
+ planX,
386
+ localY,
387
+ planZ,
388
+ ],
389
+ },
390
+ },
391
+ },
392
+ })
393
+ const parent = { id: 'move_action_rotatable_run', type: parentKind } as unknown as AnyNode
394
+ const child = {
395
+ id: 'move_action_rotatable_module',
396
+ type: childKind,
397
+ parentId: parent.id,
398
+ } as unknown as AnyNode
399
+
400
+ expect(
401
+ resolveMoveActionNode(child, {
402
+ [parent.id]: parent,
403
+ [child.id]: child,
404
+ }),
405
+ ).toBe(parent)
406
+ })
275
407
  })
@@ -45,6 +45,25 @@ const BESPOKE_SELECTION_MOVE_KINDS = new Set([
45
45
  'liquid-line',
46
46
  ])
47
47
 
48
+ export const EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY = 'editorHandleHitArea'
49
+
50
+ export function pointerEventHitsEditorHandle(event: unknown): boolean {
51
+ if (!event || typeof event !== 'object') return false
52
+ const intersections = (
53
+ event as {
54
+ intersections?: readonly {
55
+ object?: { userData?: Record<string, unknown> }
56
+ }[]
57
+ }
58
+ ).intersections
59
+ return (
60
+ intersections?.some(
61
+ (intersection) =>
62
+ intersection.object?.userData?.[EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY] === true,
63
+ ) ?? false
64
+ )
65
+ }
66
+
48
67
  export function canDirectMoveNode(node: AnyNode): boolean {
49
68
  // These MEP kinds own move through bespoke selection rigs (latch cubes,
50
69
  // directional arrows, grid-driven previews). Sending body drags/clicks
@@ -64,6 +83,24 @@ export function canDirectMoveNode(node: AnyNode): boolean {
64
83
  return isMovable(node)
65
84
  }
66
85
 
86
+ export function shouldStartDirectMoveDrag({
87
+ allowPlainDrag,
88
+ commandModifier,
89
+ handleOwnsPointer,
90
+ nodeId,
91
+ selectedIds,
92
+ }: {
93
+ allowPlainDrag: boolean
94
+ commandModifier: boolean
95
+ handleOwnsPointer: boolean
96
+ nodeId: string
97
+ selectedIds: readonly string[]
98
+ }): boolean {
99
+ if (handleOwnsPointer) return false
100
+ if (commandModifier) return selectedIds.length === 1 && selectedIds[0] === nodeId
101
+ return allowPlainDrag && selectedIds.length < 2
102
+ }
103
+
67
104
  export function resolveDirectManipulationNode(
68
105
  node: AnyNode,
69
106
  nodes: Readonly<Record<string, AnyNode | undefined>>,
@@ -80,7 +117,7 @@ export function resolveMoveActionNode(
80
117
  ): AnyNode {
81
118
  const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
82
119
  const parent = parentFrame?.resolveParent(node, nodes as Readonly<Record<string, AnyNode>>)
83
- return parent?.type === node.type ? parent : node
120
+ return parent && (parent.type === node.type || canDirectRotateNode(parent)) ? parent : node
84
121
  }
85
122
 
86
123
  export function snapDirectRotationDelta(delta: number, free: boolean): number {
@@ -1,6 +1,7 @@
1
- import type { AnyNode, EditorApi } from '@pascal-app/core'
1
+ import { type AnyNode, type EditorApi, useScene } from '@pascal-app/core'
2
2
  import useEditor from '../store/use-editor'
3
3
  import useInteractionScope from '../store/use-interaction-scope'
4
+ import { resolveDirectManipulationNode, resolveMoveActionNode } from './direct-manipulation'
4
5
  import {
5
6
  controlPointReshapeScope,
6
7
  endpointReshapeScope,
@@ -25,14 +26,16 @@ export function createEditorApi(): EditorApi {
25
26
  // (every concrete kind enumerated). Descriptors pass any node; the
26
27
  // cast lets registry-driven move kinds through without forcing a
27
28
  // schema-level type widening.
28
- editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
29
+ const target = resolveMoveActionNode(node, useScene.getState().nodes)
30
+ editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
29
31
  },
30
32
  engageMoveDrag(node: AnyNode) {
31
33
  const editor = useEditor.getState()
32
34
  // Flag drag mode BEFORE mounting the move tool so the coordinator reads
33
35
  // it at setup and wires its commit-on-release listener.
34
36
  editor.setPlacementDragMode(true)
35
- editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
37
+ const target = resolveDirectManipulationNode(node, useScene.getState().nodes)
38
+ editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
36
39
  },
37
40
  engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
38
41
  // Endpoint reshape is kind-agnostic: the scope carries the node id + which
@@ -1,9 +1,10 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
3
3
  import useElevationGuides from '../store/use-elevation-guides'
4
4
  import {
5
5
  clearStructuralElevationGuide,
6
6
  collectElevationSnapTargets,
7
+ publishResolvedElevationGuide,
7
8
  publishStructuralElevationGuide,
8
9
  resolveElevationSnapMatch,
9
10
  resolveStructuralElevationSnap,
@@ -42,6 +43,10 @@ function structuralScene() {
42
43
  }
43
44
 
44
45
  describe('elevation guides', () => {
46
+ afterEach(() => {
47
+ useElevationGuides.setState({ guide: null })
48
+ })
49
+
45
50
  test('collects the level, slab faces, and ceiling plane on the source level', () => {
46
51
  const { level, nodes, slab } = structuralScene()
47
52
  const targets = collectElevationSnapTargets(
@@ -104,4 +109,26 @@ describe('elevation guides', () => {
104
109
  publishStructuralElevationGuide(source, 0.7, nodes)
105
110
  expect(useElevationGuides.getState().guide).toBeNull()
106
111
  })
112
+
113
+ test('publishes an explicitly resolved neighboring datum', () => {
114
+ const { level } = structuralScene()
115
+ useElevationGuides.setState({ guide: null })
116
+
117
+ publishResolvedElevationGuide(
118
+ { nodeId: 'leanto_moving', levelId: level.id, anchor: [2, 1] },
119
+ {
120
+ id: 'leanto_neighbor:high-edge',
121
+ elevation: 3.4,
122
+ anchor: [5, 1],
123
+ label: 'Neighbor shed edge',
124
+ },
125
+ )
126
+
127
+ expect(useElevationGuides.getState().guide).toMatchObject({
128
+ ownerId: 'leanto_moving',
129
+ elevation: 3.4,
130
+ direction: [1, 0],
131
+ label: 'Neighbor shed edge',
132
+ })
133
+ })
107
134
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ type FenceNode,
4
5
  findLevelAncestorId,
5
6
  getWallBaseElevationForNodes,
6
7
  getWallEffectiveHeightForNodes,
@@ -53,8 +54,10 @@ function segmentCenter(
53
54
  // A stale host resolves to the level base — the sculpted ground under the
54
55
  // fence's start point, sampled where the builder samples it, so the guide line
55
56
  // lands on the rail it claims to describe.
56
- function fenceBaseElevation(node: AnyNode, nodes: Record<string, AnyNode>): number {
57
- if (node.type !== 'fence') return 0
57
+ export function getFenceBaseElevationForNodes(
58
+ node: FenceNode,
59
+ nodes: Record<string, AnyNode>,
60
+ ): number {
58
61
  const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
59
62
  const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
60
63
  const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
@@ -161,7 +164,7 @@ export function collectElevationSnapTargets(
161
164
  }
162
165
 
163
166
  if (node.type === 'fence') {
164
- const base = fenceBaseElevation(node, nodes)
167
+ const base = getFenceBaseElevationForNodes(node, nodes)
165
168
  const center = segmentCenter(node.start, node.end)
166
169
  targets.push({
167
170
  id: `${node.id}:base`,
@@ -245,8 +248,20 @@ export function publishStructuralElevationGuide(
245
248
  return
246
249
  }
247
250
 
248
- const dx = match.target.anchor[0] - source.anchor[0]
249
- const dz = match.target.anchor[1] - source.anchor[1]
251
+ publishResolvedElevationGuide(source, match.target)
252
+ }
253
+
254
+ export function publishResolvedElevationGuide(
255
+ source: ElevationGuideSource,
256
+ target: ElevationSnapTarget,
257
+ ): void {
258
+ if (!source.levelId) {
259
+ clearStructuralElevationGuide(source.nodeId)
260
+ return
261
+ }
262
+
263
+ const dx = target.anchor[0] - source.anchor[0]
264
+ const dz = target.anchor[1] - source.anchor[1]
250
265
  const length = Math.hypot(dx, dz)
251
266
  const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
252
267
 
@@ -255,8 +270,8 @@ export function publishStructuralElevationGuide(
255
270
  levelId: source.levelId,
256
271
  center: source.anchor,
257
272
  direction,
258
- elevation: match.elevation,
259
- label: match.target.label,
273
+ elevation: target.elevation,
274
+ label: target.label,
260
275
  })
261
276
  }
262
277
 
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three'
3
+ import { getFloatingMenuScale } from './floating-menu-scale'
4
+
5
+ describe('getFloatingMenuScale', () => {
6
+ test('uses the standard orthographic zoom scale and clamps its range', () => {
7
+ const camera = new OrthographicCamera()
8
+ const anchor = new Vector3()
9
+
10
+ camera.zoom = 15
11
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
12
+ camera.zoom = 2
13
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
14
+ camera.zoom = 40
15
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
16
+ })
17
+
18
+ test('uses inverse perspective-camera distance and clamps its range', () => {
19
+ const camera = new PerspectiveCamera()
20
+ const anchor = new Vector3()
21
+
22
+ camera.position.set(0, 0, 16)
23
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
24
+ camera.position.set(0, 0, 48)
25
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
26
+ camera.position.set(0, 0, 6)
27
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
28
+ })
29
+ })
@@ -0,0 +1,14 @@
1
+ import { type Camera, OrthographicCamera, type Vector3 } from 'three'
2
+
3
+ const MIN_MENU_SCALE = 0.5
4
+ const MAX_MENU_SCALE = 1
5
+ const REF_ORTHO_ZOOM = 20
6
+ const REF_CAMERA_DISTANCE = 12
7
+
8
+ export function getFloatingMenuScale(camera: Camera, anchor: Vector3): number {
9
+ const raw =
10
+ camera instanceof OrthographicCamera
11
+ ? camera.zoom / REF_ORTHO_ZOOM
12
+ : REF_CAMERA_DISTANCE / Math.max(camera.position.distanceTo(anchor), 0.001)
13
+ return Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
14
+ }
@@ -1,8 +1,23 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { LevelNode, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
2
4
  import useAlignmentGuides from '../../store/use-alignment-guides'
3
- import { applyFloorplanAlignment } from './apply-alignment'
5
+ import { alignFloorplanDraftPoint, applyFloorplanAlignment } from './apply-alignment'
4
6
 
5
7
  describe('applyFloorplanAlignment', () => {
8
+ beforeEach(() => {
9
+ // These tests assume no active building (alignment runs on world axes).
10
+ // The scene/viewer stores are process-wide singletons, so an earlier test
11
+ // FILE can leak a selected building fixture into them — under bun's
12
+ // platform-dependent file order that turned into an order-dependent
13
+ // failure (getActiveBuildingPose reading a fixture building without a
14
+ // rotation array). Pin the empty-scene context explicitly.
15
+ useScene.setState({ nodes: {} } as never)
16
+ useViewer.setState({
17
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
18
+ } as never)
19
+ })
20
+
6
21
  afterEach(() => {
7
22
  useAlignmentGuides.getState().clear()
8
23
  })
@@ -22,4 +37,34 @@ describe('applyFloorplanAlignment', () => {
22
37
  expect(result.guides).toHaveLength(1)
23
38
  expect(useAlignmentGuides.getState().guides).toHaveLength(1)
24
39
  })
40
+
41
+ test('can restrict candidates to the active level', () => {
42
+ const lowerLevel = LevelNode.parse({ id: 'level_lower', level: 0 })
43
+ const upperLevel = LevelNode.parse({ id: 'level_upper', level: 1 })
44
+ const lowerWall = WallNode.parse({
45
+ id: 'wall_lower',
46
+ parentId: lowerLevel.id,
47
+ start: [10, 10],
48
+ end: [14, 10],
49
+ })
50
+ const upperWall = WallNode.parse({
51
+ id: 'wall_upper',
52
+ parentId: upperLevel.id,
53
+ start: [0, 0],
54
+ end: [4, 0],
55
+ })
56
+ useScene.setState({
57
+ nodes: Object.fromEntries(
58
+ [lowerLevel, upperLevel, lowerWall, upperWall].map((node) => [node.id, node]),
59
+ ),
60
+ } as never)
61
+
62
+ expect(
63
+ alignFloorplanDraftPoint([0.04, 0], {
64
+ applySnap: true,
65
+ levelId: lowerLevel.id,
66
+ }),
67
+ ).toEqual([0.04, 0])
68
+ expect(useAlignmentGuides.getState().guides).toHaveLength(0)
69
+ })
25
70
  })
@@ -2,6 +2,7 @@ import {
2
2
  type AlignmentAnchor,
3
3
  type AlignmentGuide,
4
4
  collectAlignmentAnchors,
5
+ resolveLevelId,
5
6
  useScene,
6
7
  } from '@pascal-app/core'
7
8
  import useAlignmentGuides from '../../store/use-alignment-guides'
@@ -96,13 +97,21 @@ export function alignFloorplanDraftPoint(
96
97
  bypass?: boolean
97
98
  threshold?: number
98
99
  excludeIds?: readonly string[]
100
+ levelId?: string | null
99
101
  },
100
102
  ): [number, number] {
101
103
  if (opts?.bypass) {
102
104
  useAlignmentGuides.getState().clear()
103
105
  return [point[0], point[1]]
104
106
  }
105
- let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
107
+ const nodes = useScene.getState().nodes
108
+ let candidates = collectAlignmentAnchors(nodes, FLOORPLAN_DRAFT_ALIGN_ID)
109
+ if (opts && 'levelId' in opts) {
110
+ candidates = candidates.filter((anchor) => {
111
+ const candidate = nodes[anchor.nodeId as keyof typeof nodes]
112
+ return candidate ? resolveLevelId(candidate, nodes) === opts.levelId : false
113
+ })
114
+ }
106
115
  if (opts?.excludeIds?.length) {
107
116
  const excluded = new Set(opts.excludeIds)
108
117
  candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))