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