@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 =>
25
25
  Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
26
26
  const isVec2 = (v: unknown): v is Vec2 =>
27
27
  Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
28
+ const isVec2Array = (v: unknown): v is Vec2[] => Array.isArray(v) && v.length > 0 && v.every(isVec2)
28
29
 
29
30
  // How a participant's placement transforms rigidly around / with the group:
30
31
  // - 'vec3' position + [x,y,z] rotation (items, …)
31
32
  // - 'scalar' position + numeric rotation (columns)
32
33
  // - 'endpoint' start/end tuples (walls, fences)
33
- export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
34
+ // - 'polygon' [x,z] vertex arrays (slabs, ceilings, zones) the placement
35
+ // lives in the vertices themselves (plus optional hole rings)
36
+ export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' | 'polygon'
34
37
 
35
38
  // A selected node qualifies when it belongs to the active level's horizontal
36
39
  // frame: either parented to that level, or declared building-scoped and parented
@@ -75,27 +78,39 @@ function getParticipantScalarRotation(node: AnyNode): number | null {
75
78
  return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
76
79
  }
77
80
 
81
+ // Shape-only classification of a positioned placement (no level-scope check).
82
+ // Used for polygon hosts' attached children, whose parent is the host rather
83
+ // than the level.
84
+ function classifyPlacementShape(node: AnyNode): 'vec3' | 'scalar' | null {
85
+ const p = getParticipantPosition(node)
86
+ const r = (node as { rotation?: unknown }).rotation
87
+ if (isVec3(p) && isVec3(r)) return 'vec3'
88
+ if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
89
+ return null
90
+ }
91
+
78
92
  export function classifyParticipant(
79
93
  node: AnyNode | undefined,
80
94
  levelId: string | null,
81
95
  sceneNodes: Record<string, AnyNode | undefined>,
82
96
  ): ParticipantKind | null {
83
97
  if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
84
- const p = getParticipantPosition(node)
85
- const r = (node as { rotation?: unknown }).rotation
98
+ const shape = classifyPlacementShape(node)
99
+ if (shape) return shape
86
100
  const start = (node as { start?: unknown }).start
87
101
  const end = (node as { end?: unknown }).end
88
- if (isVec3(p) && isVec3(r)) return 'vec3'
89
- if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
90
102
  if (isVec2(start) && isVec2(end)) return 'endpoint'
103
+ if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon'
91
104
  return null
92
105
  }
93
106
 
94
- // Pre-drag placement snapshot + how to transform it.
107
+ // Pre-drag placement snapshot + how to transform it. `holes` is null when the
108
+ // kind carries no holes field (zone), so patches never write one onto it.
95
109
  export type ParticipantStart =
96
110
  | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
97
111
  | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
98
112
  | { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
113
+ | { id: AnyNodeId; kind: 'polygon'; polygon: Vec2[]; holes: Vec2[][] | null }
99
114
 
100
115
  // An unselected wall/fence sharing a junction with a transforming endpoint. Only
101
116
  // the touching endpoint(s) follow, so the neighbour stays attached while its far
@@ -143,7 +158,7 @@ export function collectParticipants(
143
158
  position: [position[0], position[1], position[2]],
144
159
  rotation,
145
160
  })
