@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,256 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ nodeRegistry,
5
+ resolveBuildingForLevel,
6
+ sceneRegistry,
7
+ } from '@pascal-app/core'
8
+ import { Box3, Matrix4 } from 'three'
9
+
10
+ // Shared plumbing for the group transform gizmos (rotate + move). Both operate
11
+ // on the same multi-selection: classify each participant by how its placement
12
+ // transforms, snapshot pre-drag state, and pull connected wall/fence neighbours
13
+ // along so junctions stay welded.
14
+
15
+ // Outward clearance from a bbox corner so a gizmo doesn't sit on the geometry.
16
+ export const CORNER_OFFSET = 0.3
17
+ // Two endpoints within this distance count as the same junction — a hair looser
18
+ // than the store's 1e-6 so near-but-not-exact corners still hold together.
19
+ const JUNCTION_EPS = 1e-4
20
+
21
+ export type Vec2 = [number, number]
22
+ export type Vec3 = [number, number, number]
23
+
24
+ const isVec3 = (v: unknown): v is Vec3 =>
25
+ Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
26
+ const isVec2 = (v: unknown): v is Vec2 =>
27
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
28
+
29
+ // How a participant's placement transforms rigidly around / with the group:
30
+ // - 'vec3' position + [x,y,z] rotation (items, …)
31
+ // - 'scalar' position + numeric rotation (columns)
32
+ // - 'endpoint' start/end tuples (walls, fences)
33
+ export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
34
+
35
+ // A selected node qualifies when it belongs to the active level's horizontal
36
+ // frame: either parented to that level, or declared building-scoped and parented
37
+ // to the active level's building. Doors/windows parent to their wall, so they're
38
+ // excluded here and ride their wall.
39
+ function isInGroupTransformScope(
40
+ node: AnyNode | undefined,
41
+ levelId: string | null,
42
+ sceneNodes: Record<string, AnyNode | undefined>,
43
+ ): boolean {
44
+ if (!node || !levelId) return false
45
+ if (node.parentId === levelId) return true
46
+
47
+ if (nodeRegistry.get(node.type)?.floorplanScope !== 'building') {
48
+ return false
49
+ }
50
+
51
+ const buildingId = resolveBuildingForLevel(
52
+ levelId as AnyNodeId,
53
+ sceneNodes as Record<AnyNodeId, AnyNode>,
54
+ )
55
+ return Boolean(buildingId && node.parentId === buildingId)
56
+ }
57
+
58
+ function getLegacyScenePosition(node: AnyNode): Vec3 | null {
59
+ if (node.type !== 'elevator') return null
60
+ const object = sceneRegistry.nodes.get(node.id)
61
+ if (!object) return [0, 0, 0]
62
+ return [object.position.x, object.position.y, object.position.z]
63
+ }
64
+
65
+ function getParticipantPosition(node: AnyNode): Vec3 | null {
66
+ const p = (node as { position?: unknown }).position
67
+ if (isVec3(p)) return p
68
+ return getLegacyScenePosition(node)
69
+ }
70
+
71
+ function getParticipantScalarRotation(node: AnyNode): number | null {
72
+ const r = (node as { rotation?: unknown }).rotation
73
+ if (typeof r === 'number' && Number.isFinite(r)) return r
74
+ if (node.type !== 'elevator') return null
75
+ return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
76
+ }
77
+
78
+ export function classifyParticipant(
79
+ node: AnyNode | undefined,
80
+ levelId: string | null,
81
+ sceneNodes: Record<string, AnyNode | undefined>,
82
+ ): ParticipantKind | null {
83
+ if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
84
+ const p = getParticipantPosition(node)
85
+ const r = (node as { rotation?: unknown }).rotation
86
+ const start = (node as { start?: unknown }).start
87
+ 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
+ if (isVec2(start) && isVec2(end)) return 'endpoint'
91
+ return null
92
+ }
93
+
94
+ // Pre-drag placement snapshot + how to transform it.
95
+ export type ParticipantStart =
96
+ | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
97
+ | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
98
+ | { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
99
+
100
+ // An unselected wall/fence sharing a junction with a transforming endpoint. Only
101
+ // the touching endpoint(s) follow, so the neighbour stays attached while its far
102
+ // end stays put (it stretches, mirroring single-wall move).
103
+ export type LinkedNeighbor = {
104
+ id: AnyNodeId
105
+ start: Vec2
106
+ end: Vec2
107
+ startLinked: boolean
108
+ endLinked: boolean
109
+ }
110
+
111
+ const nearPoint = (a: Vec2, b: Vec2) =>
112
+ Math.abs(a[0] - b[0]) <= JUNCTION_EPS && Math.abs(a[1] - b[1]) <= JUNCTION_EPS
113
+
114
+ // Snapshot the selected participants and the connected (unselected) wall/fence
115
+ // neighbours whose shared endpoints should follow the transform.
116
+ export function collectParticipants(
117
+ ids: string[],
118
+ sceneNodes: Record<string, AnyNode | undefined>,
119
+ levelId: string | null,
120
+ ): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
121
+ const starts: ParticipantStart[] = []
122
+ for (const id of ids) {
123
+ const node = sceneNodes[id]
124
+ const kind = classifyParticipant(node, levelId, sceneNodes)
125
+ if (!node || !kind) continue
126
+ if (kind === 'vec3') {
127
+ const n = node as AnyNode & { position: Vec3; rotation: Vec3 }
128
+ const position = getParticipantPosition(node)
129
+ if (!position) continue
130
+ starts.push({
131
+ id: id as AnyNodeId,
132
+ kind,
133
+ position: [position[0], position[1], position[2]],
134
+ rotation: [n.rotation[0], n.rotation[1], n.rotation[2]],
135
+ })
136
+ } else if (kind === 'scalar') {
137
+ const position = getParticipantPosition(node)
138
+ const rotation = getParticipantScalarRotation(node)
139
+ if (!(position && rotation !== null)) continue
140
+ starts.push({
141
+ id: id as AnyNodeId,
142
+ kind,
143
+ position: [position[0], position[1], position[2]],
144
+ rotation,
145
+ })
146
+ } else {
147
+ const n = node as AnyNode & { start: Vec2; end: Vec2 }
148
+ starts.push({
149
+ id: id as AnyNodeId,
150
+ kind,
151
+ start: [n.start[0], n.start[1]],
152
+ end: [n.end[0], n.end[1]],
153
+ })
154
+ }
155
+ }
156
+
157
+ const endpoints: Vec2[] = []
158
+ for (const s of starts) {
159
+ if (s.kind === 'endpoint') endpoints.push(s.start, s.end)
160
+ }
161
+ const links: LinkedNeighbor[] = []
162
+ if (endpoints.length > 0) {
163
+ const selected = new Set(starts.map((s) => s.id))
164
+ for (const [nid, node] of Object.entries(sceneNodes)) {
165
+ if (selected.has(nid as AnyNodeId)) continue
166
+ if (classifyParticipant(node, levelId, sceneNodes) !== 'endpoint') continue
167
+ const n = node as AnyNode & { start: Vec2; end: Vec2 }
168
+ const start: Vec2 = [n.start[0], n.start[1]]
169
+ const end: Vec2 = [n.end[0], n.end[1]]
170
+ const startLinked = endpoints.some((p) => nearPoint(start, p))
171
+ const endLinked = endpoints.some((p) => nearPoint(end, p))
172
+ if (startLinked || endLinked) {
173
+ links.push({ id: nid as AnyNodeId, start, end, startLinked, endLinked })
174
+ }
175
+ }
176
+ }
177
+ return { starts, links }
178
+ }
179
+
180
+ // Grow a selection to the full connected component of walls/fences: any
181
+ // endpoint node transitively reachable through shared junctions from a selected
182
+ // endpoint node joins in, so the whole rigid structure transforms as one piece
183
+ // (rather than tearing/stretching at the boundary). Non-endpoint selections
184
+ // (items, columns) pass through unchanged.
185
+ export function expandToComponent(
186
+ selectedIds: string[],
187
+ sceneNodes: Record<string, AnyNode | undefined>,
188
+ levelId: string | null,
189
+ ): string[] {
190
+ const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
191
+ for (const [id, node] of Object.entries(sceneNodes)) {
192
+ if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') {
193
+ const n = node as AnyNode & { start: Vec2; end: Vec2 }
194
+ endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
195
+ }
196
+ }
197
+ const included = new Set(selectedIds)
198
+ if (!endpoints.some((e) => included.has(e.id))) return selectedIds
199
+
200
+ let changed = true
201
+ while (changed) {
202
+ changed = false
203
+ for (const e of endpoints) {
204
+ if (included.has(e.id)) continue
205
+ const touches = endpoints.some(
206
+ (o) =>
207
+ included.has(o.id) &&
208
+ (nearPoint(e.start, o.start) ||
209
+ nearPoint(e.start, o.end) ||
210
+ nearPoint(e.end, o.start) ||
211
+ nearPoint(e.end, o.end)),
212
+ )
213
+ if (touches) {
214
+ included.add(e.id)
215
+ changed = true
216
+ }
217
+ }
218
+ }
219
+ return Array.from(included)
220
+ }
221
+
222
+ // Frozen world matrix of the level group + its inverse. A node's placement
223
+ // (`position` / `start` / `end`) is stored in its parent level's frame, but the
224
+ // gizmos raycast the ground plane in WORLD space. When the building is rotated
225
+ // those frames diverge, so a world-space drag delta / rotation pivot must be
226
+ // converted into the level frame before it's written back to placements —
227
+ // otherwise the move drifts off-axis from the cursor and the rotation orbits a
228
+ // displaced centre. Returns identity matrices when the level isn't mounted, which
229
+ // collapses to the old behaviour (world == local) for an unrotated building.
230
+ export function levelFrame(levelId: string | null): { matrix: Matrix4; inverse: Matrix4 } {
231
+ const obj = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
232
+ if (!obj) return { matrix: new Matrix4(), inverse: new Matrix4() }
233
+ obj.updateWorldMatrix(true, false)
234
+ const matrix = obj.matrixWorld.clone()
235
+ return { matrix, inverse: matrix.clone().invert() }
236
+ }
237
+
238
+ // World-space union bounding box of the selected meshes, or null if none are
239
+ // mounted yet. Used to place the gizmos (which are portalled to the scene root,
240
+ // so they live in world space); placement writes convert back to the level frame
241
+ // via `levelFrame`.
242
+ export function computeGroupBox(ids: string[]): Box3 | null {
243
+ const box = new Box3()
244
+ const tmp = new Box3()
245
+ let found = false
246
+ for (const id of ids) {
247
+ const obj = sceneRegistry.nodes.get(id)
248
+ if (!obj) continue
249
+ obj.updateWorldMatrix(true, true)
250
+ tmp.setFromObject(obj)
251
+ if (tmp.isEmpty()) continue
252
+ box.union(tmp)
253
+ found = true
254
+ }
255
+ return found ? box : null
256
+ }