@pascal-app/core 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 (272) hide show
  1. package/dist/events/bus.d.ts +39 -13
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/scene-registry.d.ts +6 -22
  4. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  5. package/dist/hooks/scene-registry/scene-registry.js +33 -26
  6. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +14 -0
  7. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -0
  8. package/dist/hooks/spatial-grid/floor-placed-elevation.js +51 -0
  9. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +2 -0
  10. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +1 -0
  11. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +273 -0
  12. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +13 -2
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.js +95 -9
  15. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +28 -1
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.js +91 -18
  18. package/dist/index.d.ts +22 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +19 -2
  21. package/dist/lib/polygon-relations.d.ts +10 -0
  22. package/dist/lib/polygon-relations.d.ts.map +1 -0
  23. package/dist/lib/polygon-relations.js +73 -0
  24. package/dist/lib/space-detection-pause.test.d.ts +2 -0
  25. package/dist/lib/space-detection-pause.test.d.ts.map +1 -0
  26. package/dist/lib/space-detection-pause.test.js +42 -0
  27. package/dist/lib/space-detection.d.ts +30 -1
  28. package/dist/lib/space-detection.d.ts.map +1 -1
  29. package/dist/lib/space-detection.js +215 -36
  30. package/dist/lib/space-detection.test.d.ts +2 -0
  31. package/dist/lib/space-detection.test.d.ts.map +1 -0
  32. package/dist/lib/space-detection.test.js +77 -0
  33. package/dist/material-library.d.ts +1 -1
  34. package/dist/material-library.d.ts.map +1 -1
  35. package/dist/material-library.js +1940 -143
  36. package/dist/registry/__bench__/relations-resolver.bench.d.ts +15 -0
  37. package/dist/registry/__bench__/relations-resolver.bench.d.ts.map +1 -0
  38. package/dist/registry/__bench__/relations-resolver.bench.js +170 -0
  39. package/dist/registry/handles.d.ts +321 -0
  40. package/dist/registry/handles.d.ts.map +1 -0
  41. package/dist/registry/handles.js +24 -0
  42. package/dist/registry/index.d.ts +7 -0
  43. package/dist/registry/index.d.ts.map +1 -0
  44. package/dist/registry/index.js +4 -0
  45. package/dist/registry/registry.d.ts +93 -0
  46. package/dist/registry/registry.d.ts.map +1 -0
  47. package/dist/registry/registry.js +201 -0
  48. package/dist/registry/registry.test.d.ts +2 -0
  49. package/dist/registry/registry.test.d.ts.map +1 -0
  50. package/dist/registry/registry.test.js +183 -0
  51. package/dist/registry/relations-resolver.d.ts +42 -0
  52. package/dist/registry/relations-resolver.d.ts.map +1 -0
  53. package/dist/registry/relations-resolver.js +77 -0
  54. package/dist/registry/relations-resolver.test.d.ts +2 -0
  55. package/dist/registry/relations-resolver.test.d.ts.map +1 -0
  56. package/dist/registry/relations-resolver.test.js +183 -0
  57. package/dist/registry/scene-api.d.ts +44 -0
  58. package/dist/registry/scene-api.d.ts.map +1 -0
  59. package/dist/registry/scene-api.js +111 -0
  60. package/dist/registry/scene-api.test.d.ts +2 -0
  61. package/dist/registry/scene-api.test.d.ts.map +1 -0
  62. package/dist/registry/scene-api.test.js +183 -0
  63. package/dist/registry/subtree.d.ts +67 -0
  64. package/dist/registry/subtree.d.ts.map +1 -0
  65. package/dist/registry/subtree.js +119 -0
  66. package/dist/registry/subtree.test.d.ts +2 -0
  67. package/dist/registry/subtree.test.d.ts.map +1 -0
  68. package/dist/registry/subtree.test.js +133 -0
  69. package/dist/registry/types.d.ts +1420 -0
  70. package/dist/registry/types.d.ts.map +1 -0
  71. package/dist/registry/types.js +1 -0
  72. package/dist/schema/index.d.ts +17 -2
  73. package/dist/schema/index.d.ts.map +1 -1
  74. package/dist/schema/index.js +17 -3
  75. package/dist/schema/material.d.ts +10 -0
  76. package/dist/schema/material.d.ts.map +1 -1
  77. package/dist/schema/material.js +10 -0
  78. package/dist/schema/nodes/box-vent.d.ts +74 -0
  79. package/dist/schema/nodes/box-vent.d.ts.map +1 -0
  80. package/dist/schema/nodes/box-vent.js +63 -0
  81. package/dist/schema/nodes/building.d.ts +1 -1
  82. package/dist/schema/nodes/building.d.ts.map +1 -1
  83. package/dist/schema/nodes/building.js +3 -2
  84. package/dist/schema/nodes/ceiling.d.ts +2 -0
  85. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  86. package/dist/schema/nodes/ceiling.js +1 -1
  87. package/dist/schema/nodes/chimney.d.ts +158 -0
  88. package/dist/schema/nodes/chimney.d.ts.map +1 -0
  89. package/dist/schema/nodes/chimney.js +67 -0
  90. package/dist/schema/nodes/column.d.ts +597 -5
  91. package/dist/schema/nodes/column.d.ts.map +1 -1
  92. package/dist/schema/nodes/column.js +586 -6
  93. package/dist/schema/nodes/cupola.d.ts +66 -0
  94. package/dist/schema/nodes/cupola.d.ts.map +1 -0
  95. package/dist/schema/nodes/cupola.js +28 -0
  96. package/dist/schema/nodes/dormer.d.ts +228 -0
  97. package/dist/schema/nodes/dormer.d.ts.map +1 -0
  98. package/dist/schema/nodes/dormer.js +119 -0
  99. package/dist/schema/nodes/downspout.d.ts +75 -0
  100. package/dist/schema/nodes/downspout.d.ts.map +1 -0
  101. package/dist/schema/nodes/downspout.js +63 -0
  102. package/dist/schema/nodes/elevator.d.ts +105 -0
  103. package/dist/schema/nodes/elevator.d.ts.map +1 -0
  104. package/dist/schema/nodes/elevator.js +54 -0
  105. package/dist/schema/nodes/eyebrow-vent.d.ts +68 -0
  106. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -0
  107. package/dist/schema/nodes/eyebrow-vent.js +40 -0
  108. package/dist/schema/nodes/fence.d.ts +1 -0
  109. package/dist/schema/nodes/fence.d.ts.map +1 -1
  110. package/dist/schema/nodes/fence.js +2 -0
  111. package/dist/schema/nodes/gutter.d.ts +84 -0
  112. package/dist/schema/nodes/gutter.d.ts.map +1 -0
  113. package/dist/schema/nodes/gutter.js +76 -0
  114. package/dist/schema/nodes/item.d.ts +4 -0
  115. package/dist/schema/nodes/item.d.ts.map +1 -1
  116. package/dist/schema/nodes/item.js +9 -0
  117. package/dist/schema/nodes/level.d.ts +1 -1
  118. package/dist/schema/nodes/level.d.ts.map +1 -1
  119. package/dist/schema/nodes/level.js +2 -0
  120. package/dist/schema/nodes/ridge-vent.d.ts +67 -0
  121. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -0
  122. package/dist/schema/nodes/ridge-vent.js +30 -0
  123. package/dist/schema/nodes/roof-segment.d.ts +195 -1
  124. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  125. package/dist/schema/nodes/roof-segment.js +254 -3
  126. package/dist/schema/nodes/roof.d.ts.map +1 -1
  127. package/dist/schema/nodes/roof.js +4 -16
  128. package/dist/schema/nodes/shelf.d.ts +105 -0
  129. package/dist/schema/nodes/shelf.d.ts.map +1 -0
  130. package/dist/schema/nodes/shelf.js +84 -0
  131. package/dist/schema/nodes/site.d.ts +1 -122
  132. package/dist/schema/nodes/site.d.ts.map +1 -1
  133. package/dist/schema/nodes/site.js +2 -6
  134. package/dist/schema/nodes/skylight.d.ts +279 -0
  135. package/dist/schema/nodes/skylight.d.ts.map +1 -0
  136. package/dist/schema/nodes/skylight.js +152 -0
  137. package/dist/schema/nodes/slab.d.ts +2 -0
  138. package/dist/schema/nodes/slab.d.ts.map +1 -1
  139. package/dist/schema/nodes/slab.js +1 -1
  140. package/dist/schema/nodes/solar-panel.d.ts +117 -0
  141. package/dist/schema/nodes/solar-panel.d.ts.map +1 -0
  142. package/dist/schema/nodes/solar-panel.js +47 -0
  143. package/dist/schema/nodes/surface-hole-metadata.d.ts +2 -0
  144. package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -1
  145. package/dist/schema/nodes/surface-hole-metadata.js +3 -1
  146. package/dist/schema/nodes/turbine-vent.d.ts +68 -0
  147. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -0
  148. package/dist/schema/nodes/turbine-vent.js +41 -0
  149. package/dist/schema/types.d.ts +1325 -125
  150. package/dist/schema/types.d.ts.map +1 -1
  151. package/dist/schema/types.js +26 -0
  152. package/dist/services/alignment-anchors.d.ts +100 -0
  153. package/dist/services/alignment-anchors.d.ts.map +1 -0
  154. package/dist/services/alignment-anchors.js +283 -0
  155. package/dist/services/alignment-anchors.test.d.ts +2 -0
  156. package/dist/services/alignment-anchors.test.d.ts.map +1 -0
  157. package/dist/services/alignment-anchors.test.js +383 -0
  158. package/dist/services/alignment.d.ts +126 -0
  159. package/dist/services/alignment.d.ts.map +1 -0
  160. package/dist/services/alignment.js +169 -0
  161. package/dist/services/alignment.test.d.ts +2 -0
  162. package/dist/services/alignment.test.d.ts.map +1 -0
  163. package/dist/services/alignment.test.js +92 -0
  164. package/dist/services/drag-session.d.ts +47 -0
  165. package/dist/services/drag-session.d.ts.map +1 -0
  166. package/dist/services/drag-session.js +100 -0
  167. package/dist/services/drag-session.test.d.ts +2 -0
  168. package/dist/services/drag-session.test.d.ts.map +1 -0
  169. package/dist/services/drag-session.test.js +219 -0
  170. package/dist/services/hosting.d.ts +80 -0
  171. package/dist/services/hosting.d.ts.map +1 -0
  172. package/dist/services/hosting.js +122 -0
  173. package/dist/services/hosting.test.d.ts +2 -0
  174. package/dist/services/hosting.test.d.ts.map +1 -0
  175. package/dist/services/hosting.test.js +199 -0
  176. package/dist/services/index.d.ts +7 -0
  177. package/dist/services/index.d.ts.map +1 -0
  178. package/dist/services/index.js +6 -0
  179. package/dist/services/movement.d.ts +48 -0
  180. package/dist/services/movement.d.ts.map +1 -0
  181. package/dist/services/movement.js +65 -0
  182. package/dist/services/movement.test.d.ts +2 -0
  183. package/dist/services/movement.test.d.ts.map +1 -0
  184. package/dist/services/movement.test.js +133 -0
  185. package/dist/services/single-undo-dance.test.d.ts +2 -0
  186. package/dist/services/single-undo-dance.test.d.ts.map +1 -0
  187. package/dist/services/single-undo-dance.test.js +210 -0
  188. package/dist/services/snap.d.ts +72 -0
  189. package/dist/services/snap.d.ts.map +1 -0
  190. package/dist/services/snap.js +120 -0
  191. package/dist/services/snap.test.d.ts +2 -0
  192. package/dist/services/snap.test.d.ts.map +1 -0
  193. package/dist/services/snap.test.js +95 -0
  194. package/dist/solar-panel-presets.d.ts +17 -0
  195. package/dist/solar-panel-presets.d.ts.map +1 -0
  196. package/dist/solar-panel-presets.js +43 -0
  197. package/dist/store/actions/node-actions.d.ts +14 -2
  198. package/dist/store/actions/node-actions.d.ts.map +1 -1
  199. package/dist/store/actions/node-actions.js +492 -15
  200. package/dist/store/actions/node-mutation-sanitize.test.d.ts +2 -0
  201. package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +1 -0
  202. package/dist/store/actions/node-mutation-sanitize.test.js +147 -0
  203. package/dist/store/actions/reparent.test.d.ts +2 -0
  204. package/dist/store/actions/reparent.test.d.ts.map +1 -0
  205. package/dist/store/actions/reparent.test.js +212 -0
  206. package/dist/store/use-interactive.d.ts +39 -0
  207. package/dist/store/use-interactive.d.ts.map +1 -1
  208. package/dist/store/use-interactive.js +75 -0
  209. package/dist/store/use-live-node-overrides.d.ts +22 -0
  210. package/dist/store/use-live-node-overrides.d.ts.map +1 -0
  211. package/dist/store/use-live-node-overrides.js +59 -0
  212. package/dist/store/use-scene-elevator-migration.test.d.ts +2 -0
  213. package/dist/store/use-scene-elevator-migration.test.d.ts.map +1 -0
  214. package/dist/store/use-scene-elevator-migration.test.js +109 -0
  215. package/dist/store/use-scene.d.ts +11 -0
  216. package/dist/store/use-scene.d.ts.map +1 -1
  217. package/dist/store/use-scene.js +196 -6
  218. package/dist/systems/elevator/elevator-dispatch.d.ts +12 -0
  219. package/dist/systems/elevator/elevator-dispatch.d.ts.map +1 -0
  220. package/dist/systems/elevator/elevator-dispatch.js +57 -0
  221. package/dist/systems/elevator/elevator-geometry.d.ts +15 -0
  222. package/dist/systems/elevator/elevator-geometry.d.ts.map +1 -0
  223. package/dist/systems/elevator/elevator-geometry.js +50 -0
  224. package/dist/systems/elevator/elevator-opening-sync.d.ts +6 -0
  225. package/dist/systems/elevator/elevator-opening-sync.d.ts.map +1 -0
  226. package/dist/systems/elevator/elevator-opening-sync.js +218 -0
  227. package/dist/systems/elevator/elevator-opening-sync.test.d.ts +2 -0
  228. package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +1 -0
  229. package/dist/systems/elevator/elevator-opening-sync.test.js +146 -0
  230. package/dist/systems/elevator/elevator-opening-system.d.ts +2 -0
  231. package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -0
  232. package/dist/systems/elevator/elevator-opening-system.js +48 -0
  233. package/dist/systems/elevator/elevator-runtime-system.d.ts +2 -0
  234. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +1 -0
  235. package/dist/systems/elevator/elevator-runtime-system.js +8 -0
  236. package/dist/systems/elevator/elevator-runtime.d.ts +18 -0
  237. package/dist/systems/elevator/elevator-runtime.d.ts.map +1 -0
  238. package/dist/systems/elevator/elevator-runtime.js +225 -0
  239. package/dist/systems/elevator/elevator-runtime.test.d.ts +2 -0
  240. package/dist/systems/elevator/elevator-runtime.test.d.ts.map +1 -0
  241. package/dist/systems/elevator/elevator-runtime.test.js +64 -0
  242. package/dist/systems/elevator/elevator-service.d.ts +19 -0
  243. package/dist/systems/elevator/elevator-service.d.ts.map +1 -0
  244. package/dist/systems/elevator/elevator-service.js +111 -0
  245. package/dist/systems/stair/stair-footprint.d.ts +44 -0
  246. package/dist/systems/stair/stair-footprint.d.ts.map +1 -0
  247. package/dist/systems/stair/stair-footprint.js +168 -0
  248. package/dist/systems/stair/stair-opening-preview.d.ts +1777 -0
  249. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -0
  250. package/dist/systems/stair/stair-opening-preview.js +93 -0
  251. package/dist/systems/stair/stair-opening-preview.test.d.ts +2 -0
  252. package/dist/systems/stair/stair-opening-preview.test.d.ts.map +1 -0
  253. package/dist/systems/stair/stair-opening-preview.test.js +80 -0
  254. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  255. package/dist/systems/stair/stair-opening-sync.js +172 -147
  256. package/dist/systems/stair/stair-opening-sync.test.js +385 -2
  257. package/dist/systems/stair/stair-opening-system.d.ts +2 -0
  258. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -0
  259. package/dist/systems/stair/stair-opening-system.js +104 -0
  260. package/dist/systems/wall/wall-move.d.ts +48 -0
  261. package/dist/systems/wall/wall-move.d.ts.map +1 -0
  262. package/dist/systems/wall/wall-move.js +193 -0
  263. package/dist/utils/clone-scene-graph.d.ts +6 -4
  264. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  265. package/dist/utils/clone-scene-graph.js +6 -4
  266. package/dist/utils/clone-scene-graph.test.d.ts +2 -0
  267. package/dist/utils/clone-scene-graph.test.d.ts.map +1 -0
  268. package/dist/utils/clone-scene-graph.test.js +59 -0
  269. package/dist/validation/validate-build-json.d.ts +43 -0
  270. package/dist/validation/validate-build-json.d.ts.map +1 -0
  271. package/dist/validation/validate-build-json.js +227 -0
  272. package/package.json +9 -2