146
- } else {
161
+ } else if (kind === 'endpoint') {
147
162
  const n = node as AnyNode & { start: Vec2; end: Vec2 }
148
163
  starts.push({
149
164
  id: id as AnyNodeId,
@@ -151,6 +166,56 @@ export function collectParticipants(
151
166
  start: [n.start[0], n.start[1]],
152
167
  end: [n.end[0], n.end[1]],
153
168
  })
169
+ } else {
170
+ const n = node as AnyNode & { polygon: Vec2[]; holes?: Vec2[][] }
171
+ starts.push({
172
+ id: id as AnyNodeId,
173
+ kind,
174
+ polygon: n.polygon.map(([x, z]) => [x, z] as Vec2),
175
+ holes: Array.isArray(n.holes)
176
+ ? n.holes.map((hole) => hole.map(([x, z]) => [x, z] as Vec2))
177
+ : null,
178
+ })
179
+ }
180
+ }
181
+
182
+ // Polygon hosts (slab/ceiling) rebuild their geometry from vertices rather
183
+ // than transforming a group, so — unlike wall children, which ride the wall
184
+ // mesh — their attached children (ceiling-mounted items) must transform
185
+ // explicitly. Their positions are stored in the level frame (the host group
186
+ // sits at the origin), so the same rigid patches apply.
187
+ const includedIds = new Set<string>(starts.map((s) => s.id))
188
+ for (const s of [...starts]) {
189
+ if (s.kind !== 'polygon') continue
190
+ const host = sceneNodes[s.id]
191
+ const childIds = (host as { children?: string[] } | undefined)?.children
192
+ if (!Array.isArray(childIds)) continue
193
+ for (const childId of childIds) {
194
+ if (includedIds.has(childId)) continue
195
+ const child = sceneNodes[childId]
196
+ if (!child) continue
197
+ const shape = classifyPlacementShape(child)
198
+ const position = child ? getParticipantPosition(child) : null
199
+ if (!(shape && position)) continue
200
+ if (shape === 'vec3') {
201
+ const c = child as AnyNode & { rotation: Vec3 }
202
+ starts.push({
203
+ id: childId as AnyNodeId,
204
+ kind: 'vec3',
205
+ position: [position[0], position[1], position[2]],
206
+ rotation: [c.rotation[0], c.rotation[1], c.rotation[2]],
207
+ })
208
+ } else {
209
+ const rotation = getParticipantScalarRotation(child)
210
+ if (rotation === null) continue
211
+ starts.push({
212
+ id: childId as AnyNodeId,
213
+ kind: 'scalar',
214
+ position: [position[0], position[1], position[2]],
215
+ rotation,
216
+ })
217
+ }
218
+ includedIds.add(childId)
154
219
  }
155
220
  }
156
221
 
@@ -219,6 +284,174 @@ export function expandToComponent(
219
284
  return Array.from(included)
220
285
  }
221
286
 
