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

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 (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -387,12 +387,11 @@ export function rotateGroupSnapshots(
387
387
  return { starts: rotatedStarts, links: rotatedLinks }
388
388
  }
389
389
 
390
+ export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
391
+
390
392
  // Level-frame XZ extents of the participant DATA — the mesh-free sibling of
391
- // `computeGroupBox`, used when meshes aren't mounted yet and to re-seed
392
- // alignment anchors after a mid-drag rotation.
393
- export function participantExtents(
394
- starts: ParticipantStart[],
395
- ): { minX: number; minZ: number; maxX: number; maxZ: number } | null {
393
+ // `computeGroupBox`, used when meshes aren't mounted yet.
394
+ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null {
396
395
  let minX = Number.POSITIVE_INFINITY
397
396
  let minZ = Number.POSITIVE_INFINITY
398
397
  let maxX = Number.NEGATIVE_INFINITY
@@ -419,8 +418,73 @@ export function participantExtents(
419
418
  return { minX, minZ, maxX, maxZ }
420
419
  }
421
420
 
421
+ // The one footprint every group transform measures itself against: the
422
+ // selection's mounted meshes (world box, converted into the level frame) with
423
+ // the participant DATA extents as the fallback when the meshes aren't up yet
424
+ // (Duplicate picks up its clones a frame before their renderers mount). Anchor
425
+ // points alone sit metres inside a wide selection's real footprint, so a
426
+ // gesture that pivots on the data extents orbits a different point than the
427
+ // idle keyboard rotate and the rotate gizmos, which both use the mesh box.
428
+ export function groupPlanBounds(
429
+ box: Box3 | null,
430
+ starts: ParticipantStart[],
431
+ frameInv: Matrix4,
432
+ ): GroupPlanBounds | null {
433
+ if (!box) return participantExtents(starts)
434
+ const min = box.min.clone().applyMatrix4(frameInv)
435
+ const max = box.max.clone().applyMatrix4(frameInv)
436
+ return {
437
+ minX: Math.min(min.x, max.x),
438
+ minZ: Math.min(min.z, max.z),
439
+ maxX: Math.max(min.x, max.x),
440
+ maxZ: Math.max(min.z, max.z),
441
+ }
442
+ }
443
+
444
+ export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [
445
+ (b.minX + b.maxX) / 2,
446
+ (b.minZ + b.maxZ) / 2,
447
+ ]
448
+
449
+ // Re-seed the footprint after a mid-gesture rotation by orbiting the box
450
+ // corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA
451
+ // extents instead would slide the centre off the pivot the snapshots turned
452
+ // around, dragging the alignment anchors away from the group under the cursor.
453
+ export function rotatePlanBounds(
454
+ b: GroupPlanBounds,
455
+ center: { x: number; z: number },
456
+ delta: number,
457
+ ): GroupPlanBounds {
458
+ const cos = Math.cos(delta)
459
+ const sin = Math.sin(delta)
460
+ let minX = Number.POSITIVE_INFINITY
461
+ let minZ = Number.POSITIVE_INFINITY
462
+ let maxX = Number.NEGATIVE_INFINITY
463
+ let maxZ = Number.NEGATIVE_INFINITY
464
+ const corners: Vec2[] = [
465
+ [b.minX, b.minZ],
466
+ [b.maxX, b.minZ],
467
+ [b.maxX, b.maxZ],
468
+ [b.minX, b.maxZ],
469
+ ]
470
+ for (const [x, z] of corners) {
471
+ const dx = x - center.x
472
+ const dz = z - center.z
473
+ const rx = center.x + dx * cos - dz * sin
474
+ const rz = center.z + dx * sin + dz * cos
475
+ minX = Math.min(minX, rx)
476
+ minZ = Math.min(minZ, rz)
477
+ maxX = Math.max(maxX, rx)
478
+ maxZ = Math.max(maxZ, rz)
479
+ }
480
+ return { minX, minZ, maxX, maxZ }
481
+ }
482
+
422
483
  // Rigid group slide: shift every participant (and each linked neighbour's
423
- // shared endpoint) by the same level-frame XZ delta. Y and rotations untouched.
484
+ // shared endpoint) by the same level-frame XZ delta. Y is untouched; the
485
+ // snapshot's rotation rides along because a mid-gesture R/T turns the
486
+ // SNAPSHOTS — dropping it here would republish (and commit) the pre-rotation
487
+ // facing, orbiting the layout while every member keeps its old bearing.
424
488
  export function translateGroupPatches(
425
489
  starts: ParticipantStart[],
426
490
  links: LinkedNeighbor[],
@@ -437,7 +501,10 @@ export function translateGroupPatches(
437
501
  if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
438
502
  patches.push([s.id, patch])
439
503
  } else {
440
- patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
504
+ const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz]
505
+ const rotation =
506
+ s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation
507
+ patches.push([s.id, { position, rotation }])
441
508
  }
442
509
  }
443
510
  for (const l of links) {
@@ -0,0 +1,86 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { _roots, act, createRoot, extend } from '@react-three/fiber'
3
+ import { createElement } from 'react'
4
+ import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3, type WebGLRenderer } from 'three'
5
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
6
+ import useEditor from '../../../store/use-editor'
7
+ import { hitAreaRaycast, InvisibleHandleHitArea } from './handle-arrow'
8
+
9
+ extend({ Mesh })
10
+
11
+ test('an occluded handle does not sort ahead of a nearer scene body', () => {
12
+ const geometry = new BoxGeometry(0.5, 0.5, 0.5)
13
+ const material = new MeshBasicMaterial()
14
+ const body = new Mesh(geometry, material)
15
+ body.position.z = 1
16
+ body.updateMatrixWorld()
17
+ const handle = new Mesh(geometry, material)
18
+ handle.position.z = 2
19
+ handle.raycast = hitAreaRaycast
20
+ handle.updateMatrixWorld()
21
+
22
+ const raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 1))
23
+ const hits = raycaster.intersectObjects([body, handle], false)
24
+
25
+ expect(hits[0]?.object).toBe(body)
26
+ expect(hits.find((hit) => hit.object === handle)?.distance).toBeGreaterThan(
27
+ hits.find((hit) => hit.object === body)?.distance ?? Number.POSITIVE_INFINITY,
28
+ )
29
+
30
+ geometry.dispose()
31
+ material.dispose()
32
+ })
33
+
34
+ test('mounted handles disable synchronously during placement drags and restore after release', async () => {
35
+ const previousDragMode = useEditor.getState().placementDragMode
36
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
37
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
38
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
39
+ const canvas = new EventTarget() as HTMLCanvasElement
40
+ const root = createRoot(canvas)
41
+ const geometry = new BoxGeometry(1, 1, 1)
42
+ const material = new MeshBasicNodeMaterial()
43
+ try {
44
+ useEditor.setState({ placementDragMode: true })
45
+ await root.configure({
46
+ gl: { render() {}, setSize() {}, setPixelRatio() {} } as unknown as WebGLRenderer,
47
+ frameloop: 'never',
48
+ dpr: 1,
49
+ size: { width: 100, height: 100, top: 0, left: 0 },
50
+ })
51
+ const render = (scale: number) =>
52
+ createElement(InvisibleHandleHitArea, {
53
+ geometry,
54
+ material,
55
+ scale,
56
+ onPointerDown() {},
57
+ onPointerEnter() {},
58
+ onPointerLeave() {},
59
+ })
60
+ await act(async () => root.render(render(1)))
61
+ const scene = _roots.get(canvas)!.store.getState().scene
62
+ const handle = scene.children[0] as Mesh
63
+ scene.updateMatrixWorld(true)
64
+ const raycaster = new Raycaster(new Vector3(0, 0, 2), new Vector3(0, 0, -1))
65
+ raycaster.layers.enableAll()
66
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
67
+ useEditor.setState({ placementDragMode: false })
68
+ expect(handle.raycast).toBe(hitAreaRaycast)
69
+ expect(raycaster.intersectObject(handle).length).toBeGreaterThan(0)
70
+ useEditor.setState({ placementDragMode: true })
71
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
72
+ await act(async () => root.render(render(2)))
73
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
74
+ await act(async () => root.render(null))
75
+ const detachedRaycast = handle.raycast
76
+ useEditor.setState({ placementDragMode: false })
77
+ expect(handle.raycast).toBe(detachedRaycast)
78
+ } finally {
79
+ await act(async () => root.render(null))
80
+ _roots.delete(canvas)
81
+ geometry.dispose()
82
+ material.dispose()
83
+ useEditor.setState({ placementDragMode: previousDragMode })
84
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
85
+ }
86
+ })
@@ -21,6 +21,7 @@ import {
21
21
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
22
22
  import { MeshBasicNodeMaterial } from 'three/webgpu'
23
23
  import { EDITOR_LAYER } from '../../../lib/constants'
24
+ import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation'
24
25
  import useEditor from '../../../store/use-editor'
25
26
 
26
27
  // While a press-drag move is in flight (`placementDragMode`), the move tool
@@ -29,7 +30,7 @@ import useEditor from '../../../store/use-editor'
29
30
  // (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
30
31
  // Make every handle hit area inert for the duration; the indicator mesh still
31
32
  // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
32
- function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
33
+ export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
33
34
  if (useEditor.getState().placementDragMode) return
34
35
  Mesh.prototype.raycast.call(this, raycaster, intersects)
35
36
  }