@@ -0,0 +1,225 @@
1
+ import { useInteractive } from '../../store/use-interactive';
2
+ import useScene from '../../store/use-scene';
3
+ import { resolveElevatorLevels } from './elevator-service';
4
+ const EPSILON = 0.001;
5
+ function moveToward(current, target, maxDelta) {
6
+ const delta = target - current;
7
+ if (Math.abs(delta) <= maxDelta)
8
+ return target;
9
+ return current + Math.sign(delta) * maxDelta;
10
+ }
11
+ export function createElevatorInteractiveState(levelId, carY) {
12
+ return {
13
+ currentLevelId: levelId,
14
+ targetLevelId: null,
15
+ carY,
16
+ doorOpen: 0,
17
+ phase: 'idle',
18
+ phaseStartedAt: null,
19
+ queue: [],
20
+ requestedStops: [],
21
+ };
22
+ }
23
+ function getInitialElevatorState(elevatorId, nodes) {
24
+ const node = nodes[elevatorId];
25
+ if (node?.type !== 'elevator')
26
+ return null;
27
+ const { defaultEntry } = resolveElevatorLevels(node, nodes);
28
+ if (!defaultEntry)
29
+ return null;
30
+ return createElevatorInteractiveState(defaultEntry.id, defaultEntry.baseY);
31
+ }
32
+ function ensureElevatorState(elevatorId, nodes) {
33
+ const interactive = useInteractive.getState();
34
+ const existing = interactive.elevators[elevatorId];
35
+ if (existing)
36
+ return existing;
37
+ const initial = getInitialElevatorState(elevatorId, nodes);
38
+ if (!initial)
39
+ return null;
40
+ interactive.initElevator(elevatorId, initial.currentLevelId, initial.carY);
41
+ return initial;
42
+ }
43
+ export function queueElevatorRequest(state, levelId) {
44
+ const isAlreadyQueued = state.queue.includes(levelId) || state.targetLevelId === levelId;
45
+ if (isAlreadyQueued)
46
+ return state;
47
+ return {
48
+ ...state,
49
+ queue: [...state.queue, levelId],
50
+ requestedStops: state.requestedStops.includes(levelId)
51
+ ? state.requestedStops
52
+ : [...state.requestedStops, levelId],
53
+ };
54
+ }
55
+ export function openElevatorDoorState(state) {
56
+ if (!state.currentLevelId || state.phase === 'moving')
57
+ return state;
58
+ return {
59
+ ...state,
60
+ phase: 'opening',
61
+ phaseStartedAt: null,
62
+ };
63
+ }
64
+ export function requestElevatorLevel(elevatorId, levelId) {
65
+ const nodes = useScene.getState().nodes;
66
+ const current = ensureElevatorState(elevatorId, nodes);
67
+ if (!current)
68
+ return;
69
+ const next = queueElevatorRequest(current, levelId);
70
+ if (next === current)
71
+ return;
72
+ useInteractive.getState().setElevatorState(elevatorId, next);
73
+ }
74
+ export function openElevatorDoor(elevatorId) {
75
+ const nodes = useScene.getState().nodes;
76
+ const current = ensureElevatorState(elevatorId, nodes);
77
+ if (!current)
78
+ return;
79
+ const next = openElevatorDoorState(current);
80
+ if (next === current)
81
+ return;
82
+ useInteractive.getState().setElevatorState(elevatorId, next);
83
+ }
84
+ export function stepElevatorRuntimeState({ defaultEntry, delta, elevator, entries, now, state, }) {
85
+ const currentEntry = entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry;
86
+ if (currentEntry.id !== state.currentLevelId) {
87
+ return {
88
+ ...state,
89
+ currentLevelId: currentEntry.id,
90
+ carY: currentEntry.baseY,
91
+ targetLevelId: null,
92
+ phase: 'idle',
93
+ phaseStartedAt: null,
94
+ queue: [],
95
+ requestedStops: [],
96
+ doorOpen: 0,
97
+ };
98
+ }
99
+ const targetEntry = state.targetLevelId
100
+ ? entries.find((entry) => entry.id === state.targetLevelId)
101
+ : state.queue[0]
102
+ ? entries.find((entry) => entry.id === state.queue[0])
103
+ : null;
104
+ const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1);
105
+ const doorStep = (delta * 1000) / doorDurationMs;
106
+ switch (state.phase) {
107
+ case 'idle': {
108
+ const nextLevelId = state.queue[0] ?? null;
109
+ if (!nextLevelId) {
110
+ if (state.doorOpen > EPSILON) {
111
+ return {
112
+ ...state,
113
+ doorOpen: Math.max(0, state.doorOpen - doorStep),
114
+ };
115
+ }
116
+ if (state.requestedStops.length === 0)
117
+ return state;
118
+ return {
119
+ ...state,
120
+ requestedStops: [],
121
+ };
122
+ }
123
+ return {
124
+ ...state,
125
+ targetLevelId: nextLevelId,
126
+ phase: state.doorOpen > EPSILON
127
+ ? 'closing'
128
+ : nextLevelId === state.currentLevelId
129
+ ? 'opening'
130
+ : 'moving',
131
+ phaseStartedAt: now,
132
+ };
133
+ }
134
+ case 'closing': {
135
+ const doorOpen = Math.max(0, state.doorOpen - doorStep);
136
+ return {
137
+ ...state,
138
+ doorOpen,
139
+ phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
140
+ phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
141
+ };
142
+ }
143
+ case 'moving': {
144
+ if (!targetEntry) {
145
+ return {
146
+ ...state,
147
+ targetLevelId: null,
148
+ phase: 'idle',
149
+ queue: [],
150
+ requestedStops: [],
151
+ };
152
+ }
153
+ const speed = Math.max(elevator.speed ?? 2.2, 0.1);
154
+ const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta);
155
+ const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON;
156
+ return {
157
+ ...state,
158
+ carY: nextY,
159
+ currentLevelId: arrived ? targetEntry.id : state.currentLevelId,
160
+ phase: arrived ? 'opening' : 'moving',
161
+ phaseStartedAt: arrived ? now : state.phaseStartedAt,
162
+ };
163
+ }
164
+ case 'opening': {
165
+ const doorOpen = Math.min(1, state.doorOpen + doorStep);
166
+ return {
167
+ ...state,
168
+ doorOpen,
169
+ phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening',
170
+ phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt,
171
+ targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId,
172
+ queue: doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId
173
+ ? state.queue.slice(1)
174
+ : state.queue,
175
+ };
176
+ }
177
+ case 'open': {
178
+ const elapsed = now - (state.phaseStartedAt ?? now);
179
+ if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0))
180
+ return state;
181
+ return {
182
+ ...state,
183
+ phase: 'closing',
184
+ phaseStartedAt: now,
185
+ targetLevelId: state.queue[0] ?? null,
186
+ };
187
+ }
188
+ }
189
+ }
190
+ export function stepElevatorRuntimes(now, delta) {
191
+ const nodes = useScene.getState().nodes;
192
+ const interactive = useInteractive.getState();
193
+ for (const elevatorId of Object.keys(interactive.elevators)) {
194
+ const typedElevatorId = elevatorId;
195
+ if (nodes[typedElevatorId]?.type !== 'elevator') {
196
+ interactive.removeElevator(typedElevatorId);
197
+ }
198
+ }
199
+ for (const node of Object.values(nodes)) {
200
+ if (node.type !== 'elevator')
201
+ continue;
202
+ const elevatorId = node.id;
203
+ const { entries, defaultEntry } = resolveElevatorLevels(node, nodes);
204
+ if (!defaultEntry)
205
+ continue;
206
+ const state = useInteractive.getState().elevators[elevatorId];
207
+ if (!state) {
208
+ useInteractive
209
+ .getState()
210
+ .initElevator(elevatorId, defaultEntry.id, defaultEntry.baseY);
211
+ continue;
212
+ }
213
+ const next = stepElevatorRuntimeState({
214
+ defaultEntry,
215
+ delta,
216
+ elevator: node,
217
+ entries,
218
+ now,
219
+ state,
220
+ });
221
+ if (next !== state) {
222
+ useInteractive.getState().setElevatorState(elevatorId, next);
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=elevator-runtime.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elevator-runtime.test.d.ts","sourceRoot":"","sources":["../../../src/systems/elevator/elevator-runtime.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ElevatorNode } from '../../schema';
3
+ import { createElevatorInteractiveState, openElevatorDoorState, queueElevatorRequest, stepElevatorRuntimeState, } from './elevator-runtime';
4
+ const groundLevelId = 'level_ground';
5
+ const upperLevelId = 'level_upper';
6
+ const entries = [
7
+ { id: groundLevelId, label: '0', baseY: 0 },
8
+ { id: upperLevelId, label: '1', baseY: 2.5 },
9
+ ];
10
+ const elevator = ElevatorNode.parse({
11
+ speed: 10,
12
+ doorDurationMs: 100,
13
+ dwellMs: 0,
14
+ });
15
+ describe('elevator runtime helpers', () => {
16
+ test('queues level requests without duplicating the target', () => {
17
+ const state = createElevatorInteractiveState(groundLevelId, 0);
18
+ const queued = queueElevatorRequest(state, upperLevelId);
19
+ const duplicated = queueElevatorRequest(queued, upperLevelId);
20
+ expect(queued.queue).toEqual([upperLevelId]);
21
+ expect(queued.requestedStops).toEqual([upperLevelId]);
22
+ expect(duplicated.queue).toEqual([upperLevelId]);
23
+ expect(duplicated.requestedStops).toEqual([upperLevelId]);
24
+ });
25
+ test('opens doors only when the elevator is not moving', () => {
26
+ const idle = createElevatorInteractiveState(groundLevelId, 0);
27
+ const moving = { ...idle, phase: 'moving' };
28
+ expect(openElevatorDoorState(idle).phase).toBe('opening');
29
+ expect(openElevatorDoorState(moving)).toBe(moving);
30
+ });
31
+ test('moves to a queued level and clears the served request on arrival', () => {
32
+ const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId);
33
+ const moving = stepElevatorRuntimeState({
34
+ defaultEntry: entries[0],
35
+ delta: 0.016,
36
+ elevator,
37
+ entries,
38
+ now: 0,
39
+ state: queued,
40
+ });
41
+ const arrived = stepElevatorRuntimeState({
42
+ defaultEntry: entries[0],
43
+ delta: 1,
44
+ elevator,
45
+ entries,
46
+ now: 100,
47
+ state: moving,
48
+ });
49
+ const open = stepElevatorRuntimeState({
50
+ defaultEntry: entries[0],
51
+ delta: 1,
52
+ elevator,
53
+ entries,
54
+ now: 200,
55
+ state: arrived,
56
+ });
57
+ expect(moving.phase).toBe('moving');
58
+ expect(arrived.currentLevelId).toBe(upperLevelId);
59
+ expect(arrived.phase).toBe('opening');
60
+ expect(open.phase).toBe('open');
61
+ expect(open.queue).toEqual([]);
62
+ expect(open.requestedStops).toEqual([upperLevelId]);
63
+ });
64
+ });
@@ -0,0 +1,19 @@
1
+ import type { AnyNode, ElevatorNode, LevelNode } from '../../schema';
2
+ export declare const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5;
3
+ export type ElevatorLevelEntry = {
4
+ id: LevelNode['id'];
5
+ label: string;
6
+ baseY: number;
7
+ };
8
+ export declare function resolveElevatorBuildingLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>): LevelNode[];
9
+ export declare function resolveElevatorServiceLevelIds(elevator: ElevatorNode, nodes: Record<string, AnyNode>): string[];
10
+ export declare function resolveElevatorServiceLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>): LevelNode[];
11
+ export declare function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number;
12
+ export declare function resolveElevatorLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>): {
13
+ entries: ElevatorLevelEntry[];
14
+ defaultEntry: ElevatorLevelEntry | null;
15
+ shaftBaseY: number;
16
+ shaftTopY: number;
17
+ totalHeight: number;
18
+ };
19
+ //# sourceMappingURL=elevator-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elevator-service.d.ts","sourceRoot":"","sources":["../../../src/systems/elevator/elevator-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAGP,YAAY,EACZ,SAAS,EAEV,MAAM,cAAc,CAAA;AAErB,eAAO,MAAM,6BAA6B,MAAM,CAAA;AAEhD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AA2BD,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAEb;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,MAAM,EAAE,CAEV;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAsBb;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAoB9F;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B;IACD,OAAO,EAAE,kBAAkB,EAAE,CAAA;IAC7B,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAA;IACvC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACpB,CA0CA"}
@@ -0,0 +1,111 @@
1
+ export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5;
2
+ function getBuildingLevels(elevator, nodes) {
3
+ const building = elevator.parentId && nodes[elevator.parentId]?.type === 'building'
4
+ ? nodes[elevator.parentId]
5
+ : null;
6
+ if (building?.type !== 'building')
7
+ return [];
8
+ return building.children
9
+ .map((childId) => nodes[childId])
10
+ .filter((node) => node?.type === 'level')
11
+ .sort((left, right) => left.level - right.level);
12
+ }
13
+ function findLevelIndex(levels, levelId) {
14
+ if (!levelId)
15
+ return -1;
16
+ return levels.findIndex((level) => level.id === levelId);
17
+ }
18
+ function getDefaultToIndex(levels, fromIndex) {
19
+ if (levels.length === 0)
20
+ return -1;
21
+ if (fromIndex < 0)
22
+ return Math.min(1, levels.length - 1);
23
+ return Math.min(fromIndex + 1, levels.length - 1);
24
+ }
25
+ export function resolveElevatorBuildingLevels(elevator, nodes) {
26
+ return getBuildingLevels(elevator, nodes);
27
+ }
28
+ export function resolveElevatorServiceLevelIds(elevator, nodes) {
29
+ return resolveElevatorServiceLevels(elevator, nodes).map((level) => level.id);
30
+ }
31
+ export function resolveElevatorServiceLevels(elevator, nodes) {
32
+ const levels = getBuildingLevels(elevator, nodes);
33
+ if (levels.length === 0)
34
+ return [];
35
+ const hasServiceBounds = Boolean(elevator.fromLevelId || elevator.toLevelId);
36
+ let legacyServedLevels = [];
37
+ if (!hasServiceBounds && elevator.servedLevelIds && elevator.servedLevelIds.length > 0) {
38
+ const servedIds = new Set(elevator.servedLevelIds);
39
+ legacyServedLevels = levels.filter((level) => servedIds.has(level.id));
40
+ }
41
+ const legacyFromLevelId = legacyServedLevels[0]?.id ?? null;
42
+ const legacyToLevelId = legacyServedLevels[legacyServedLevels.length - 1]?.id ?? null;
43
+ const explicitFromIndex = findLevelIndex(levels, elevator.fromLevelId ?? legacyFromLevelId);
44
+ const defaultFromIndex = findLevelIndex(levels, elevator.defaultLevelId);
45
+ const fromIndex = explicitFromIndex >= 0 ? explicitFromIndex : Math.max(defaultFromIndex, 0);
46
+ const toIndex = findLevelIndex(levels, elevator.toLevelId ?? legacyToLevelId);
47
+ const resolvedToIndex = toIndex >= 0 ? toIndex : getDefaultToIndex(levels, fromIndex);
48
+ const minIndex = Math.min(fromIndex, resolvedToIndex);
49
+ const maxIndex = Math.max(fromIndex, resolvedToIndex);
50
+ return levels.slice(minIndex, maxIndex + 1);
51
+ }
52
+ export function getElevatorLevelHeight(levelId, nodes) {
53
+ const level = nodes[levelId];
54
+ if (!level || level.type !== 'level')
55
+ return DEFAULT_ELEVATOR_LEVEL_HEIGHT;
56
+ let maxTop = 0;
57
+ for (const childId of level.children) {
58
+ const child = nodes[childId];
59
+ if (!child)
60
+ continue;
61
+ if (child.type === 'ceiling') {
62
+ const height = child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT;
63
+ if (height > maxTop)
64
+ maxTop = height;
65
+ }
66
+ else if (child.type === 'wall') {
67
+ const height = child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT;
68
+ if (height > maxTop)
69
+ maxTop = height;
70
+ }
71
+ }
72
+ return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT;
73
+ }
74
+ export function resolveElevatorLevels(elevator, nodes) {
75
+ const allLevels = resolveElevatorBuildingLevels(elevator, nodes);
76
+ const baseYByLevelId = new Map();
77
+ let cumulativeY = 0;
78
+ for (const level of allLevels) {
79
+ baseYByLevelId.set(level.id, cumulativeY);
80
+ cumulativeY += getElevatorLevelHeight(level.id, nodes);
81
+ }
82
+ const serviceLevels = resolveElevatorServiceLevels(elevator, nodes);
83
+ const entries = serviceLevels.map((level) => ({
84
+ id: level.id,
85
+ label: String(level.level),
86
+ baseY: baseYByLevelId.get(level.id) ?? 0,
87
+ }));
88
+ const defaultEntry = entries.find((entry) => entry.id === elevator.defaultLevelId) ??
89
+ entries.find((entry) => entry.id === elevator.fromLevelId) ??
90
+ entries[0] ??
91
+ null;
92
+ const firstServedLevel = serviceLevels[0] ?? null;
93
+ const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null;
94
+ const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0;
95
+ const lastServedIndex = lastServedLevel
96
+ ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
97
+ : -1;
98
+ const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null;
99
+ const shaftTopY = nextLevel
100
+ ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
101
+ : lastServedLevel
102
+ ? cumulativeY
103
+ : elevator.cabHeight + 0.3;
104
+ return {
105
+ entries,
106
+ defaultEntry,
107
+ shaftBaseY,
108
+ shaftTopY,
109
+ totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
110
+ };
111
+ }
@@ -0,0 +1,44 @@
1
+ import type { AnyNode, StairNode, StairSegmentNode } from '../../schema';
2
+ /**
3
+ * Stair footprint geometry shared by the slab-opening sync and the
4
+ * alignment-anchor adapters. A stair has no single box footprint: straight
5
+ * stairs are a cumulative chain of `stair-segment` children, curved / spiral
6
+ * stairs are an annular sector stored entirely on the parent. Both reduce to
7
+ * an XZ bounding box here so callers that only need "where does the stair sit
8
+ * in plan" (alignment guides) don't have to re-walk the geometry.
9
+ *
10
+ * All math is in the building-local XZ frame, matching `node.position`.
11
+ */
12
+ export type StairFootprintAABB = {
13
+ minX: number;
14
+ minZ: number;
15
+ maxX: number;
16
+ maxZ: number;
17
+ };
18
+ type SegmentTransform = {
19
+ position: [number, number, number];
20
+ rotation: number;
21
+ };
22
+ /**
23
+ * XZ rotation in the stair geometry convention (equivalent to rotating by
24
+ * `-angle` in standard math): positive `angle` turns local +Z toward +X. Every
25
+ * stair helper — slab openings, floor-plan emitter, this footprint — shares it,
26
+ * so anchors line up with the rendered stair.
27
+ */
28
+ export declare function rotateXZ(x: number, z: number, angle: number): [number, number];
29
+ /**
30
+ * Cumulative per-segment transforms for a straight (segment-chained) stair.
31
+ * Each flight attaches to the previous segment's end; `attachmentSide` rotates
32
+ * the chain ±90° (left / right) or continues straight (front). Positions are in
33
+ * the stair's local frame (before the stair's own `position` / `rotation`).
34
+ */
35
+ export declare function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[];
36
+ /**
37
+ * XZ bounding box of a stair's plan footprint, or null when it can't be
38
+ * determined (a straight stair whose segment children aren't in `nodes`).
39
+ * Straight stairs need the children to walk the flight chain; curved / spiral
40
+ * stairs are derived from the parent alone, so `nodes` is optional for them.
41
+ */
42
+ export declare function stairFootprintAABB(stair: StairNode, nodes?: Readonly<Record<string, AnyNode>>): StairFootprintAABB | null;
43
+ export {};
44
+ //# sourceMappingURL=stair-footprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stair-footprint.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-footprint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAa,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAEnF;;;;;;;;;GASG;AAEH,MAAM,MAAM,kBAAkB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3F,KAAK,gBAAgB,GAAG;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAI9E;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,EAAE,CAkDzF;AA0GD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAChB,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACxC,kBAAkB,GAAG,IAAI,CAK3B"}
@@ -0,0 +1,168 @@
1
+ /**
2
+ * XZ rotation in the stair geometry convention (equivalent to rotating by
3
+ * `-angle` in standard math): positive `angle` turns local +Z toward +X. Every
4
+ * stair helper — slab openings, floor-plan emitter, this footprint — shares it,
5
+ * so anchors line up with the rendered stair.
6
+ */
7
+ export function rotateXZ(x, z, angle) {
8
+ const cos = Math.cos(angle);
9
+ const sin = Math.sin(angle);
10
+ return [x * cos + z * sin, -x * sin + z * cos];
11
+ }
12
+ /**
13
+ * Cumulative per-segment transforms for a straight (segment-chained) stair.
14
+ * Each flight attaches to the previous segment's end; `attachmentSide` rotates
15
+ * the chain ±90° (left / right) or continues straight (front). Positions are in
16
+ * the stair's local frame (before the stair's own `position` / `rotation`).
17
+ */
18
+ export function computeSegmentTransforms(segments) {
19
+ const transforms = [];
20
+ let currentX = 0;
21
+ let currentY = 0;
22
+ let currentZ = 0;
23
+ let currentRot = 0;
24
+ for (let index = 0; index < segments.length; index++) {
25
+ const segment = segments[index];
26
+ if (!segment)
27
+ continue;
28
+ if (index === 0) {
29
+ transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot });
30
+ continue;
31
+ }
32
+ const previous = segments[index - 1];
33
+ if (!previous)
34
+ continue;
35
+ let attachX = 0;
36
+ let attachZ = 0;
37
+ let rotationDelta = 0;
38
+ switch (segment.attachmentSide) {
39
+ case 'front':
40
+ attachX = 0;
41
+ attachZ = previous.length;
42
+ break;
43
+ case 'left':
44
+ attachX = previous.width / 2;
45
+ attachZ = previous.length / 2;
46
+ rotationDelta = Math.PI / 2;
47
+ break;
48
+ case 'right':
49
+ attachX = -previous.width / 2;
50
+ attachZ = previous.length / 2;
51
+ rotationDelta = -Math.PI / 2;
52
+ break;
53
+ }
54
+ const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot);
55
+ currentX += deltaX;
56
+ currentY += previous.height;
57
+ currentZ += deltaZ;
58
+ currentRot += rotationDelta;
59
+ transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot });
60
+ }
61
+ return transforms;
62
+ }
63
+ function emptyBox() {
64
+ return {
65
+ minX: Number.POSITIVE_INFINITY,
66
+ minZ: Number.POSITIVE_INFINITY,
67
+ maxX: Number.NEGATIVE_INFINITY,
68
+ maxZ: Number.NEGATIVE_INFINITY,
69
+ };
70
+ }
71
+ /** Grow `box` to include the world-plan point produced by rotating the
72
+ * stair-local point by the stair's rotation and offsetting by its position. */
73
+ function extendByLocal(box, stair, localX, localZ) {
74
+ const [wx, wz] = rotateXZ(localX, localZ, stair.rotation ?? 0);
75
+ const x = stair.position[0] + wx;
76
+ const z = stair.position[2] + wz;
77
+ if (x < box.minX)
78
+ box.minX = x;
79
+ if (x > box.maxX)
80
+ box.maxX = x;
81
+ if (z < box.minZ)
82
+ box.minZ = z;
83
+ if (z > box.maxZ)
84
+ box.maxZ = z;
85
+ }
86
+ function finiteBox(box) {
87
+ return Number.isFinite(box.minX) && Number.isFinite(box.minZ) ? box : null;
88
+ }
89
+ /** Bounding box of a straight stair's segment chain, walking the children. */
90
+ function straightStairAABB(stair, nodes) {
91
+ const segments = (stair.children ?? [])
92
+ .map((childId) => nodes[childId])
93
+ .filter((segment) => segment?.type === 'stair-segment' && segment.visible !== false);
94
+ if (segments.length === 0)
95
+ return null;
96
+ const transforms = computeSegmentTransforms(segments);
97
+ const box = emptyBox();
98
+ segments.forEach((segment, index) => {
99
+ const transform = transforms[index];
100
+ if (!transform)
101
+ return;
102
+ const halfWidth = segment.width / 2;
103
+ // Segment-local footprint: X across the flight, Z along the run from the
104
+ // attachment edge (0) to the far edge (length).
105
+ for (const [cornerX, cornerZ] of [
106
+ [-halfWidth, 0],
107
+ [halfWidth, 0],
108
+ [halfWidth, segment.length],
109
+ [-halfWidth, segment.length],
110
+ ]) {
111
+ const [offsetX, offsetZ] = rotateXZ(cornerX, cornerZ, transform.rotation);
112
+ extendByLocal(box, stair, transform.position[0] + offsetX, transform.position[2] + offsetZ);
113
+ }
114
+ });
115
+ return finiteBox(box);
116
+ }
117
+ const ARC_SAMPLES = 48;
118
+ /** Bounding box of a curved / spiral stair's annular sector (plus the
119
+ * integrated spiral top landing when present). */
120
+ function arcStairAABB(stair) {
121
+ const isSpiral = stair.stairType === 'spiral';
122
+ const minInnerRadius = isSpiral ? 0.05 : 0.2;
123
+ const innerRadius = Math.max(minInnerRadius, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9));
124
+ const width = Math.max(stair.width ?? 1, 0.4);
125
+ const outerRadius = innerRadius + width;
126
+ let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2);
127
+ // A full revolution would make the arc degenerate; clamp just under 2π the
128
+ // same way the floor-plan emitter does so the sampled box stays correct.
129
+ if (Math.abs(sweep) >= Math.PI * 2)
130
+ sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001);
131
+ const half = sweep / 2;
132
+ const box = emptyBox();
133
+ // Sample both rims across the sweep — the extremes can fall on either the
134
+ // arc ends or an axis crossing in between, so we need the full sweep.
135
+ for (let step = 0; step <= ARC_SAMPLES; step += 1) {
136
+ const angle = -half + (sweep * step) / ARC_SAMPLES;
137
+ const cos = Math.cos(angle);
138
+ const sin = Math.sin(angle);
139
+ extendByLocal(box, stair, cos * innerRadius, sin * innerRadius);
140
+ extendByLocal(box, stair, cos * outerRadius, sin * outerRadius);
141
+ }
142
+ // Integrated spiral top landing — a rectangle hung off the outer rim.
143
+ if (isSpiral && stair.topLandingMode === 'integrated') {
144
+ const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1);
145
+ const halfWidth = width / 2;
146
+ for (const [cornerX, cornerZ] of [
147
+ [outerRadius, -halfWidth],
148
+ [outerRadius + depth, -halfWidth],
149
+ [outerRadius + depth, halfWidth],
150
+ [outerRadius, halfWidth],
151
+ ]) {
152
+ extendByLocal(box, stair, cornerX, cornerZ);
153
+ }
154
+ }
155
+ return finiteBox(box);
156
+ }
157
+ /**
158
+ * XZ bounding box of a stair's plan footprint, or null when it can't be
159
+ * determined (a straight stair whose segment children aren't in `nodes`).
160
+ * Straight stairs need the children to walk the flight chain; curved / spiral
161
+ * stairs are derived from the parent alone, so `nodes` is optional for them.
162
+ */
163
+ export function stairFootprintAABB(stair, nodes) {
164
+ if ((stair.stairType ?? 'straight') === 'straight') {
165
+ return nodes ? straightStairAABB(stair, nodes) : null;
166
+ }
167
+ return arcStairAABB(stair);
168
+ }