287
+ // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
288
+ // preview and for the single batched `updateNodes` on commit.
289
+ export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
290
+
291
+ // Rigid group rotation: orbit each participant's anchor point(s) CCW by
292
+ // `delta` (atan2 x→z sense) around `center` (level-frame XZ) and turn yaws by
293
+ // `-delta` to match three.js Y-rotation handedness (same convention as the
294
+ // single-item rotate handle in item/definition.ts). Endpoint nodes
295
+ // (walls/fences) have no yaw — swinging both endpoints around the pivot
296
+ // rotates them rigidly; their curveOffset sagitta is rotation-invariant, so
297
+ // arcs are preserved. Linked neighbours' shared endpoints land exactly on the
298
+ // selected wall's rotated endpoint (rot is deterministic), keeping junctions
299
+ // welded while the far end stays put.
300
+ export function rotateGroupPatches(
301
+ starts: ParticipantStart[],
302
+ links: LinkedNeighbor[],
303
+ center: { x: number; z: number },
304
+ delta: number,
305
+ ): GroupPatch[] {
306
+ const cos = Math.cos(delta)
307
+ const sin = Math.sin(delta)
308
+ const rot = (x: number, z: number): Vec2 => {
309
+ const dx = x - center.x
310
+ const dz = z - center.z
311
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
312
+ }
313
+ const patches: GroupPatch[] = []
314
+ for (const s of starts) {
315
+ if (s.kind === 'endpoint') {
316
+ patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }])
317
+ } else if (s.kind === 'polygon') {
318
+ const patch: Record<string, unknown> = { polygon: s.polygon.map(([x, z]) => rot(x, z)) }
319
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(([x, z]) => rot(x, z)))
320
+ patches.push([s.id, patch])
321
+ } else {
322
+ const [px, pz] = rot(s.position[0], s.position[2])
323
+ const position: Vec3 = [px, s.position[1], pz]
324
+ const rotation =
325
+ s.kind === 'vec3'
326
+ ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
327
+ : s.rotation - delta
328
+ patches.push([s.id, { position, rotation }])
329
+ }
330
+ }
331
+ for (const l of links) {
332
+ patches.push([
333
+ l.id,
334
+ {
335
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
336
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
337
+ },
338
+ ])
339
+ }
340
+ return patches
341
+ }
342
+
343
+ // Rotate the SNAPSHOTS themselves (same math as `rotateGroupPatches`, but
344
+ // producing new snapshot shapes instead of node patches). Lets an in-flight
345
+ // group move re-seed its rest state after a mid-drag R/T: subsequent
346
+ // translate patches then place the rotated layout at the live delta.
347
+ export function rotateGroupSnapshots(
348
+ starts: ParticipantStart[],
349
+ links: LinkedNeighbor[],
350
+ center: { x: number; z: number },
351
+ delta: number,
352
+ ): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
353
+ const cos = Math.cos(delta)
354
+ const sin = Math.sin(delta)
355
+ const rot = (x: number, z: number): Vec2 => {
356
+ const dx = x - center.x
357
+ const dz = z - center.z
358
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
359
+ }
360
+ const rotatedStarts = starts.map((s): ParticipantStart => {
361
+ if (s.kind === 'endpoint') {
362
+ return { ...s, start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }
363
+ }
364
+ if (s.kind === 'polygon') {
365
+ return {
366
+ ...s,
367
+ polygon: s.polygon.map(([x, z]) => rot(x, z)),
368
+ holes: s.holes ? s.holes.map((hole) => hole.map(([x, z]) => rot(x, z))) : null,
369
+ }
370
+ }
371
+ const [px, pz] = rot(s.position[0], s.position[2])
372
+ const position: Vec3 = [px, s.position[1], pz]
373
+ if (s.kind === 'vec3') {
374
+ return { ...s, position, rotation: [s.rotation[0], s.rotation[1] - delta, s.rotation[2]] }
375
+ }
376
+ return { ...s, position, rotation: s.rotation - delta }
377
+ })
378
+ // Only the welded (linked) endpoints follow the rotation; the far ends
379
+ // stay put, exactly like the per-tick patch path.
380
+ const rotatedLinks = links.map(
381
+ (l): LinkedNeighbor => ({
382
+ ...l,
383
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
384
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
385
+ }),
386
+ )
387
+ return { starts: rotatedStarts, links: rotatedLinks }
388
+ }
389
+
390
+ // 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 {
396
+ let minX = Number.POSITIVE_INFINITY
397
+ let minZ = Number.POSITIVE_INFINITY
398
+ let maxX = Number.NEGATIVE_INFINITY
399
+ let maxZ = Number.NEGATIVE_INFINITY
400
+ const reach = (x: number, z: number) => {
401
+ minX = Math.min(minX, x)
402
+ minZ = Math.min(minZ, z)
403
+ maxX = Math.max(maxX, x)
404
+ maxZ = Math.max(maxZ, z)
405
+ }
406
+ for (const s of starts) {
407
+ if (s.kind === 'endpoint') {
408
+ reach(s.start[0], s.start[1])
409
+ reach(s.end[0], s.end[1])
410
+ } else if (s.kind === 'polygon') {
411
+ for (const [x, z] of s.polygon) {
412
+ reach(x, z)
413
+ }
414
+ } else {
415
+ reach(s.position[0], s.position[2])
416
+ }
417
+ }
418
+ if (!Number.isFinite(minX)) return null
419
+ return { minX, minZ, maxX, maxZ }
420
+ }
421
+
422
+ // 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.
424
+ export function translateGroupPatches(
425
+ starts: ParticipantStart[],
426
+ links: LinkedNeighbor[],
427
+ dx: number,
428
+ dz: number,
429
+ ): GroupPatch[] {
430
+ const patches: GroupPatch[] = []
431
+ const shift = ([x, z]: Vec2): Vec2 => [x + dx, z + dz]
432
+ for (const s of starts) {
433
+ if (s.kind === 'endpoint') {
434
+ patches.push([s.id, { start: shift(s.start), end: shift(s.end) }])
435
+ } else if (s.kind === 'polygon') {
436
+ const patch: Record<string, unknown> = { polygon: s.polygon.map(shift) }
437
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
438
+ patches.push([s.id, patch])
439
+ } else {
440
+ patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
441
+ }
442
+ }
443
+ for (const l of links) {
444
+ patches.push([
445
+ l.id,
446
+ {
447
+ start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
448
+ end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
449
+ },
450
+ ])
451
+ }
452
+ return patches
453
+ }
454
+
222
455
  // Frozen world matrix of the level group + its inverse. A node's placement
223
456
  // (`position` / `start` / `end`) is stored in its parent level's frame, but the
224
457
  // gizmos raycast the ground plane in WORLD space. When the building is rotated
@@ -12,12 +12,27 @@ import {
12
12
  DoubleSide,
13
13
  ExtrudeGeometry,
14
14
  type Group,
15
+ type Intersection,
16
+ Mesh,
17
+ type Raycaster,
15
18
  Shape,
16
19
  TorusGeometry,
17
20
  } from 'three'
18
21
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
19
22
  import { MeshBasicNodeMaterial } from 'three/webgpu'
20
23
  import { EDITOR_LAYER } from '../../../lib/constants'
