@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
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react'
4
+ import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
@@ -10,6 +10,7 @@ type NodeActionMenuProps = {
10
10
  onDelete?: MouseEventHandler<HTMLButtonElement>
11
11
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
12
12
  onMove?: MouseEventHandler<HTMLButtonElement>
13
+ onEditMesh?: MouseEventHandler<HTMLButtonElement>
13
14
  onCurve?: MouseEventHandler<HTMLButtonElement>
14
15
  /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */
15
16
  onGroup?: MouseEventHandler<HTMLButtonElement>
@@ -27,6 +28,7 @@ export function NodeActionMenu({
27
28
  onDelete,
28
29
  onDuplicate,
29
30
  onMove,
31
+ onEditMesh,
30
32
  onCurve,
31
33
  onGroup,
32
34
  onUngroup,
@@ -65,6 +67,17 @@ export function NodeActionMenu({
65
67
  <Move className="h-4 w-4" />
66
68
  </button>
67
69
  )}
70
+ {onEditMesh && (
71
+ <button
72
+ aria-label="Edit mesh"
73
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
74
+ onClick={onEditMesh}
75
+ title="Edit mesh"
76
+ type="button"
77
+ >
78
+ <PencilRuler className="h-4 w-4" />
79
+ </button>
80
+ )}
68
81
  {onGroup && (
69
82
  <button
70
83
  aria-label="Group selection"
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
47
47
  import { sfxEmitter } from '../../lib/sfx-bus'
48
48
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
49
49
  import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
50
+ import { useHandleGroup } from '../../store/use-handle-group'
50
51
  import useInteractionScope, {
51
52
  useEndpointReshape,
52
53
  useIsCurveReshape,
@@ -62,7 +63,12 @@ import {
62
63
  HandleArrow,
63
64
  NO_RAYCAST,
64
65
  } from './handles/handle-arrow'
65
- import { replacePreviewOverrideIds } from './handles/preview-overrides'
66
+ import {
67
+ computeFreezeOffset,
68
+ resolveLinearHandlePosition,
69
+ resolveLinearHandleRotation,
70
+ } from './handles/handle-placement'
71
+ import { createLinearResizeDragBinding, linearResizeFactor } from './handles/linear-resize-drag'
66
72
  import { resolveResizeSnapValue } from './handles/resize-snap'
67
73
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
68
74
 
@@ -227,6 +233,9 @@ export function NodeArrowHandles() {
227
233
  () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
228
234
  [rawNode, liveOverride],
229
235
  )
236
+ const activeGroup = useHandleGroup((s) =>
237
+ s.active && s.active.nodeId === node?.id ? s.active.group : null,
238
+ )
230
239
  const def = node ? nodeRegistry.get(node.type) : null
231
240
  const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
232
241
  const descriptors = useMemo(() => {
@@ -235,17 +244,18 @@ export function NodeArrowHandles() {
235
244
  typeof def.handles === 'function'
236
245
  ? def.handles(node as never, descriptorSceneApi)
237
246
  : (def.handles as HandleDescriptor[])
238
- // The whole-node move-cross gizmo is gone: moving is now click-to-move on
239
- // the selected node body (see selection-manager). Drop both flavours — the
240
- // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
241
- // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
242
- return all.filter(
243
- (d) =>
244
- d.kind !== 'translate' &&
245
- !('shape' in d && d.shape === 'move-cross') &&
246
- (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
247
- )
248
- }, [node, def, descriptorSceneApi])
247
+ return all.filter((descriptor) => {
248
+ if (activeGroup)
249
+ return descriptor.kind === 'linear-resize' && descriptor.latchGroup === activeGroup
250
+ if (descriptor.kind === 'translate') return false
251
+ const visible =
252
+ 'visible' in descriptor
253
+ ? descriptor.visible?.(node as never, descriptorSceneApi)
254
+ : undefined
255
+ if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true
256
+ return visible !== false
257
+ })
258
+ }, [node, def, descriptorSceneApi, activeGroup])
249
259
 
250
260
  const shouldRender =
251
261
  Boolean(node && descriptors?.length) &&
@@ -292,19 +302,29 @@ function NodeArrowHandlesForNode({
292
302
  node: AnyNode
293
303
  descriptors: HandleDescriptor[]
294
304
  }) {
305
+ const controlledGroup = useHandleGroup((s) =>
306
+ s.active?.nodeId === node.id ? s.active.group : null,
307
+ )
295
308
  const parentId = node.parentId ?? null
296
- const grandparentId = useScene((state) => {
297
- if (!parentId) return null
298
- const parent = state.nodes[parentId as AnyNodeId]
299
- return parent?.parentId ?? null
300
- })
301
309
 
302
310
  const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
303
311
  ? 'grandparent'
304
312
  : 'parent'
305
313
 
314
+ const portalTargetResolver = descriptors.find(
315
+ (descriptor) => descriptor.portalTarget !== undefined,
316
+ )?.portalTarget
317
+ const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
318
+
306
319
  // Portal target: the mesh we createPortal into.
307
- const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
320
+ const portalTargetId = useScene((state) => {
321
+ if (portalTargetResolver) {
322
+ return portalTargetResolver(node as never, descriptorSceneApi) ?? null
323
+ }
324
+ const parentId = node.parentId ?? null
325
+ if (!parentId || portalMode === 'parent') return parentId
326
+ return state.nodes[parentId as AnyNodeId]?.parentId ?? null
327
+ })
308
328
  // Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
309
329
  // outer IS the node (so handles + drag math both live in node-local).
310
330
  // For 'grandparent' the outer rides the parent and an inner group adds
@@ -470,7 +490,8 @@ function NodeArrowHandlesForNode({
470
490
  }
471
491
  // Arrows tagged with a latch group stay hidden until that group is open.
472
492
  const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
473
- if (latchGroup && !openLatchGroups.has(latchGroup)) return null
493
+ if (latchGroup && latchGroup !== controlledGroup && !openLatchGroups.has(latchGroup))
494
+ return null
474
495
  return (
475
496
  <ArrowHandle
476
497
  activeIndex={activeIndex}
@@ -496,39 +517,6 @@ function NodeArrowHandlesForNode({
496
517
  )
497
518
  }
498
519
 
499
- // Offset, in node-local frame, that compensates for `position` drift on
500
- // the mesh during an asymmetric resize. Width/length L+R recompute
501
- // `position` so the anchored edge stays world-fixed — the renderer
502
- // follows that override, the ride object moves, and every arrow under
503
- // it would drift along with the mesh center. Subtracting this offset
504
- // from a non-active arrow's local placement undoes that drift so it
505
- // stays at its pre-drag world position.
506
- //
507
- // Rotation drags don't change `position`, so the offset collapses to
508
- // zero and non-active arrows naturally rotate with the mesh — which is
509
- // the desired behaviour (the whole rig rotates as a unit).
510
- function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
511
- // Not every node in the union carries a `position` field (sites are the
512
- // notable holdout — they don't have handles anyway, but TypeScript still
513
- // requires us to discriminate). Guarded access keeps the freeze logic
514
- // safe for the few node kinds that lack the field.
515
- const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
516
- const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
517
- 0, 0, 0,
518
- ]
519
- const deltaWorldX = liveP[0] - preP[0]
520
- const deltaWorldY = liveP[1] - preP[1]
521
- const deltaWorldZ = liveP[2] - preP[2]
522
- const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
523
- // World → node-local for Y-axis rotation by rotY (THREE.Object3D
524
- // rotation-y convention): inverse is rotation by -rotY around +Y.
525
- const cosR = Math.cos(rotY)
526
- const sinR = Math.sin(rotY)
527
- const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
528
- const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
529
- return [deltaLocalX, deltaWorldY, deltaLocalZ]
530
- }
531
-
532
520
  function ArrowHandle({
533
521
  descriptor,
534
522
  liveNode,
@@ -660,7 +648,7 @@ function LinearArrow({
660
648
  // for the edge being resized); cleared when the drag ends.
661
649
  const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
662
650
  const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
663
- const basePosition = descriptor.placement.position(node, placementSceneApi)
651
+ const basePosition = resolveLinearHandlePosition(descriptor, node, placementSceneApi, baseScale)
664
652
  // `freezeOffset` (in node-local frame) cancels the mesh's `position`
665
653
  // drift while another arrow is being dragged — `basePosition` is
666
654
  // computed against the pre-drag snapshot, then we subtract the offset
@@ -710,34 +698,29 @@ function LinearArrow({
710
698
  getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
711
699
  ) / localToWorldScale
712
700
 
713
- const overrideId =
714
- (descriptor.kind === 'linear-resize'
715
- ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
716
- : undefined) ?? nodeId
701
+ const linearBinding =
702
+ descriptor.kind === 'linear-resize'
703
+ ? createLinearResizeDragBinding({
704
+ descriptor,
705
+ initialNode,
706
+ nodeId,
707
+ sceneApi,
708
+ initialModifiers: { altKey: event.nativeEvent.altKey },
709
+ })
710
+ : null
711
+ const overrideId = linearBinding?.overrideId ?? nodeId
717
712
  const initialValue = descriptor.currentValue(initialNode)
718
713
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
719
714
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
720
- const factor =
721
- descriptor.kind === 'radial-resize'
722
- ? 1
723
- : descriptor.anchor === 'center'
724
- ? 2
725
- : descriptor.anchor === 'min'
726
- ? 1
727
- : -1
715
+ const factor = linearResizeFactor(descriptor)
728
716
 
729
717
  // Last value an emitted resize tick fired at — a new tick fires only
730
718
  // when the (snapped + clamped) value actually changes, so the cue
731
719
  // tracks real size steps instead of every sub-pixel pointer jitter.
732
720
  let lastTickValue = initialValue
733
- let previewOverrideIds = new Set<AnyNodeId>()
734
-
735
721
  return {
736
722
  overrideId,
737
- commit:
738
- descriptor.kind === 'linear-resize' && descriptor.commit
739
- ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
740
- : undefined,
723
+ commit: linearBinding?.commit,
741
724
  onBegin: () => {
742
725
  // Always claim the handle-drag scope so the HUD knows a resize is the
743
726
  // active interaction (keeps the idle select hints off-screen). The
@@ -755,12 +738,9 @@ function LinearArrow({
755
738
  descriptor.onDragEnd?.(initialNode as never, sceneApi)
756
739
  }
757
740
  if (onDrag) useOpeningGuides.getState().clear()
758
- for (const previewId of previewOverrideIds) {
759
- useLiveNodeOverrides.getState().clear(previewId)
760
- useScene.getState().markDirty(previewId)
761
- }
741
+ linearBinding?.clearPreview()
762
742
  },
763
- move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
743
+ move: ({ event: moveEvent, modifiers, getPointerRay: getMovePointerRay }) => {
764
744
  const currentPointer =
765
745
  closestAxisParameterToRay(
766
746
  _resizeOriginW,
@@ -772,43 +752,27 @@ function LinearArrow({
772
752
  const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null
773
753
  const snappedNext = resolveResizeSnapValue({
774
754
  rawValue: rawNext,
755
+ fallbackValue: lastTickValue,
775
756
  gridSnapEnabled: linearDescriptor?.gridSnap === true,
776
- gridSnapActive: isGridSnapActive(),
757
+ gridSnapActive: isGridSnapActive() && !modifiers.altKey,
777
758
  gridSnapStep: useEditor.getState().gridSnapStep,
778
- magneticSnapActive: isMagneticSnapActive(),
759
+ magneticSnapActive: isMagneticSnapActive() && !modifiers.altKey,
779
760
  magneticSnap: linearDescriptor?.magneticSnap
780
761
  ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value
781
762
  : undefined,
763
+ connectionSnapActive: !modifiers.altKey,
764
+ connectionSnap: linearDescriptor?.connectionSnap
765
+ ? (value) => linearDescriptor.connectionSnap?.(initialNode, value, sceneApi) ?? value
766
+ : undefined,
782
767
  })
783
768
  const next = Math.min(maxBound, Math.max(minBound, snappedNext))
784
769
  if (next !== lastTickValue) {
785
770
  lastTickValue = next
786
771
  sfxEmitter.emit('sfx:resize')
787
772
  }
788
- const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
789
- if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) {
790
- const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi)
791
- const nextPreviewOverrideIds = replacePreviewOverrideIds(
792
- previewOverrideIds,
793
- previewEntries,
794
- (previewId) => {
795
- useLiveNodeOverrides.getState().clear(previewId)
796
- useScene.getState().markDirty(previewId)
797
- },
798
- )
799
- useLiveNodeOverrides
800
- .getState()
801
- .setMany(
802
- previewEntries.map(([id, previewPatch]) => [
803
- id,
804
- previewPatch as Record<string, unknown>,
805
- ]),
806
- )
807
- for (const [previewId] of previewEntries) {
808
- useScene.getState().markDirty(previewId)
809
- }
810
- previewOverrideIds = nextPreviewOverrideIds
811
- }
773
+ const patch = linearBinding
774
+ ? linearBinding.apply(next, modifiers)
775
+ : (descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>)
812
776
  // Let the kind publish live guides for the edge being resized.
813
777
  onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
814
778
  return patch
@@ -817,23 +781,7 @@ function LinearArrow({
817
781
  },
818
782
  })
819
783
 
820
- // For axis === 'y' (vertical handles), tilt the chevron up via local
821
- // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
822
- // sits below the node (placement Y < 0, e.g. window bottom arrow),
823
- // flip the Z rotation so the chevron points outward (downward).
824
- //
825
- // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
826
- // roll the blade 90° about its own pointing (X) axis so it stands up from
827
- // the horizontal XZ plane into the node's facing plane (XY = the wall
828
- // face) — otherwise the blade is seen edge-on from the front.
829
- const faceNormalX =
830
- descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
831
- const innerRotation: [number, number, number] =
832
- descriptor.axis === 'y'
833
- ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
834
- : faceNormalX
835
- ? [Math.PI / 2, 0, 0]
836
- : [0, 0, 0]
784
+ const innerRotation = resolveLinearHandleRotation(descriptor, position)
837
785
 
838
786
  // Optional guide decoration — linear handles use it for curved-stair
839
787
  // width / inner-radius rings; radial handles use it for the column's