@@ -66,6 +67,12 @@ const MOVE_CROSS_DEPTH = 0.06
66
67
  const MOVE_CROSS_BEVEL_THICKNESS = 0.018
67
68
  const MOVE_CROSS_BEVEL_SIZE = 0.012
68
69
  const MOVE_CROSS_BEVEL_SEGMENTS = 6
70
+ const PLUS_HALF_LENGTH = 0.18
71
+ const PLUS_HALF_WIDTH = 0.045
72
+ const PLUS_DEPTH = 0.06
73
+ const PLUS_BEVEL_THICKNESS = 0.018
74
+ const PLUS_BEVEL_SIZE = 0.012
75
+ const PLUS_BEVEL_SEGMENTS = 6
69
76
  const ROTATE_HANDLE_RADIUS = 0.2
70
77
  const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
71
78
  const ROTATE_RIBBON_HALF_WIDTH = 0.02
@@ -73,7 +80,13 @@ const ROTATE_HEAD_HALF_WIDTH = 0.045
73
80
  const TRACKER_CUBE_SIZE = 0.16
74
81
  export const CORNER_HEX_RADIUS = 0.11
75
82
 
76
- export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
83
+ export type HandleArrowShape =
84
+ | 'chevron'
85
+ | 'cross'
86
+ | 'plus'
87
+ | 'curved-arrow'
88
+ | 'tracker'
89
+ | 'corner-picker'
77
90
  export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
