@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
@@ -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,67 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type HandleDragModifiers,
5
+ type LinearResizeHandle,
6
+ type SceneApi,
7
+ useLiveNodeOverrides,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { replacePreviewOverrideIds } from './preview-overrides'
11
+
12
+ export function createLinearResizeDragBinding({
13
+ descriptor,
14
+ initialNode,
15
+ nodeId,
16
+ sceneApi,
17
+ initialModifiers,
18
+ }: {
19
+ descriptor: LinearResizeHandle<AnyNode>
20
+ initialNode: AnyNode
21
+ nodeId: AnyNodeId
22
+ sceneApi: SceneApi
23
+ initialModifiers: HandleDragModifiers
24
+ }) {
25
+ const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId
26
+ let lastModifiers = initialModifiers
27
+ let previewOverrideIds = new Set<AnyNodeId>()
28
+
29
+ return {
30
+ overrideId,
31
+ commit: descriptor.commit
32
+ ? (patch: Partial<AnyNode>) =>
33
+ descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers)
34
+ : undefined,
35
+ apply(next: number, modifiers: HandleDragModifiers): Partial<AnyNode> {
36
+ lastModifiers = modifiers
37
+ const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial<AnyNode>
38
+ const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers)
39
+ if (!previewEntries) return patch
40
+
41
+ previewOverrideIds = replacePreviewOverrideIds(
42
+ previewOverrideIds,
43
+ previewEntries,
44
+ (previewId) => {
45
+ useLiveNodeOverrides.getState().clear(previewId)
46
+ useScene.getState().markDirty(previewId)
47
+ },
48
+ )
49
+ useLiveNodeOverrides
50
+ .getState()
51
+ .setMany(
52
+ previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
53
+ )
54
+ for (const [previewId] of previewEntries) {
55
+ useScene.getState().markDirty(previewId)
56
+ }
57
+ return patch
58
+ },
59
+ clearPreview(): void {
60
+ for (const previewId of previewOverrideIds) {
61
+ useLiveNodeOverrides.getState().clear(previewId)
62
+ useScene.getState().markDirty(previewId)
63
+ }
64
+ previewOverrideIds = new Set()
65
+ },
66
+ }
67
+ }
@@ -49,4 +49,50 @@ describe('resolveResizeSnapValue', () => {
49
49
  ).toBe(0.56)
50
50
  expect(magneticSnap).not.toHaveBeenCalled()
51
51
  })
52
+
53
+ it('applies a structural connection snap independently of the active mode', () => {
54
+ const connectionSnap = mock(() => 0.6)
55
+
56
+ expect(
57
+ resolveResizeSnapValue({
58
+ rawValue: 0.59,
59
+ gridSnapEnabled: false,
60
+ gridSnapActive: false,
61
+ gridSnapStep: 0.1,
62
+ magneticSnapActive: false,
63
+ connectionSnap,
64
+ }),
65
+ ).toBe(0.6)
66
+ expect(connectionSnap).toHaveBeenCalledWith(0.59)
67
+ })
68
+
69
+ it('bypasses a structural connection snap while force-moving', () => {
70
+ const connectionSnap = mock(() => 0.6)
71
+
72
+ expect(
73
+ resolveResizeSnapValue({
74
+ rawValue: 0.59,
75
+ gridSnapEnabled: false,
76
+ gridSnapActive: false,
77
+ gridSnapStep: 0.1,
78
+ magneticSnapActive: false,
79
+ connectionSnapActive: false,
80
+ connectionSnap,
81
+ }),
82
+ ).toBe(0.59)
83
+ expect(connectionSnap).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('keeps the last valid value when pointer projection is non-finite', () => {
87
+ expect(
88
+ resolveResizeSnapValue({
89
+ rawValue: Number.NaN,
90
+ fallbackValue: 12.3,
91
+ gridSnapEnabled: false,
92
+ gridSnapActive: false,
93
+ gridSnapStep: 0.5,
94
+ magneticSnapActive: false,
95
+ }),
96
+ ).toBe(12.3)
97
+ })
52
98
  })
@@ -2,22 +2,31 @@ import { snapScalar } from '@pascal-app/core'
2
2
 