24
+ import useEditor from '../../../store/use-editor'
25
+
26
+ // While a press-drag move is in flight (`placementDragMode`), the move tool
27
+ // owns the pointer and the handle rig rides the moving node — so a handle hit
28
+ // area would sit under the cursor and starve the tool's surface raycast
29
+ // (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
30
+ // Make every handle hit area inert for the duration; the indicator mesh still
31
+ // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
32
+ function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
33
+ if (useEditor.getState().placementDragMode) return
34
+ Mesh.prototype.raycast.call(this, raycaster, intersects)
35
+ }
21
36
 
22
37
  export const ARROW_SCALE = 0.65
23
38
  export const ARROW_COLOR = '#8381ed'
@@ -36,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
36
51
  const CHEVRON_BEVEL_THICKNESS = 0.035
37
52
  const CHEVRON_BEVEL_SIZE = 0.03
38
53
  const CHEVRON_BEVEL_SEGMENTS = 10
54
+ // Slimmer extrude profile matching the legacy wall side handles
55
+ // (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier
56
+ // default is preserved for every other handle that uses the shared chevron.
57
+ const CHEVRON_THIN_DEPTH = 0.045
58
+ const CHEVRON_THIN_BEVEL_THICKNESS = 0.018
59
+ const CHEVRON_THIN_BEVEL_SIZE = 0.02
60
+ const CHEVRON_THIN_BEVEL_SEGMENTS = 8
39
61
  const MOVE_CROSS_HALF_LENGTH = 0.36
40
62
  const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
41
63
  const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
@@ -49,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
49
71
  const ROTATE_RIBBON_HALF_WIDTH = 0.02
50
72
  const ROTATE_HEAD_HALF_WIDTH = 0.045
51
73
  const TRACKER_CUBE_SIZE = 0.16
52
- export const CORNER_HEX_RADIUS = 0.16
74
+ export const CORNER_HEX_RADIUS = 0.11
53
75
 
54
76
  export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
55
77
  export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
@@ -75,6 +97,10 @@ export type HandleArrowProps = {
75
97
  indicatorRotation?: readonly [number, number, number]
76
98
  onPointerEnter?: PointerHandler
77
99
  onPointerLeave?: PointerHandler
100
+ // Extrude the slimmer wall-handle chevron profile (chevron shape only).
101
+ thin?: boolean
102
+ // Render the corner-picker disc as a smooth circle instead of a hexagon.
103
+ round?: boolean
78
104
  }
79
105
 
80
106
  function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
