@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
@@ -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
  }