78
91
 
79
92
  export type HandleArrowPlacement = {
@@ -304,6 +317,54 @@ function createMoveCrossHitAreaGeometry() {
304
317
  return merged
305
318
  }
306
319
 
320
+ function createPlusHandleGeometry() {
321
+ const shape = new Shape()
322
+ shape.moveTo(-PLUS_HALF_WIDTH, PLUS_HALF_LENGTH)
323
+ shape.lineTo(PLUS_HALF_WIDTH, PLUS_HALF_LENGTH)
324
+ shape.lineTo(PLUS_HALF_WIDTH, PLUS_HALF_WIDTH)
325
+ shape.lineTo(PLUS_HALF_LENGTH, PLUS_HALF_WIDTH)
326
+ shape.lineTo(PLUS_HALF_LENGTH, -PLUS_HALF_WIDTH)
327
+ shape.lineTo(PLUS_HALF_WIDTH, -PLUS_HALF_WIDTH)
328
+ shape.lineTo(PLUS_HALF_WIDTH, -PLUS_HALF_LENGTH)
329
+ shape.lineTo(-PLUS_HALF_WIDTH, -PLUS_HALF_LENGTH)
330
+ shape.lineTo(-PLUS_HALF_WIDTH, -PLUS_HALF_WIDTH)
331
+ shape.lineTo(-PLUS_HALF_LENGTH, -PLUS_HALF_WIDTH)
332
+ shape.lineTo(-PLUS_HALF_LENGTH, PLUS_HALF_WIDTH)
333
+ shape.lineTo(-PLUS_HALF_WIDTH, PLUS_HALF_WIDTH)
334
+ shape.closePath()
335
+ const geometry = new ExtrudeGeometry(shape, {
336
+ depth: PLUS_DEPTH,
337
+ bevelEnabled: true,
338
+ bevelThickness: PLUS_BEVEL_THICKNESS,
339
+ bevelSize: PLUS_BEVEL_SIZE,
340
+ bevelOffset: 0,
341
+ bevelSegments: PLUS_BEVEL_SEGMENTS,
342
+ curveSegments: 8,
343
+ steps: 1,
344
+ })
345
+ geometry.translate(0, 0, -PLUS_DEPTH / 2)
346
+ geometry.computeVertexNormals()
347
+ geometry.computeBoundingSphere()
348
+ return geometry
349
+ }
350
+
351
+ function createPlusHitAreaGeometry() {
352
+ const length = (PLUS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
353
+ const width = (PLUS_HALF_WIDTH + HIT_AREA_MARGIN) * 2
354
+ const horizontal = new BoxGeometry(length, width, HIT_AREA_THICKNESS)
355
+ const vertical = new BoxGeometry(width, length, HIT_AREA_THICKNESS)
356
+ const merged = mergeGeometries([horizontal, vertical], false)
357
+ if (!merged) {
358
+ vertical.dispose()
359
+ horizontal.computeBoundingSphere()
360
+ return horizontal
361
+ }
362
+ horizontal.dispose()
363
+ vertical.dispose()
364
+ merged.computeBoundingSphere()
365
+ return merged
366
+ }
367
+
307
368
  export function createRotateArrowHitAreaGeometry() {
308
369
  const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS
309
370
  const geometry = new TorusGeometry(
@@ -345,6 +406,7 @@ const CORNER_DISC_ROUND_SEGMENTS = 32
345
406
  function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
346
407
  if (shape === 'chevron') return createArrowHandleGeometry(thin)
347
408
  if (shape === 'cross') return createMoveCrossHandleGeometry()
409
+ if (shape === 'plus') return createPlusHandleGeometry()
348
410
  if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
349
411
  if (shape === 'tracker') {
350
412
  const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE)
@@ -362,6 +424,7 @@ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round
362
424
  function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
363
425
  if (shape === 'chevron') return createArrowHitAreaGeometry()
364
426
  if (shape === 'cross') return createMoveCrossHitAreaGeometry()
427
+ if (shape === 'plus') return createPlusHitAreaGeometry()
365
428
  if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
366
429
  if (shape === 'tracker') return createTrackerHitAreaGeometry()
367
430
  const geometry = new CircleGeometry(
@@ -373,7 +436,29 @@ function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
373
436
  }
374
437
 
375
438
  let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null
376
- let sharedHitAreaMaterialRefs = 0
439
+ const sharedHandleGeometries = new Map<string, BufferGeometry>()
440
+ const sharedHandleHitGeometries = new Map<string, BufferGeometry>()
441
+ const sharedHandleMaterials = new Map<string, MeshBasicNodeMaterial>()
442
+
443
+ function sharedHandleGeometry(shape: HandleArrowShape, thin: boolean, round: boolean) {
444
+ const key = `${shape}:${thin}:${round}`
445
+ let geometry = sharedHandleGeometries.get(key)
446
+ if (!geometry) {
447
+ geometry = createHandleArrowGeometry(shape, thin, round)
448
+ sharedHandleGeometries.set(key, geometry)
449
+ }
450
+ return geometry
451
+ }
452
+
453
+ function sharedHandleHitGeometry(shape: HandleArrowShape, round: boolean) {
454
+ const key = `${shape}:${round}`
455
+ let geometry = sharedHandleHitGeometries.get(key)
456
+ if (!geometry) {
457
+ geometry = createHandleArrowHitGeometry(shape, round)
458
+ sharedHandleHitGeometries.set(key, geometry)
459
+ }
460
+ return geometry
461
+ }
377
462
 
378
463
  function createInvisibleHitAreaMaterial() {
379
464
  return new MeshBasicNodeMaterial({
@@ -388,23 +473,8 @@ function createInvisibleHitAreaMaterial() {
388
473
  }
389
474
 
390
475
  export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial {
391
- const materialRef = useRef<MeshBasicNodeMaterial | null>(null)
392
- if (!materialRef.current) {
393
- sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
394
- materialRef.current = sharedHitAreaMaterial
395
- }
396
- useEffect(() => {
397
- sharedHitAreaMaterialRefs += 1
398
- return () => {
399
- sharedHitAreaMaterialRefs -= 1
400
- if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) {
401
- sharedHitAreaMaterial.dispose()
402
- sharedHitAreaMaterial = null
403
- sharedHitAreaMaterialRefs = 0
404
- }
405
- }
406
- }, [])
407
- return materialRef.current
476
+ sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
477
+ return sharedHitAreaMaterial
408
478
  }
409
479
 
410
480
  export function InvisibleHandleHitArea({
@@ -434,6 +504,7 @@ export function InvisibleHandleHitArea({
434
504
  raycast={hitAreaRaycast}
435
505
  renderOrder={HIT_AREA_RENDER_ORDER}
436
506
  scale={scale}
507
+ userData={{ [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }}
437
508
  />
438
509
  )
439
510
  }
@@ -461,19 +532,21 @@ export function useArrowMaterial(): MeshBasicNodeMaterial {
461
532
  )
462
533
  }
463
534
 
464
- function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial {
465
- return useMemo(
466
- () =>
467
- new MeshBasicNodeMaterial({
468
- color: new Color(ARROW_COLOR),
469
- side: DoubleSide,
470
- transparent: true,
471
- opacity: shape === 'corner-picker' ? 0.95 : 1,
472
- depthTest: false,
473
- depthWrite: shape !== 'corner-picker',
474
- }),
475
- [shape],
476
- )
535
+ function useHandleArrowMaterial(shape: HandleArrowShape, hover: boolean): MeshBasicNodeMaterial {
536
+ const key = `${shape}:${hover}`
537
+ let material = sharedHandleMaterials.get(key)
538
+ if (!material) {
539
+ material = new MeshBasicNodeMaterial({
540
+ color: new Color(hover ? ARROW_HOVER_COLOR : ARROW_COLOR),
541
+ side: DoubleSide,
542
+ transparent: true,
543
+ opacity: shape === 'corner-picker' ? 0.95 : 1,
544
+ depthTest: false,
545
+ depthWrite: shape !== 'corner-picker',
546
+ })
547
+ sharedHandleMaterials.set(key, material)
548
+ }
549
+ return material
477
550
  }
478
551
 
479
552
  function indicatorRenderOrder(shape: HandleArrowShape) {
@@ -497,15 +570,9 @@ export function HandleArrow({
497
570
  round = false,
498
571
  }: HandleArrowProps) {
499
572
  const visualShape = normalizeHandleArrowShape(shape, cursor)
500
- const geometry = useMemo(
501
- () => createHandleArrowGeometry(visualShape, thin, round),
502
- [visualShape, thin, round],
503
- )
504
- const hitGeometry = useMemo(
505
- () => createHandleArrowHitGeometry(visualShape, round),
506
- [visualShape, round],
507
- )
508
- const indicatorMaterial = useHandleArrowMaterial(visualShape)
573
+ const geometry = sharedHandleGeometry(visualShape, thin, round)
574
+ const hitGeometry = sharedHandleHitGeometry(visualShape, round)
575
+ const indicatorMaterial = useHandleArrowMaterial(visualShape, hover)
509
576
  const hitMaterial = useInvisibleHitAreaMaterial()
510
577
  const rootRef = useRef<Group>(null)
511
578
  const rotation: [number, number, number] = placement.rotation
@@ -517,9 +584,6 @@ export function HandleArrow({
517
584
  const scale = (hover ? hoverScale : 1) * placement.baseScale
518
585
  const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale
519
586
 
520
- useEffect(() => {
521
- indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR)
522
- }, [indicatorMaterial, hover])
523
587
  useEffect(() => {
524
588
  const hideForCapture = () => {
525
589
  if (rootRef.current) rootRef.current.visible = false
@@ -534,9 +598,6 @@ export function HandleArrow({
534
598
  emitter.off('thumbnail:after-capture', restoreAfterCapture)
535
599
  }
536
600
  }, [])
537
- useEffect(() => () => geometry.dispose(), [geometry])
538
- useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
539
- useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial])
540
601
 
541
602
  const handleEnter: PointerHandler = (event) => {
542
603
  event.stopPropagation()
@@ -0,0 +1,111 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { type AnyNode, createSceneApi, type LinearResizeHandle, useScene } from '@pascal-app/core'
3
+ import { Euler, Vector3 } from 'three'
4
+ import { createArrowHandleGeometry } from './handle-arrow'
5
+ import {
6
+ computeFreezeOffset,
7
+ resolveLinearHandlePosition,
8
+ resolveLinearHandleRotation,
9
+ } from './handle-placement'
10
+
11
+ const scene = createSceneApi(useScene)
12
+ for (const axis of ['x', 'y', 'z'] as const) {
13
+ test(`${axis} clearance preserves sufficient offsets and keeps even hovered arrow tails clear at different scales`, () => {
14
+ const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
15
+ const geometry = createArrowHandleGeometry(true)
16
+ geometry.computeBoundingBox()
17
+ const tail = geometry.boundingBox!.min.x * 1.12
18
+ for (const edge of [0.06, 1.2, -0.4]) {
19
+ const position: [number, number, number] = [0, 0, 0]
20
+ position[index] = edge + 0.15
21
+ const descriptor: LinearResizeHandle<null> = {
22
+ kind: 'linear-resize',
23
+ axis,
24
+ anchor: 'center',
25
+ currentValue: () => 1,
26
+ apply: () => null,
27
+ placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
28
+ }
29
+ for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
30
+ const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
31
+ expect(resolved[index] + tail * scale - edge).toBeGreaterThan(0.1 * scale)
32
+ if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
33
+ resolved.forEach((value, i) => {
34
+ if (i !== index) expect(value).toBe(position[i]!)
35
+ })
36
+ }
37
+ delete descriptor.placement.clearance
38
+ expect(resolveLinearHandlePosition(descriptor, null, scene, 3)).toBe(position)
39
+ }
40
+ geometry.dispose()
41
+ })
42
+ }
43
+
44
+ test('array rotations keep sibling handles finite during rotation and resize', () => {
45
+ const initial = { position: [1, 2, 3], rotation: [0, Math.PI / 2, 0] } as unknown as AnyNode
46
+ const rotated = { ...initial, rotation: [0, Math.PI, 0] } as unknown as AnyNode
47
+ expect(computeFreezeOffset(rotated, initial)).toEqual([0, 0, 0])
48
+ const moved = { ...initial, position: [2, 4, 3] } as unknown as AnyNode
49
+ const offset = computeFreezeOffset(moved, initial)
50
+ expect(offset[0]).toBeCloseTo(0)
51
+ expect(offset[1]).toBe(2)
52
+ expect(offset[2]).toBeCloseTo(1)
53
+ expect(
54
+ computeFreezeOffset(moved, { ...initial, rotation: Math.PI / 2 } as unknown as AnyNode),
55
+ ).toEqual(offset)
56
+ })
57
+
58
+ for (const axis of ['x', 'y', 'z'] as const) {
59
+ test(`${axis} negative direction keeps hovered arrow above its lower-edge clearance`, () => {
60
+ const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
61
+ const geometry = createArrowHandleGeometry(true)
62
+ geometry.computeBoundingBox()
63
+ const tail = -geometry.boundingBox!.min.x * 1.12
64
+ for (const edge of [-2, 0, 2]) {
65
+ const position: [number, number, number] = [0, 0, 0]
66
+ position[index] = edge - 0.15
67
+ const descriptor: LinearResizeHandle<null> = {
68
+ kind: 'linear-resize',
69
+ axis,
70
+ anchor: 'max',
71
+ direction: -1,
72
+ currentValue: () => 1,
73
+ apply: () => null,
74
+ placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
75
+ }
76
+ for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
77
+ const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
78
+ expect(edge - (resolved[index] + tail * scale)).toBeGreaterThan(0.1 * scale)
79
+ if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
80
+ const pointing = new Vector3(1, 0, 0)
81
+ .applyEuler(new Euler(...resolveLinearHandleRotation(descriptor, resolved)))
82
+ .applyAxisAngle(new Vector3(0, 1, 0), axis === 'z' ? -Math.PI / 2 : 0)
83
+ expect(pointing.getComponent(index)).toBeCloseTo(-1)
84
+ }
85
+ }
86
+ geometry.dispose()
87
+ })
88
+ }
89
+
90
+ test('unspecified direction preserves vertical position heuristic and wall-facing blade', () => {
91
+ const descriptor: LinearResizeHandle<null> = {
92
+ kind: 'linear-resize',
93
+ axis: 'y',
94
+ anchor: 'min',
95
+ currentValue: () => 1,
96
+ apply: () => null,
97
+ placement: { position: () => [0, 0, 0] },
98
+ }
99
+ expect(resolveLinearHandleRotation(descriptor, [0, -1, 0])).toEqual([
100
+ 0,
101
+ Math.PI / 2,
102
+ -Math.PI / 2,
103
+ ])
104
+ expect(resolveLinearHandleRotation(descriptor, [0, 1, 0])).toEqual([0, Math.PI / 2, Math.PI / 2])
105
+ expect(
106
+ resolveLinearHandleRotation({ ...descriptor, axis: 'x', faceNormal: true }, [0, 0, 0]),
107
+ ).toEqual([Math.PI / 2, 0, 0])
108
+ expect(
109
+ resolveLinearHandleRotation({ ...descriptor, axis: 'z', faceNormal: true }, [0, 0, 0]),
110
+ ).toEqual([0, 0, 0])
111
+ })
@@ -0,0 +1,69 @@
1
+ import type { AnyNode, LinearResizeHandle, RadialResizeHandle, SceneApi } from '@pascal-app/core'
2
+
3
+ export function resolveLinearHandlePosition<N>(
4
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
5
+ node: N,
6
+ scene: SceneApi,
7
+ baseScale: number,
8
+ ): readonly [number, number, number] {
9
+ const position = descriptor.placement.position(node, scene)
10
+ const clearance = descriptor.kind === 'linear-resize' ? descriptor.placement.clearance : undefined
11
+ if (!clearance) return position
12
+ const index = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
13
+ const result: [number, number, number] = [...position]
14
+ const direction = descriptor.kind === 'linear-resize' ? (descriptor.direction ?? 1) : 1
15
+ const edge = clearance.edge(node, scene) + direction * clearance.distance * baseScale
16
+ result[index] = direction === -1 ? Math.min(result[index], edge) : Math.max(result[index], edge)
17
+ return result
18
+ }
19
+
20
+ export function resolveLinearHandleRotation<N>(
21
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
22
+ position: readonly [number, number, number],
23
+ ): [number, number, number] {
24
+ const direction = descriptor.kind === 'linear-resize' ? descriptor.direction : undefined
25
+ if (descriptor.axis === 'y') {
26
+ const sign = direction ?? (position[1] < 0 ? -1 : 1)
27
+ return [0, Math.PI / 2, (sign * Math.PI) / 2]
28
+ }
29
+ const faceNormal =
30
+ descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal
31
+ return [faceNormal ? Math.PI / 2 : 0, 0, direction === -1 ? Math.PI : 0]
32
+ }
33
+
34
+ // Offset, in node-local frame, that compensates for `position` drift on
35
+ // the mesh during an asymmetric resize. Width/length L+R recompute
36
+ // `position` so the anchored edge stays world-fixed — the renderer
37
+ // follows that override, the ride object moves, and every arrow under
38
+ // it would drift along with the mesh center. Subtracting this offset
39
+ // from a non-active arrow's local placement undoes that drift so it
40
+ // stays at its pre-drag world position.
41
+ //
42
+ // Rotation drags don't change `position`, so the offset collapses to
43
+ // zero and non-active arrows naturally rotate with the mesh — which is
44
+ // the desired behaviour (the whole rig rotates as a unit).
45
+ export function computeFreezeOffset(
46
+ liveNode: AnyNode,
47
+ preDragNode: AnyNode,
48
+ ): [number, number, number] {
49
+ // Not every node in the union carries a `position` field (sites are the
50
+ // notable holdout — they don't have handles anyway, but TypeScript still
51
+ // requires us to discriminate). Guarded access keeps the freeze logic
52
+ // safe for the few node kinds that lack the field.
53
+ const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
54
+ const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
55
+ 0, 0, 0,
56
+ ]
57
+ const deltaWorldX = liveP[0] - preP[0]
58
+ const deltaWorldY = liveP[1] - preP[1]
59
+ const deltaWorldZ = liveP[2] - preP[2]
60
+ const rotation = (preDragNode as { rotation?: number | readonly number[] }).rotation
61
+ const rotY = typeof rotation === 'number' ? rotation : (rotation?.[1] ?? 0)
62
+ // World → node-local for Y-axis rotation by rotY (THREE.Object3D
63
+ // rotation-y convention): inverse is rotation by -rotY around +Y.
64
+ const cosR = Math.cos(rotY)
65
+ const sinR = Math.sin(rotY)
66
+ const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
67
+ const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
68
+ return [deltaLocalX, deltaWorldY, deltaLocalZ]
69
+ }