@@ -164,8 +190,9 @@ export function createRotateArrowHandleGeometry() {
164
190
 
165
191
  // Reused chevron+shaft silhouette. The chevron points along +X by default;
166
192
  // callers rotate it around Y for Z-axis handles and into a vertical frame for
167
- // Y-axis handles.
168
- export function createArrowHandleGeometry() {
193
+ // Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
194
+ export function createArrowHandleGeometry(thin = false) {
195
+ const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
169
196
  const shape = new Shape()
170
197
  shape.moveTo(CHEVRON_MAX_X, 0)
171
198
  shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
@@ -176,16 +203,16 @@ export function createArrowHandleGeometry() {
176
203
  shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
177
204
  shape.lineTo(CHEVRON_MAX_X, 0)
178
205
  const geometry = new ExtrudeGeometry(shape, {
179
- depth: CHEVRON_DEPTH,
206
+ depth,
180
207
  bevelEnabled: true,
181
- bevelThickness: CHEVRON_BEVEL_THICKNESS,
182
- bevelSize: CHEVRON_BEVEL_SIZE,
208
+ bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
209
+ bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
183
210
  bevelOffset: 0,
184
- bevelSegments: CHEVRON_BEVEL_SEGMENTS,
211
+ bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
185
212
  curveSegments: 16,
186
213
  steps: 1,
187
214
  })
188
- geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
215
+ geometry.translate(0, 0, -depth / 2)
189
216
  geometry.rotateX(-Math.PI / 2)
190
217
  geometry.computeVertexNormals()
191
218
  geometry.computeBoundingSphere()
@@ -254,15 +281,27 @@ export function createArrowHitAreaGeometry() {
254
281
  return geometry
255
282
  }
256
283
 
284
+ // The move cross is a plus, not a disk. A disk-shaped hit area fills the four
285
+ // corner gaps between the arms, so a neighbouring node sitting next to the
286
+ // selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
287
+ // invisible grip and can't be picked. Wrap the visible arms instead: two flat
288
+ // arm boxes (length/width + margin) merged into a plus, leaving the corners
289
+ // empty so co-located neighbours stay selectable while the grip stays grabbable.
257
290
  function createMoveCrossHitAreaGeometry() {
258
- const geometry = new CylinderGeometry(
259
- MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
260
- MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
261
- HIT_AREA_THICKNESS,
262
- 32,
263
- )
264
- geometry.computeBoundingSphere()
265
- return geometry
291
+ const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
292
+ const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
293
+ const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
294
+ const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
295
+ const merged = mergeGeometries([armX, armZ], false)
296
+ if (!merged) {
297
+ armZ.dispose()
298
+ armX.computeBoundingSphere()
299
+ return armX
300
+ }
301
+ armX.dispose()
302
+ armZ.dispose()
303
+ merged.computeBoundingSphere()
304
+ return merged
266
305
  }
267
306
 
268
307
  export function createRotateArrowHitAreaGeometry() {
@@ -299,8 +338,12 @@ export function createEndpointHitAreaGeometry(radius: number) {
299
338
  return geometry
300
339
  }
301
340
 
302
- function createHandleArrowGeometry(shape: HandleArrowShape) {
303
- if (shape === 'chevron') return createArrowHandleGeometry()
341
+ // Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`.
342
+ const CORNER_DISC_SEGMENTS = 6
343
+ const CORNER_DISC_ROUND_SEGMENTS = 32
344
+
345
+ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
346
+ if (shape === 'chevron') return createArrowHandleGeometry(thin)
304
347
  if (shape === 'cross') return createMoveCrossHandleGeometry()
305
348
  if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
306
349
  if (shape === 'tracker') {
@@ -308,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape) {
308
351
  geometry.computeBoundingSphere()
309
352
  return geometry
310
353
  }
311
- const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
354
+ const geometry = new CircleGeometry(
355
+ CORNER_HEX_RADIUS,
356
+ round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
357
+ )
312
358
  geometry.computeBoundingSphere()
313
359
  return geometry
314
360
  }
315
361
 
316
- function createHandleArrowHitGeometry(shape: HandleArrowShape) {
362
+ function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
317
363
  if (shape === 'chevron') return createArrowHitAreaGeometry()
318
364
  if (shape === 'cross') return createMoveCrossHitAreaGeometry()
319
365
  if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
320
366
  if (shape === 'tracker') return createTrackerHitAreaGeometry()
321
- const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
367
+ const geometry = new CircleGeometry(
368
+ CORNER_HEX_RADIUS,
369
+ round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
370
+ )
322
371
  geometry.computeBoundingSphere()
323
372
  return geometry
324
373
  }
@@ -382,6 +431,7 @@ export function InvisibleHandleHitArea({
382
431
  onPointerDown={onPointerDown}
383
432
  onPointerEnter={onPointerEnter}
384
433
  onPointerLeave={onPointerLeave}
434
+ raycast={hitAreaRaycast}
385
435
  renderOrder={HIT_AREA_RENDER_ORDER}
386
436
  scale={scale}
387
437
  />
@@ -443,10 +493,18 @@ export function HandleArrow({
443
493
  onPointerDown,
444
494
  onPointerEnter,
445
495
  onPointerLeave,
496
+ thin = false,
497
+ round = false,
446
498
  }: HandleArrowProps) {
447
499
  const visualShape = normalizeHandleArrowShape(shape, cursor)
448
- const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
449
- const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
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
+ )
450
508
  const indicatorMaterial = useHandleArrowMaterial(visualShape)
451
509
  const hitMaterial = useInvisibleHitAreaMaterial()
452
510
  const rootRef = useRef<Group>(null)
@@ -11,7 +11,8 @@ import {
11
11
  import { useViewer } from '@pascal-app/viewer'
12
12
  import { type ThreeEvent, useThree } from '@react-three/fiber'
13
13
  import { useEffect, useRef } from 'react'
14
- import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three'
14
+ import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
15
+ import { isHistoryShortcut } from '../../../lib/history'
15
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
17
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
17
18
 
@@ -27,9 +28,12 @@ type IntersectPlane = (
27
28
  target: Vector3,
28
29
  ) => Vector3 | null
29
30
 
31
+ type GetPointerRay = (clientX: number, clientY: number, target: Ray) => Ray
32
+
30
33
  export type HandleDragStartContext = {
31
34
  event: ThreeEvent<PointerEvent>
32
35
  camera: Camera
36
+ getPointerRay: GetPointerRay
33
37
  intersectPlane: IntersectPlane
34
38
  initialNode: AnyNode
35
39
  node: AnyNode
@@ -40,11 +44,13 @@ export type HandleDragStartContext = {
40
44
 
41
45
  export type HandleDragMoveContext = {
42
46
  event: PointerEvent
47
+ getPointerRay: GetPointerRay
43
48
  intersectPlane: IntersectPlane
44
49
  }
45
50
 
46
51
  type HandleDragSession = {
47
52
  move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
53
+ commit?: (patch: Partial<AnyNode>) => void
48
54
  markDirty?: boolean
49
55
  onBegin?: () => void
50
56
  onEnd?: () => void
@@ -105,6 +111,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
105
111
  useEffect(() => () => dragCleanupRef.current?.(), [])
106
112
 
107
113
  return (event: ThreeEvent<PointerEvent>) => {
114
+ // Only the primary button starts a handle gesture — right/middle-drag
115
+ // belongs to the camera, so let it propagate untouched.
116
+ if (event.button !== 0) return
108
117
  event.stopPropagation()
109
118
  suppressBoxSelectForPointer(event)
110
119
 
@@ -121,13 +130,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
121
130
  rideObject.updateMatrixWorld()
122
131
 
123
132
  const ndc = new Vector2()
124
- const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
133
+ const setPointerRay = (clientX: number, clientY: number) => {
125
134
  const rect = gl.domElement.getBoundingClientRect()
126
135
  ndc.set(
127
136
  ((clientX - rect.left) / rect.width) * 2 - 1,
128
137
  -((clientY - rect.top) / rect.height) * 2 + 1,
129
138
  )
130
139
  raycaster.setFromCamera(ndc, camera)
140
+ }
141
+ const getPointerRay: GetPointerRay = (clientX, clientY, target) => {
142
+ setPointerRay(clientX, clientY)
143
+ return target.copy(raycaster.ray)
144
+ }
145
+ const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
146
+ setPointerRay(clientX, clientY)
131
147
  return raycaster.ray.intersectPlane(plane, target)
132
148
  }
133
149
 
@@ -137,6 +153,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
137
153
  const session = args.onStart({
138
154
  event,
139
155
  camera,
156
+ getPointerRay,
140
157
  intersectPlane,
141
158
  initialNode,
142
159
  node,
@@ -159,7 +176,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
159
176
  let lastPatch: Partial<AnyNode> | null = null
160
177
 
161
178
  const onMove = (moveEvent: PointerEvent) => {
162
- const patch = session.move({ event: moveEvent, intersectPlane })
179
+ const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
163
180
  if (!patch) return
164
181
  lastPatch = patch
165
182
  useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
@@ -172,6 +189,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
172
189
  window.removeEventListener('pointermove', onMove)
173
190
  window.removeEventListener('pointerup', onUp)
174
191
  window.removeEventListener('pointercancel', onCancel)
192
+ window.removeEventListener('keydown', onKeyDown, true)
175
193
  if (document.body.style.cursor === cursor) {
176
194
  document.body.style.cursor = ''
177
195
  }
@@ -194,7 +212,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
194
212
  swallowNextClick()
195
213
  sfxEmitter.emit('sfx:item-place')
196
214
  if (lastPatch) {
197
- sceneApi.update(overrideId, lastPatch)
215
+ if (session.commit) {
216
+ session.commit(lastPatch)
217
+ } else {
218
+ sceneApi.update(overrideId, lastPatch)
219
+ }
198
220
  }
199
221
  clearOverride()
200
222
  cleanup()
@@ -205,9 +227,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
205
227
  cleanup()
206
228
  }
207
229
 
230
+ // Escape / ⌘Z abort the drag — capture phase so they win over the global
231
+ // use-keyboard arms (⌘Z must never history-jump under a live pointer).
232
+ const onKeyDown = (e: KeyboardEvent) => {
233
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
234
+ e.preventDefault()
235
+ e.stopPropagation()
236
+ swallowNextClick()
237
+ onCancel()
238
+ }
239
+
208
240
  dragCleanupRef.current = cleanup
209
241
  window.addEventListener('pointermove', onMove)
210
242
  window.addEventListener('pointerup', onUp)
211
243
  window.addEventListener('pointercancel', onCancel)
244
+ window.addEventListener('keydown', onKeyDown, true)
212
245
  }
213
246
  }