3
3
  export function resolveResizeSnapValue({
4
4
  rawValue,
5
+ fallbackValue = rawValue,
5
6
  gridSnapEnabled,
6
7
  gridSnapActive,
7
8
  gridSnapStep,
8
9
  magneticSnapActive,
9
10
  magneticSnap,
11
+ connectionSnapActive = true,
12
+ connectionSnap,
10
13
  }: {
11
14
  rawValue: number
15
+ fallbackValue?: number
12
16
  gridSnapEnabled: boolean
13
17
  gridSnapActive: boolean
14
18
  gridSnapStep: number
15
19
  magneticSnapActive: boolean
16
20
  magneticSnap?: (value: number) => number
21
+ connectionSnapActive?: boolean
22
+ connectionSnap?: (value: number) => number
17
23
  }): number {
24
+ if (!Number.isFinite(rawValue)) return fallbackValue
18
25
  const gridValue =
19
26
  gridSnapEnabled && gridSnapActive && gridSnapStep > 0
20
27
  ? snapScalar(rawValue, gridSnapStep)
21
28
  : rawValue
22
- return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
29
+ const modeValue = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
30
+ const resolved = connectionSnapActive && connectionSnap ? connectionSnap(modeValue) : modeValue
31
+ return Number.isFinite(resolved) ? resolved : fallbackValue
23
32
  }
@@ -5,6 +5,7 @@ import {
5
5
  type AnyNodeId,
6
6
  type Cursor,
7
7
  createSceneApi,
8
+ type HandleDragModifiers,
8
9
  runAsSingleSceneHistoryStep,
9
10
  useLiveNodeOverrides,
10
11
  useScene,
@@ -46,6 +47,7 @@ export type HandleDragStartContext = {
46
47
 
47
48
  export type HandleDragMoveContext = {
48
49
  event: PointerEvent
50
+ modifiers: HandleDragModifiers
49
51
  getPointerRay: GetPointerRay
50
52
  intersectPlane: IntersectPlane
51
53
  }
@@ -177,6 +179,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
177
179
 
178
180
  let lastPatch: Partial<AnyNode> | null = null
179
181
  let historyPaused = true
182
+ let altKey = event.nativeEvent.altKey
180
183
 
181
184
  const resumeHistory = () => {
182
185
  if (!historyPaused) return
@@ -185,7 +188,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
185
188
  }
186
189
 
187
190
  const onMove = (moveEvent: PointerEvent) => {
188
- const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
191
+ const patch = session.move({
192
+ event: moveEvent,
193
+ modifiers: { altKey },
194
+ getPointerRay,
195
+ intersectPlane,
196
+ })
189
197
  if (!patch) return
190
198
  lastPatch = patch
191
199
  useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
@@ -199,6 +207,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
199
207
  window.removeEventListener('pointerup', onUp)
200
208
  window.removeEventListener('pointercancel', onCancel)
201
209
  window.removeEventListener('keydown', onKeyDown, true)
210
+ window.removeEventListener('keyup', onKeyUp, true)
202
211
  if (document.body.style.cursor === cursor) {
203
212
  document.body.style.cursor = ''
204
213
  }
@@ -240,17 +249,25 @@ export function useHandleDrag(args: UseHandleDragArgs) {
240
249
  // Escape / ⌘Z abort the drag — capture phase so they win over the global
241
250
  // use-keyboard arms (⌘Z must never history-jump under a live pointer).
242
251
  const onKeyDown = (e: KeyboardEvent) => {
252
+ if (e.key === 'Alt') {
253
+ altKey = true
254
+ return
255
+ }
243
256
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
244
257
  e.preventDefault()
245
258
  e.stopPropagation()
246
259
  swallowNextClick()
247
260
  onCancel()
248
261
  }
262
+ const onKeyUp = (e: KeyboardEvent) => {
263
+ if (e.key === 'Alt') altKey = false
264
+ }
249
265
 
250
266
  dragCleanupRef.current = onCancel
251
267
  window.addEventListener('pointermove', onMove)
252
268
  window.addEventListener('pointerup', onUp)
253
269
  window.addEventListener('pointercancel', onCancel)
254
270
  window.addEventListener('keydown', onKeyDown, true)
271
+ window.addEventListener('keyup', onKeyUp, true)
255
272
  }
256
273
  }