@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,382 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ useLiveNodeOverrides,
7
+ useLiveTransforms,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
+ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
14
+ import { sfxEmitter } from '../../lib/sfx-bus'
15
+ import useEditor from '../../store/use-editor'
16
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
17
+ import {
18
+ CORNER_OFFSET,
19
+ classifyParticipant,
20
+ collectParticipants,
21
+ computeGroupBox,
22
+ expandToComponent,
23
+ levelFrame,
24
+ type Vec2,
25
+ type Vec3,
26
+ } from './group-transform-shared'
27
+ import {
28
+ ARROW_COLOR,
29
+ ARROW_HOVER_COLOR,
30
+ ARROW_SCALE,
31
+ createRotateArrowHandleGeometry,
32
+ createRotateArrowHitAreaGeometry,
33
+ GuideRing,
34
+ InvisibleHandleHitArea,
35
+ NO_RAYCAST,
36
+ RotationGuide,
37
+ type RotationGuideData,
38
+ swallowNextClick,
39
+ useArrowMaterial,
40
+ useInvisibleHitAreaMaterial,
41
+ } from './node-arrow-handles'
42
+
43
+ const ROTATE_SNAP = Math.PI / 12 // 15°
44
+
45
+ /**
46
+ * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
47
+ * selected, a single rotation handle appears at the selection's bounding-box
48
+ * center. Dragging it spins every selected node rigidly around that shared
49
+ * center — orbiting each node's position AND turning its yaw by the same delta,
50
+ * so the group rotates as one piece.
51
+ *
52
+ * The single-selection case is handled by `NodeArrowHandles`; a full-level
53
+ * box-select promotes to a building selection, so neither reaches this gizmo.
54
+ */
55
+ export function GroupRotateHandle() {
56
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
57
+ const levelId = useViewer((s) => s.selection.levelId)
58
+ const mode = useEditor((s) => s.mode)
59
+ const movingNode = useEditor((s) => s.movingNode)
60
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
61
+ // Re-derive participants whenever the scene mutates (e.g. after a commit).
62
+ // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
63
+ const nodes = useScene((s) => s.nodes)
64
+
65
+ const participantIds = useMemo(
66
+ () =>
67
+ selectedIds.filter(
68
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
69
+ ),
70
+ [selectedIds, levelId, nodes],
71
+ )
72
+
73
+ // Gate on the explicit selection (so a single connected wall still gets the
74
+ // per-node handles), but transform the full connected wall/fence component so
75
+ // attached structure rotates rigidly as one piece.
76
+ const fullIds = useMemo(
77
+ () => expandToComponent(participantIds, nodes, levelId),
78
+ [participantIds, levelId, nodes],
79
+ )
80
+
81
+ const shouldRender =
82
+ participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
83
+
84
+ if (!shouldRender) return null
85
+ // Remount when the moving set changes so the rest pivot re-seeds cleanly.
86
+ return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} />
87
+ }
88
+
89
+ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
90
+ const { camera, raycaster, gl, scene } = useThree()
91
+ const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
92
+ const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
93
+ const arrowMaterial = useArrowMaterial()
94
+ const hitMaterial = useInvisibleHitAreaMaterial()
95
+ const [isHovered, setIsHovered] = useState(false)
96
+ const [isDragging, setIsDragging] = useState(false)
97
+ const [guide, setGuide] = useState<RotationGuideData | null>(null)
98
+ const dragCleanupRef = useRef<(() => void) | null>(null)
99
+ const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null)
100
+
101
+ useEffect(() => {
102
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
103
+ }, [arrowMaterial, isHovered])
104
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
105
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
106
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
107
+ useEffect(() => () => dragCleanupRef.current?.(), [])
108
+
109
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
110
+ const baseScale = zoom * ARROW_SCALE * 1.05
111
+ const scale = (isHovered ? 1.12 : 1) * baseScale
112
+
113
+ // World-space bounding box of the selected meshes. Levels are axis-aligned in
114
+ // XZ, so world XZ coincides with each node's level-local placement — letting
115
+ // us rotate `position` / `start` / `end` directly against the pivot without
116
+ // per-node frame conversion.
117
+ // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
118
+ // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
119
+ const rest = useMemo(() => {
120
+ const box = computeGroupBox(ids)
121
+ if (!box) return null
122
+ const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
123
+ const corner = new Vector3(
124
+ box.max.x + CORNER_OFFSET,
125
+ (box.min.y + box.max.y) / 2,
126
+ box.max.z + CORNER_OFFSET,
127
+ )
128
+ return { pivot, corner }
129
+ }, [ids])
130
+
131
+ if (!rest) return null
132
+ const active = isDragging && frozenRest.current ? frozenRest.current : rest
133
+ const corner = active.corner
134
+
135
+ const activate = (event: ThreeEvent<PointerEvent>) => {
136
+ event.stopPropagation()
137
+ suppressBoxSelectForPointer(event)
138
+
139
+ frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
140
+ const center = rest.pivot.clone()
141
+
142
+ // Snapshot the selected participants + connected wall/fence neighbours whose
143
+ // shared endpoints must follow the rotation (so junctions stay welded).
144
+ const levelId = useViewer.getState().selection.levelId
145
+ const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
146
+ if (starts.length === 0) return
147
+
148
+ // Placements live in the level frame; the world pivot must be converted into
149
+ // it before orbiting positions, or a rotated building displaces the centre.
150
+ // The swept angle is frame-invariant (both frames differ by a constant yaw,
151
+ // which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
152
+ // in world against `center` — keeping the world-space guide overlay correct.
153
+ const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
154
+
155
+ // Horizontal drag plane at the pivot; bearing measured around the pivot.
156
+ const plane = new Plane(new Vector3(0, 1, 0), -center.y)
157
+ const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
158
+
159
+ // Wedge radius tracks how far the group spreads from the pivot — sample each
160
+ // participant's anchor point(s).
161
+ let spread = 0
162
+ const reach = (x: number, z: number) => {
163
+ spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
164
+ }
165
+ for (const s of starts) {
166
+ if (s.kind === 'endpoint') {
167
+ reach(s.start[0], s.start[1])
168
+ reach(s.end[0], s.end[1])
169
+ } else {
170
+ reach(s.position[0], s.position[2])
171
+ }
172
+ }
173
+ const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
174
+
175
+ const ndc = new Vector2()
176
+ const setNDC = (clientX: number, clientY: number) => {
177
+ const rect = gl.domElement.getBoundingClientRect()
178
+ ndc.set(
179
+ ((clientX - rect.left) / rect.width) * 2 - 1,
180
+ -((clientY - rect.top) / rect.height) * 2 + 1,
181
+ )
182
+ }
183
+
184
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
185
+ raycaster.setFromCamera(ndc, camera)
186
+ const hit = new Vector3()
187
+ if (!raycaster.ray.intersectPlane(plane, hit)) return
188
+ const initialAngle = angleOf(hit)
189
+
190
+ document.body.style.cursor = 'grabbing'
191
+ sfxEmitter.emit('sfx:item-pick')
192
+ useViewer.getState().setInputDragging(true)
193
+ useScene.temporal.getState().pause()
194
+ setIsDragging(true)
195
+
196
+ const onMove = (e: PointerEvent) => {
197
+ setNDC(e.clientX, e.clientY)
198
+ raycaster.setFromCamera(ndc, camera)
199
+ const moveHit = new Vector3()
200
+ if (!raycaster.ray.intersectPlane(plane, moveHit)) return
201
+ let delta = angleOf(moveHit) - initialAngle
202
+ while (delta > Math.PI) delta -= 2 * Math.PI
203
+ while (delta < -Math.PI) delta += 2 * Math.PI
204
+ if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
205
+
206
+ // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
207
+ // turn its yaw by `-delta` to match three.js Y-rotation handedness (same
208
+ // convention as the single-item rotate handle in item/definition.ts).
209
+ // Endpoint nodes (walls/fences) have no yaw — swinging both endpoints
210
+ // around the pivot rotates them rigidly; their curveOffset sagitta is
211
+ // rotation-invariant, so arcs are preserved.
212
+ const cos = Math.cos(delta)
213
+ const sin = Math.sin(delta)
214
+ const rot = (x: number, z: number): Vec2 => {
215
+ const dx = x - localCenter.x
216
+ const dz = z - localCenter.z
217
+ return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
218
+ }
219
+ const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
220
+ const liveTransforms = useLiveTransforms.getState()
221
+ for (const s of starts) {
222
+ if (s.kind === 'endpoint') {
223
+ overrideEntries.push([
224
+ s.id,
225
+ { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) },
226
+ ])
227
+ } else {
228
+ const [px, pz] = rot(s.position[0], s.position[2])
229
+ const position: Vec3 = [px, s.position[1], pz]
230
+ const rotation =
231
+ s.kind === 'vec3'
232
+ ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
233
+ : s.rotation - delta
234
+ overrideEntries.push([s.id, { position, rotation }])
235
+ if (s.kind === 'scalar') {
236
+ liveTransforms.set(s.id, { position, rotation: s.rotation - delta })
237
+ }
238
+ }
239
+ useScene.getState().markDirty(s.id)
240
+ }
241
+
242
+ // Drag each linked neighbour's shared endpoint to the same rotated spot
243
+ // (rot is deterministic, so it lands exactly on the selected wall's
244
+ // rotated endpoint), keeping the junction welded; the far end stays put.
245
+ for (const l of links) {
246
+ overrideEntries.push([
247
+ l.id,
248
+ {
249
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
250
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
251
+ },
252
+ ])
253
+ useScene.getState().markDirty(l.id)
254
+ }
255
+ useLiveNodeOverrides.getState().setMany(overrideEntries)
256
+
257
+ if (Math.abs(delta) < 0.0087) {
258
+ setGuide(null)
259
+ } else {
260
+ const midAngle = initialAngle + delta / 2
261
+ const labelRadius = guideRadius + 0.22
262
+ setGuide({
263
+ center: [center.x, center.y, center.z],
264
+ startAngle: initialAngle,
265
+ endAngle: initialAngle + delta,
266
+ radius: guideRadius,
267
+ labelPos: [
268
+ center.x + Math.cos(midAngle) * labelRadius,
269
+ center.y + 0.02,
270
+ center.z + Math.sin(midAngle) * labelRadius,
271
+ ],
272
+ sweep: Math.abs(delta),
273
+ })
274
+ }
275
+ }
276
+
277
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
278
+ const clearLivePreviews = () => {
279
+ const overrides = useLiveNodeOverrides.getState()
280
+ const liveTransforms = useLiveTransforms.getState()
281
+ for (const id of affectedIds) {
282
+ overrides.clear(id)
283
+ liveTransforms.clear(id)
284
+ useScene.getState().markDirty(id)
285
+ }
286
+ }
287
+
288
+ const cleanup = () => {
289
+ window.removeEventListener('pointermove', onMove)
290
+ window.removeEventListener('pointerup', onUp)
291
+ window.removeEventListener('pointercancel', onCancel)
292
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
293
+ useScene.temporal.getState().resume()
294
+ useViewer.getState().setInputDragging(false)
295
+ setIsDragging(false)
296
+ setGuide(null)
297
+ frozenRest.current = null
298
+ dragCleanupRef.current = null
299
+ }
300
+
301
+ const commitFromOverrides = () => {
302
+ const overrides = useLiveNodeOverrides.getState()
303
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
304
+ for (const id of affectedIds) {
305
+ const patch = overrides.get(id)
306
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
307
+ }
308
+ return updates
309
+ }
310
+
311
+ const onUp = () => {
312
+ // Eat the click that follows pointer-up so the selection manager doesn't
313
+ // treat it as a canvas click and clear the multi-selection.
314
+ swallowNextClick()
315
+ sfxEmitter.emit('sfx:item-place')
316
+ const updates = commitFromOverrides()
317
+ // Resume before the commit so the single batched `updateNodes` is the
318
+ // one tracked set — collapsing the whole group rotation into one undo.
319
+ useScene.temporal.getState().resume()
320
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
321
+ clearLivePreviews()
322
+ cleanup()
323
+ }
324
+
325
+ const onCancel = () => {
326
+ // Revert: drop overrides + mark dirty so renderers rebuild from the store.
327
+ clearLivePreviews()
328
+ cleanup()
329
+ }
330
+
331
+ dragCleanupRef.current = () => {
332
+ clearLivePreviews()
333
+ cleanup()
334
+ }
335
+ for (const id of affectedIds) {
336
+ useLiveTransforms.getState().clear(id)
337
+ }
338
+ window.addEventListener('pointermove', onMove)
339
+ window.addEventListener('pointerup', onUp)
340
+ window.addEventListener('pointercancel', onCancel)
341
+ }
342
+
343
+ return createPortal(
344
+ <>
345
+ {(isHovered || isDragging) && (
346
+ <group position={[corner.x, corner.y, corner.z]}>
347
+ <GuideRing radius={0.2 * scale} y={0} />
348
+ </group>
349
+ )}
350
+ <group position={[corner.x, corner.y, corner.z]}>
351
+ <InvisibleHandleHitArea
352
+ geometry={hitGeometry}
353
+ material={hitMaterial}
354
+ onPointerDown={activate}
355
+ onPointerEnter={(event) => {
356
+ event.stopPropagation()
357
+ setIsHovered(true)
358
+ if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
359
+ }}
360
+ onPointerLeave={(event) => {
361
+ event.stopPropagation()
362
+ setIsHovered(false)
363
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
364
+ }}
365
+ scale={baseScale}
366
+ />
367
+ <mesh
368
+ frustumCulled={false}
369
+ geometry={arrowGeometry}
370
+ material={arrowMaterial}
371
+ raycast={NO_RAYCAST}
372
+ renderOrder={1010}
373
+ scale={scale}
374
+ />
375
+ </group>
376
+ {guide ? <RotationGuide data={guide} /> : null}
377
+ </>,
378
+ scene,
379
+ )
380
+ }
381
+
382
+ export default GroupRotateHandle
@@ -0,0 +1,205 @@
1
+ import { beforeAll, describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
3
+ import { z } from 'zod'
4
+ import { classifyParticipant, collectParticipants } from './group-transform-shared'
5
+
6
+ const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
7
+
8
+ function registerBuildingScopedTestKind() {
9
+ if (nodeRegistry.has(BUILDING_SCOPED_KIND)) return
10
+
11
+ registerNode({
12
+ kind: BUILDING_SCOPED_KIND,
13
+ schemaVersion: 1,
14
+ schema: z.object({ type: z.literal(BUILDING_SCOPED_KIND) }) as never,
15
+ category: 'structure',
16
+ defaults: () => ({}),
17
+ capabilities: {},
18
+ floorplanScope: 'building',
19
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
20
+ } as AnyNodeDefinition)
21
+ }
22
+
23
+ function registerElevatorTestKind() {
24
+ if (nodeRegistry.has('elevator')) return
25
+
26
+ registerNode({
27
+ kind: 'elevator',
28
+ schemaVersion: 1,
29
+ schema: z.object({ type: z.literal('elevator') }) as never,
30
+ category: 'structure',
31
+ defaults: () => ({}),
32
+ capabilities: { selectable: {} },
33
+ floorplanScope: 'building',
34
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
35
+ } as AnyNodeDefinition)
36
+ }
37
+
38
+ describe('group transform participants', () => {
39
+ beforeAll(() => {
40
+ registerBuildingScopedTestKind()
41
+ registerElevatorTestKind()
42
+ })
43
+
44
+ test('includes building-scoped positioned nodes for the active level building', () => {
45
+ const nodes = {
46
+ building_test: {
47
+ id: 'building_test',
48
+ type: 'building',
49
+ children: ['level_test', 'elevator_test'],
50
+ },
51
+ level_test: {
52
+ id: 'level_test',
53
+ type: 'level',
54
+ parentId: 'building_test',
55
+ children: [],
56
+ },
57
+ elevator_test: {
58
+ id: 'elevator_test',
59
+ type: BUILDING_SCOPED_KIND,
60
+ parentId: 'building_test',
61
+ position: [1, 0, 2],
62
+ rotation: 0,
63
+ },
64
+ } as unknown as Record<string, AnyNode>
65
+
66
+ expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
67
+
68
+ const participants = collectParticipants(['elevator_test'], nodes, 'level_test')
69
+ expect(participants.starts).toEqual([
70
+ {
71
+ id: 'elevator_test',
72
+ kind: 'scalar',
73
+ position: [1, 0, 2],
74
+ rotation: 0,
75
+ },
76
+ ])
77
+ })
78
+
79
+ test('excludes building-scoped positioned nodes from other buildings', () => {
80
+ const nodes = {
81
+ building_active: {
82
+ id: 'building_active',
83
+ type: 'building',
84
+ children: ['level_test'],
85
+ },
86
+ building_other: {
87
+ id: 'building_other',
88
+ type: 'building',
89
+ children: ['elevator_test'],
90
+ },
91
+ level_test: {
92
+ id: 'level_test',
93
+ type: 'level',
94
+ parentId: 'building_active',
95
+ children: [],
96
+ },
97
+ elevator_test: {
98
+ id: 'elevator_test',
99
+ type: BUILDING_SCOPED_KIND,
100
+ parentId: 'building_other',
101
+ position: [1, 0, 2],
102
+ rotation: 0,
103
+ },
104
+ } as unknown as Record<string, AnyNode>
105
+
106
+ expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBeNull()
107
+ expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([])
108
+ })
109
+
110
+ test('uses current elevator defaults for legacy elevators with no saved rotation', () => {
111
+ const nodes = {
112
+ building_test: {
113
+ id: 'building_test',
114
+ type: 'building',
115
+ children: ['level_test', 'elevator_test'],
116
+ },
117
+ level_test: {
118
+ id: 'level_test',
119
+ type: 'level',
120
+ parentId: 'building_test',
121
+ children: [],
122
+ },
123
+ elevator_test: {
124
+ id: 'elevator_test',
125
+ type: 'elevator',
126
+ parentId: 'building_test',
127
+ position: [3, 0, 4],
128
+ },
129
+ } as unknown as Record<string, AnyNode>
130
+
131
+ expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
132
+ expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
133
+ {
134
+ id: 'elevator_test',
135
+ kind: 'scalar',
136
+ position: [3, 0, 4],
137
+ rotation: 0,
138
+ },
139
+ ])
140
+ })
141
+
142
+ test('resolves building-scoped elevators when legacy level parentId is missing', () => {
143
+ const nodes = {
144
+ building_test: {
145
+ id: 'building_test',
146
+ type: 'building',
147
+ children: ['level_test', 'elevator_test'],
148
+ },
149
+ level_test: {
150
+ id: 'level_test',
151
+ type: 'level',
152
+ parentId: null,
153
+ children: [],
154
+ },
155
+ elevator_test: {
156
+ id: 'elevator_test',
157
+ type: 'elevator',
158
+ parentId: 'building_test',
159
+ position: [7, 0, 8],
160
+ },
161
+ } as unknown as Record<string, AnyNode>
162
+
163
+ expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
164
+ expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
165
+ {
166
+ id: 'elevator_test',
167
+ kind: 'scalar',
168
+ position: [7, 0, 8],
169
+ rotation: 0,
170
+ },
171
+ ])
172
+ })
173
+
174
+ test('supports legacy level-parented elevators already loaded in the editor', () => {
175
+ const nodes = {
176
+ building_test: {
177
+ id: 'building_test',
178
+ type: 'building',
179
+ children: ['level_test'],
180
+ },
181
+ level_test: {
182
+ id: 'level_test',
183
+ type: 'level',
184
+ parentId: 'building_test',
185
+ children: ['elevator_test'],
186
+ },
187
+ elevator_test: {
188
+ id: 'elevator_test',
189
+ type: 'elevator',
190
+ parentId: 'level_test',
191
+ position: [5, 0, 6],
192
+ },
193
+ } as unknown as Record<string, AnyNode>
194
+
195
+ expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
196
+ expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
197
+ {
198
+ id: 'elevator_test',
199
+ kind: 'scalar',
200
+ position: [5, 0, 6],
201
+ rotation: 0,
202
+ },
203
+ ])
204
+ })
205
+ })