@pascal-app/core 0.6.0 → 0.8.0

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 (120) hide show
  1. package/dist/events/bus.d.ts +45 -6
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/events/bus.js +1 -1
  4. package/dist/hooks/scene-registry/scene-registry.d.ts +2 -0
  5. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  6. package/dist/hooks/scene-registry/scene-registry.js +2 -0
  7. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  8. package/dist/hooks/spatial-grid/spatial-grid-manager.js +164 -6
  9. package/dist/index.d.ts +8 -15
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +7 -14
  12. package/dist/lib/door-operation.d.ts +7 -0
  13. package/dist/lib/door-operation.d.ts.map +1 -0
  14. package/dist/lib/door-operation.js +25 -0
  15. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  16. package/dist/lib/slab-polygon.d.ts +3 -0
  17. package/dist/lib/slab-polygon.d.ts.map +1 -0
  18. package/dist/lib/slab-polygon.js +58 -0
  19. package/dist/lib/space-detection.d.ts.map +1 -1
  20. package/dist/lib/space-detection.js +10 -8
  21. package/dist/material-library.d.ts +5 -3
  22. package/dist/material-library.d.ts.map +1 -1
  23. package/dist/material-library.js +28 -32
  24. package/dist/schema/asset-url.d.ts +34 -0
  25. package/dist/schema/asset-url.d.ts.map +1 -0
  26. package/dist/schema/asset-url.js +79 -0
  27. package/dist/schema/asset-url.test.d.ts +2 -0
  28. package/dist/schema/asset-url.test.d.ts.map +1 -0
  29. package/dist/schema/asset-url.test.js +134 -0
  30. package/dist/schema/index.d.ts +7 -5
  31. package/dist/schema/index.d.ts.map +1 -1
  32. package/dist/schema/index.js +5 -3
  33. package/dist/schema/material.d.ts +1 -0
  34. package/dist/schema/material.d.ts.map +1 -1
  35. package/dist/schema/material.js +13 -11
  36. package/dist/schema/nodes/column.d.ts +520 -0
  37. package/dist/schema/nodes/column.d.ts.map +1 -0
  38. package/dist/schema/nodes/column.js +385 -0
  39. package/dist/schema/nodes/door.d.ts +72 -0
  40. package/dist/schema/nodes/door.d.ts.map +1 -1
  41. package/dist/schema/nodes/door.js +39 -2
  42. package/dist/schema/nodes/fence.d.ts +1 -1
  43. package/dist/schema/nodes/fence.js +2 -2
  44. package/dist/schema/nodes/guide.d.ts +17 -0
  45. package/dist/schema/nodes/guide.d.ts.map +1 -1
  46. package/dist/schema/nodes/guide.js +11 -1
  47. package/dist/schema/nodes/item.d.ts +20 -0
  48. package/dist/schema/nodes/item.d.ts.map +1 -1
  49. package/dist/schema/nodes/item.js +30 -1
  50. package/dist/schema/nodes/level.d.ts +1 -1
  51. package/dist/schema/nodes/level.d.ts.map +1 -1
  52. package/dist/schema/nodes/level.js +6 -0
  53. package/dist/schema/nodes/roof-segment.d.ts +2 -2
  54. package/dist/schema/nodes/roof.d.ts +2 -2
  55. package/dist/schema/nodes/roof.d.ts.map +1 -1
  56. package/dist/schema/nodes/roof.js +5 -5
  57. package/dist/schema/nodes/scan.d.ts.map +1 -1
  58. package/dist/schema/nodes/scan.js +2 -1
  59. package/dist/schema/nodes/site.d.ts +7 -0
  60. package/dist/schema/nodes/site.d.ts.map +1 -1
  61. package/dist/schema/nodes/spawn.d.ts +24 -0
  62. package/dist/schema/nodes/spawn.d.ts.map +1 -0
  63. package/dist/schema/nodes/spawn.js +8 -0
  64. package/dist/schema/nodes/stair.d.ts +6 -6
  65. package/dist/schema/nodes/stair.d.ts.map +1 -1
  66. package/dist/schema/nodes/stair.js +9 -7
  67. package/dist/schema/nodes/window.d.ts +55 -0
  68. package/dist/schema/nodes/window.d.ts.map +1 -1
  69. package/dist/schema/nodes/window.js +29 -0
  70. package/dist/schema/types.d.ts +320 -5
  71. package/dist/schema/types.d.ts.map +1 -1
  72. package/dist/schema/types.js +4 -0
  73. package/dist/store/actions/node-actions.d.ts.map +1 -1
  74. package/dist/store/actions/node-actions.js +13 -10
  75. package/dist/store/use-interactive.d.ts +43 -0
  76. package/dist/store/use-interactive.d.ts.map +1 -1
  77. package/dist/store/use-interactive.js +66 -0
  78. package/dist/store/use-scene.d.ts.map +1 -1
  79. package/dist/store/use-scene.js +69 -5
  80. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  81. package/dist/systems/stair/stair-opening-sync.js +41 -7
  82. package/dist/systems/stair/stair-opening-sync.test.d.ts +2 -0
  83. package/dist/systems/stair/stair-opening-sync.test.d.ts.map +1 -0
  84. package/dist/systems/stair/stair-opening-sync.test.js +63 -0
  85. package/dist/systems/wall/wall-curve.d.ts +1 -1
  86. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  87. package/dist/systems/wall/wall-curve.js +1 -1
  88. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  89. package/dist/systems/wall/wall-mitering.js +2 -6
  90. package/package.json +34 -5
  91. package/dist/materials.d.ts +0 -10
  92. package/dist/materials.d.ts.map +0 -1
  93. package/dist/materials.js +0 -22
  94. package/dist/systems/ceiling/ceiling-system.d.ts +0 -8
  95. package/dist/systems/ceiling/ceiling-system.d.ts.map +0 -1
  96. package/dist/systems/ceiling/ceiling-system.js +0 -92
  97. package/dist/systems/door/door-system.d.ts +0 -2
  98. package/dist/systems/door/door-system.d.ts.map +0 -1
  99. package/dist/systems/door/door-system.js +0 -195
  100. package/dist/systems/fence/fence-system.d.ts +0 -2
  101. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  102. package/dist/systems/fence/fence-system.js +0 -187
  103. package/dist/systems/item/item-system.d.ts +0 -2
  104. package/dist/systems/item/item-system.d.ts.map +0 -1
  105. package/dist/systems/item/item-system.js +0 -48
  106. package/dist/systems/roof/roof-system.d.ts +0 -16
  107. package/dist/systems/roof/roof-system.d.ts.map +0 -1
  108. package/dist/systems/roof/roof-system.js +0 -797
  109. package/dist/systems/slab/slab-system.d.ts +0 -8
  110. package/dist/systems/slab/slab-system.d.ts.map +0 -1
  111. package/dist/systems/slab/slab-system.js +0 -214
  112. package/dist/systems/stair/stair-system.d.ts +0 -2
  113. package/dist/systems/stair/stair-system.d.ts.map +0 -1
  114. package/dist/systems/stair/stair-system.js +0 -776
  115. package/dist/systems/wall/wall-system.d.ts +0 -12
  116. package/dist/systems/wall/wall-system.d.ts.map +0 -1
  117. package/dist/systems/wall/wall-system.js +0 -455
  118. package/dist/systems/window/window-system.d.ts +0 -2
  119. package/dist/systems/window/window-system.d.ts.map +0 -1
  120. package/dist/systems/window/window-system.js +0 -131
@@ -1,776 +0,0 @@
1
- import { useFrame } from '@react-three/fiber';
2
- import { useEffect, useRef } from 'react';
3
- import * as THREE from 'three';
4
- import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
5
- import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
6
- import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager';
7
- import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync';
8
- import useScene from '../../store/use-scene';
9
- import { syncAutoStairOpenings } from './stair-opening-sync';
10
- const pendingStairUpdates = new Set();
11
- const MAX_STAIRS_PER_FRAME = 2;
12
- const MAX_SEGMENTS_PER_FRAME = 4;
13
- const STAIR_TREAD_MATERIAL_INDEX = 0;
14
- const STAIR_SIDE_MATERIAL_INDEX = 1;
15
- const _uvPosition = new THREE.Vector3();
16
- const _uvNormal = new THREE.Vector3();
17
- // ============================================================================
18
- // STAIR SYSTEM
19
- // ============================================================================
20
- export const StairSystem = () => {
21
- const dirtyNodes = useScene((state) => state.dirtyNodes);
22
- const clearDirty = useScene((state) => state.clearDirty);
23
- const rootNodeIds = useScene((state) => state.rootNodeIds);
24
- const syncingAutoOpeningsRef = useRef(false);
25
- useEffect(() => {
26
- const applyUpdates = (updates) => {
27
- if (updates.length === 0)
28
- return;
29
- syncingAutoOpeningsRef.current = true;
30
- useScene.getState().updateNodes(updates);
31
- queueMicrotask(() => {
32
- syncingAutoOpeningsRef.current = false;
33
- });
34
- };
35
- applyUpdates(syncAutoStairOpenings(useScene.getState().nodes));
36
- return useScene.subscribe((state, prevState) => {
37
- if (syncingAutoOpeningsRef.current)
38
- return;
39
- if (state.nodes === prevState.nodes)
40
- return;
41
- applyUpdates(syncAutoStairOpenings(state.nodes));
42
- });
43
- }, []);
44
- useFrame(() => {
45
- if (rootNodeIds.length === 0) {
46
- pendingStairUpdates.clear();
47
- return;
48
- }
49
- if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0)
50
- return;
51
- const nodes = useScene.getState().nodes;
52
- // --- Pass 1: Process dirty stair-segments (throttled) ---
53
- // Collect parent stair IDs that need segment transform recomputation
54
- const parentsNeedingSegmentSync = new Set();
55
- let segmentsProcessed = 0;
56
- dirtyNodes.forEach((id) => {
57
- const node = nodes[id];
58
- if (!node)
59
- return;
60
- if (node.type === 'stair-segment') {
61
- const mesh = sceneRegistry.nodes.get(id);
62
- if (mesh) {
63
- const isVisible = mesh.parent?.visible !== false;
64
- if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
65
- // Geometry will be updated; chained position is applied in the parent sync pass below
66
- updateStairSegmentGeometry(node, mesh);
67
- if (node.parentId)
68
- parentsNeedingSegmentSync.add(node.parentId);
69
- segmentsProcessed++;
70
- }
71
- else if (isVisible) {
72
- return; // Over budget — keep dirty, process next frame
73
- }
74
- else if (mesh.geometry.type === 'BoxGeometry') {
75
- // Replace BoxGeometry placeholder with empty geometry
76
- mesh.geometry.dispose();
77
- const placeholder = new THREE.BufferGeometry();
78
- placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
79
- mesh.geometry = placeholder;
80
- }
81
- clearDirty(id);
82
- }
83
- else {
84
- clearDirty(id);
85
- }
86
- // Queue the parent stair for a merged geometry update
87
- if (node.parentId) {
88
- pendingStairUpdates.add(node.parentId);
89
- }
90
- }
91
- else if (node.type === 'stair') {
92
- pendingStairUpdates.add(id);
93
- // Also sync individual segment positions when in edit mode
94
- parentsNeedingSegmentSync.add(id);
95
- clearDirty(id);
96
- }
97
- });
98
- // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
99
- for (const stairId of parentsNeedingSegmentSync) {
100
- const stairNode = nodes[stairId];
101
- if (!stairNode || stairNode.type !== 'stair')
102
- continue;
103
- const group = sceneRegistry.nodes.get(stairId);
104
- if (group) {
105
- syncStairGroupElevation(stairNode, group, nodes);
106
- }
107
- syncSegmentMeshTransforms(stairNode, nodes);
108
- }
109
- // --- Pass 2: Process pending merged-stair updates (throttled) ---
110
- let stairsProcessed = 0;
111
- for (const id of pendingStairUpdates) {
112
- if (stairsProcessed >= MAX_STAIRS_PER_FRAME)
113
- break;
114
- const node = nodes[id];
115
- if (!node || node.type !== 'stair') {
116
- pendingStairUpdates.delete(id);
117
- continue;
118
- }
119
- const group = sceneRegistry.nodes.get(id);
120
- if (group) {
121
- const mergedMesh = group.getObjectByName('merged-stair');
122
- if (mergedMesh?.visible !== false) {
123
- updateMergedStairGeometry(node, group, nodes);
124
- stairsProcessed++;
125
- }
126
- }
127
- pendingStairUpdates.delete(id);
128
- }
129
- }, 5);
130
- return null;
131
- };
132
- // ============================================================================
133
- // SEGMENT GEOMETRY
134
- // ============================================================================
135
- /**
136
- * Generates the step/landing profile as a THREE.Shape (in the XY plane),
137
- * then extrudes along Z for the segment width.
138
- */
139
- function generateStairSegmentGeometry(segment, absoluteHeight) {
140
- const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment;
141
- const shape = new THREE.Shape();
142
- if (segmentType === 'landing') {
143
- shape.moveTo(0, 0);
144
- shape.lineTo(length, 0);
145
- if (fillToFloor) {
146
- shape.lineTo(length, -absoluteHeight);
147
- shape.lineTo(0, -absoluteHeight);
148
- }
149
- else {
150
- shape.lineTo(length, -thickness);
151
- shape.lineTo(0, -thickness);
152
- }
153
- }
154
- else {
155
- const riserHeight = height / stepCount;
156
- const treadDepth = length / stepCount;
157
- shape.moveTo(0, 0);
158
- // Draw step profile
159
- for (let i = 0; i < stepCount; i++) {
160
- shape.lineTo(i * treadDepth, (i + 1) * riserHeight);
161
- shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight);
162
- }
163
- if (fillToFloor) {
164
- shape.lineTo(length, -absoluteHeight);
165
- shape.lineTo(0, -absoluteHeight);
166
- }
167
- else {
168
- // Sloped bottom with consistent thickness
169
- const angle = Math.atan(riserHeight / treadDepth);
170
- const vOff = thickness / Math.cos(angle);
171
- // Bottom-back corner
172
- shape.lineTo(length, height - vOff);
173
- if (absoluteHeight === 0) {
174
- // Ground floor: slope hits the ground (y=0)
175
- const m = riserHeight / treadDepth;
176
- const xGround = length - (height - vOff) / m;
177
- if (xGround > 0) {
178
- shape.lineTo(xGround, 0);
179
- }
180
- }
181
- else {
182
- // Floating: parallel slope
183
- shape.lineTo(0, -vOff);
184
- }
185
- }
186
- }
187
- shape.lineTo(0, 0);
188
- const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
189
- steps: 1,
190
- depth: width,
191
- bevelEnabled: false,
192
- });
193
- // Rotate so extrusion is along X (width), and the shape is in the XZ plane
194
- // Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset
195
- const matrix = new THREE.Matrix4();
196
- matrix.makeRotationY(-Math.PI / 2);
197
- matrix.setPosition(width / 2, 0, 0);
198
- extrudedGeometry.applyMatrix4(matrix);
199
- extrudedGeometry.computeVertexNormals();
200
- const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry;
201
- if (geometry !== extrudedGeometry) {
202
- extrudedGeometry.dispose();
203
- }
204
- applyStairSegmentUvs(geometry);
205
- ensureUv2Attribute(geometry);
206
- return geometry;
207
- }
208
- function updateStairSegmentGeometry(node, mesh) {
209
- // Compute absolute height from parent chain
210
- const absoluteHeight = computeAbsoluteHeight(node);
211
- const newGeometry = generateStairSegmentGeometry(node, absoluteHeight);
212
- applyStraightStairMaterialGroups(newGeometry);
213
- mesh.geometry.dispose();
214
- mesh.geometry = newGeometry;
215
- // NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms
216
- // which computes the chained position based on segment order and attachmentSide.
217
- }
218
- /**
219
- * Applies chained transforms to individual segment meshes (edit mode).
220
- * Each segment's world position is determined by the chain of previous segments,
221
- * not by the node's stored position field.
222
- */
223
- function syncSegmentMeshTransforms(stairNode, nodes) {
224
- const segments = (stairNode.children ?? [])
225
- .map((childId) => nodes[childId])
226
- .filter((n) => n?.type === 'stair-segment');
227
- if (segments.length === 0)
228
- return;
229
- const transforms = computeSegmentTransforms(segments);
230
- for (let i = 0; i < segments.length; i++) {
231
- const segment = segments[i];
232
- const transform = transforms[i];
233
- const mesh = sceneRegistry.nodes.get(segment.id);
234
- if (mesh) {
235
- mesh.position.set(transform.position[0], transform.position[1], transform.position[2]);
236
- mesh.rotation.y = transform.rotation;
237
- }
238
- }
239
- }
240
- function syncStairGroupElevation(stairNode, group, nodes) {
241
- const levelId = resolveLevelId(stairNode, nodes);
242
- const slabElevation = getStairSlabElevation(levelId, stairNode, nodes);
243
- group.position.y = stairNode.position[1] + slabElevation;
244
- }
245
- function getStairSlabElevation(levelId, stairNode, nodes) {
246
- const segments = (stairNode.children ?? [])
247
- .map((childId) => nodes[childId])
248
- .filter((n) => n?.type === 'stair-segment');
249
- if (segments.length === 0)
250
- return 0;
251
- const transforms = computeSegmentTransforms(segments);
252
- let maxElevation = Number.NEGATIVE_INFINITY;
253
- for (let i = 0; i < segments.length; i++) {
254
- const segment = segments[i];
255
- const transform = transforms[i];
256
- const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
257
- const centerInGroupX = transform.position[0] + centerOffsetX;
258
- const centerInGroupZ = transform.position[2] + centerOffsetZ;
259
- const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(centerInGroupX, centerInGroupZ, stairNode.rotation);
260
- const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, [
261
- stairNode.position[0] + centerOffsetWorldX,
262
- stairNode.position[1] + transform.position[1],
263
- stairNode.position[2] + centerOffsetWorldZ,
264
- ], [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], [0, stairNode.rotation + transform.rotation, 0]);
265
- if (slabElevation > maxElevation) {
266
- maxElevation = slabElevation;
267
- }
268
- }
269
- return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation;
270
- }
271
- // ============================================================================
272
- // MERGED STAIR GEOMETRY
273
- // ============================================================================
274
- const _matrix = new THREE.Matrix4();
275
- const _position = new THREE.Vector3();
276
- const _quaternion = new THREE.Quaternion();
277
- const _scale = new THREE.Vector3(1, 1, 1);
278
- const _yAxis = new THREE.Vector3(0, 1, 0);
279
- function updateMergedStairGeometry(stairNode, group, nodes) {
280
- const mergedMesh = group.getObjectByName('merged-stair');
281
- if (!mergedMesh)
282
- return;
283
- if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') {
284
- replaceMeshGeometry(mergedMesh, createEmptyGeometry());
285
- return;
286
- }
287
- const children = stairNode.children ?? [];
288
- const segments = children
289
- .map((childId) => nodes[childId])
290
- .filter((n) => n?.type === 'stair-segment');
291
- if (segments.length === 0) {
292
- replaceMeshGeometry(mergedMesh, createEmptyGeometry());
293
- return;
294
- }
295
- // Compute chained transforms for segments
296
- const transforms = computeSegmentTransforms(segments);
297
- const geometries = [];
298
- for (let i = 0; i < segments.length; i++) {
299
- const segment = segments[i];
300
- const transform = transforms[i];
301
- const absoluteHeight = transform.position[1];
302
- const geo = generateStairSegmentGeometry(segment, absoluteHeight);
303
- // Apply segment transform (position + rotation) relative to parent stair
304
- _position.set(transform.position[0], transform.position[1], transform.position[2]);
305
- _quaternion.setFromAxisAngle(_yAxis, transform.rotation);
306
- _matrix.compose(_position, _quaternion, _scale);
307
- geo.applyMatrix4(_matrix);
308
- geometries.push(geo);
309
- }
310
- const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry();
311
- applyStraightStairMaterialGroups(merged);
312
- replaceMeshGeometry(mergedMesh, merged);
313
- // Dispose individual geometries
314
- for (const geo of geometries) {
315
- geo.dispose();
316
- }
317
- }
318
- function applyStraightStairMaterialGroups(geometry) {
319
- const position = geometry.getAttribute('position');
320
- if (!position || position.count < 3) {
321
- geometry.clearGroups();
322
- return;
323
- }
324
- const index = geometry.getIndex();
325
- const triangleCount = index ? index.count / 3 : position.count / 3;
326
- if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
327
- geometry.clearGroups();
328
- return;
329
- }
330
- const triangleMaterials = new Array(triangleCount);
331
- const v0 = new THREE.Vector3();
332
- const v1 = new THREE.Vector3();
333
- const v2 = new THREE.Vector3();
334
- const edge1 = new THREE.Vector3();
335
- const edge2 = new THREE.Vector3();
336
- const normal = new THREE.Vector3();
337
- for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
338
- const vertexOffset = triangleIndex * 3;
339
- const a = index ? index.getX(vertexOffset) : vertexOffset;
340
- const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1;
341
- const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2;
342
- v0.fromBufferAttribute(position, a);
343
- v1.fromBufferAttribute(position, b);
344
- v2.fromBufferAttribute(position, c);
345
- edge1.subVectors(v1, v0);
346
- edge2.subVectors(v2, v0);
347
- normal.crossVectors(edge1, edge2);
348
- triangleMaterials[triangleIndex] =
349
- normal.lengthSq() > 0 && normal.normalize().y > 0.75
350
- ? STAIR_TREAD_MATERIAL_INDEX
351
- : STAIR_SIDE_MATERIAL_INDEX;
352
- }
353
- geometry.clearGroups();
354
- let currentMaterial = triangleMaterials[0];
355
- let groupStart = 0;
356
- for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
357
- const materialIndex = triangleMaterials[triangleIndex];
358
- if (materialIndex === currentMaterial)
359
- continue;
360
- geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial);
361
- groupStart = triangleIndex;
362
- currentMaterial = materialIndex;
363
- }
364
- geometry.addGroup(groupStart * 3, (triangleMaterials.length - groupStart) * 3, currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX);
365
- }
366
- function applyStairSegmentUvs(geometry) {
367
- const position = geometry.getAttribute('position');
368
- const normal = geometry.getAttribute('normal');
369
- if (!position || !normal || position.count === 0) {
370
- geometry.deleteAttribute('uv');
371
- return;
372
- }
373
- const uv = [];
374
- for (let index = 0; index < position.count; index++) {
375
- _uvPosition.fromBufferAttribute(position, index);
376
- _uvNormal.fromBufferAttribute(normal, index).normalize();
377
- const absX = Math.abs(_uvNormal.x);
378
- const absY = Math.abs(_uvNormal.y);
379
- const absZ = Math.abs(_uvNormal.z);
380
- if (absY >= absX && absY >= absZ) {
381
- uv.push(_uvPosition.x, _uvPosition.z);
382
- }
383
- else if (absX >= absZ) {
384
- uv.push(_uvPosition.z, _uvPosition.y);
385
- }
386
- else {
387
- uv.push(_uvPosition.x, _uvPosition.y);
388
- }
389
- }
390
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
391
- }
392
- function ensureUv2Attribute(geometry) {
393
- const uv = geometry.getAttribute('uv');
394
- if (!uv)
395
- return;
396
- geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2));
397
- }
398
- /**
399
- * Computes world-relative transforms for each segment by chaining
400
- * based on attachmentSide. This mirrors the prototype's StairSystem logic.
401
- */
402
- function computeSegmentTransforms(segments) {
403
- const transforms = [];
404
- let currentPos = new THREE.Vector3(0, 0, 0);
405
- let currentRot = 0;
406
- for (let i = 0; i < segments.length; i++) {
407
- const segment = segments[i];
408
- if (i === 0) {
409
- transforms.push({
410
- position: [currentPos.x, currentPos.y, currentPos.z],
411
- rotation: currentRot,
412
- });
413
- }
414
- else {
415
- const prev = segments[i - 1];
416
- const localAttachPos = new THREE.Vector3();
417
- let rotChange = 0;
418
- switch (segment.attachmentSide) {
419
- case 'front':
420
- localAttachPos.set(0, prev.height, prev.length);
421
- rotChange = 0;
422
- break;
423
- case 'left':
424
- localAttachPos.set(prev.width / 2, prev.height, prev.length / 2);
425
- rotChange = Math.PI / 2;
426
- break;
427
- case 'right':
428
- localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2);
429
- rotChange = -Math.PI / 2;
430
- break;
431
- }
432
- // Rotate local attachment point by previous global rotation
433
- localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot);
434
- currentPos = currentPos.clone().add(localAttachPos);
435
- currentRot += rotChange;
436
- transforms.push({
437
- position: [currentPos.x, currentPos.y, currentPos.z],
438
- rotation: currentRot,
439
- });
440
- }
441
- }
442
- return transforms;
443
- }
444
- function rotateXZ(x, z, angle) {
445
- const cos = Math.cos(angle);
446
- const sin = Math.sin(angle);
447
- return [x * cos + z * sin, -x * sin + z * cos];
448
- }
449
- function createEmptyGeometry() {
450
- const geometry = new THREE.BufferGeometry();
451
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
452
- geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX);
453
- geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX);
454
- return geometry;
455
- }
456
- function replaceMeshGeometry(mesh, geometry) {
457
- mesh.geometry.dispose();
458
- mesh.geometry = geometry;
459
- }
460
- function generateStairRailingGeometry(stairNode, segments, transforms) {
461
- const railingMode = stairNode.railingMode ?? 'none';
462
- if (railingMode === 'none') {
463
- return createEmptyGeometry();
464
- }
465
- const railHeight = Math.max(0.5, stairNode.railingHeight ?? 0.92);
466
- const midRailHeight = Math.max(railHeight * 0.45, 0.35);
467
- const railRadius = 0.022;
468
- const postRadius = 0.018;
469
- const inset = 0.06;
470
- const landingInset = 0.08;
471
- const geometries = [];
472
- const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset);
473
- for (const segmentRailPath of segmentRailPaths) {
474
- for (const sidePath of segmentRailPath.sidePaths) {
475
- const points = sidePath.points;
476
- if (points.length === 0)
477
- continue;
478
- geometries.push(...buildBalusterGeometries(points, railHeight, postRadius));
479
- geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius));
480
- geometries.push(...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8));
481
- }
482
- }
483
- for (let index = 1; index < segmentRailPaths.length; index++) {
484
- const previousPath = segmentRailPaths[index - 1];
485
- const currentPath = segmentRailPaths[index];
486
- if (!(previousPath && currentPath && currentPath.connectFromPrevious))
487
- continue;
488
- if (previousPath.segment.segmentType === 'landing')
489
- continue;
490
- for (const sidePath of currentPath.sidePaths) {
491
- if (currentPath.segment.segmentType === 'landing')
492
- continue;
493
- const currentPoint = sidePath.points[0];
494
- if (!currentPoint)
495
- continue;
496
- const previousSidePath = [...previousPath.sidePaths]
497
- .map((entry) => ({
498
- entry,
499
- distance: entry.points.length
500
- ? entry.points[entry.points.length - 1].distanceTo(currentPoint)
501
- : Number.POSITIVE_INFINITY,
502
- }))
503
- .sort((left, right) => left.distance - right.distance)[0]?.entry;
504
- const previousPoint = previousSidePath && previousSidePath.points.length > 0
505
- ? previousSidePath.points[previousSidePath.points.length - 1]
506
- : null;
507
- if (!(previousPoint && currentPoint))
508
- continue;
509
- const connectorPoints = [previousPoint, currentPoint];
510
- geometries.push(...buildOffsetRailSegmentGeometries(connectorPoints, railHeight, railRadius));
511
- geometries.push(...buildOffsetRailSegmentGeometries(connectorPoints, midRailHeight, railRadius * 0.8));
512
- }
513
- }
514
- const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry();
515
- for (const geometry of geometries) {
516
- geometry.dispose();
517
- }
518
- return merged;
519
- }
520
- function buildStairRailPaths(segments, transforms, railingMode, inset, landingInset) {
521
- const layouts = computeStairRailLayouts(segments, transforms);
522
- if (railingMode === 'both') {
523
- const isStraightLineDoubleLandingLayout = segments.length === 4 &&
524
- segments[0]?.segmentType === 'stair' &&
525
- segments[1]?.segmentType === 'landing' &&
526
- segments[2]?.segmentType === 'stair' &&
527
- segments[2]?.attachmentSide === 'front' &&
528
- segments[3]?.segmentType === 'landing' &&
529
- segments[3]?.attachmentSide === 'front';
530
- return layouts.map((layout, index) => {
531
- const segment = layout.segment;
532
- const previousSegment = index > 0 ? segments[index - 1] : undefined;
533
- const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined;
534
- const hideLandingRailing = segment.segmentType === 'landing' &&
535
- previousSegment?.segmentType === 'stair' &&
536
- nextSegment?.segmentType === 'stair';
537
- const visualTurnSide = nextSegment?.attachmentSide;
538
- const sideCandidates = hideLandingRailing
539
- ? visualTurnSide === 'left'
540
- ? ['front', 'right']
541
- : visualTurnSide === 'right'
542
- ? ['front', 'left']
543
- : ['left', 'right']
544
- : segment.segmentType === 'landing'
545
- ? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
546
- ? ['front', 'right']
547
- : nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
548
- ? ['front', 'left']
549
- : visualTurnSide === 'left'
550
- ? ['right']
551
- : visualTurnSide === 'right'
552
- ? ['left']
553
- : ['left', 'right']
554
- : ['left', 'right'];
555
- const sidePaths = sideCandidates
556
- .map((side) => buildSegmentRailPath(layout, side, previousSegment, nextSegment, inset, landingInset))
557
- .filter((entry) => entry !== null);
558
- return {
559
- segment,
560
- sidePaths: isStraightLineDoubleLandingLayout && index === 1
561
- ? (['left', 'right']
562
- .map((side) => buildSegmentRailPath(layout, side, previousSegment, nextSegment, inset, landingInset))
563
- .filter((entry) => entry !== null))
564
- : sidePaths,
565
- connectFromPrevious: index > 0 &&
566
- !(previousSegment?.segmentType === 'landing' && segment.segmentType === 'landing'),
567
- };
568
- });
569
- }
570
- const isStraightLineDoubleLandingLayout = segments.length === 4 &&
571
- segments[0]?.segmentType === 'stair' &&
572
- segments[1]?.segmentType === 'landing' &&
573
- segments[2]?.segmentType === 'stair' &&
574
- segments[2]?.attachmentSide === 'front' &&
575
- segments[3]?.segmentType === 'landing' &&
576
- segments[3]?.attachmentSide === 'front';
577
- const resolved = [];
578
- layouts.forEach((layout, index) => {
579
- const segment = layout.segment;
580
- const previousSegment = index > 0 ? segments[index - 1] : undefined;
581
- const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined;
582
- const nextAttachmentSide = nextSegment?.attachmentSide;
583
- const isMiddleLandingBetweenFlights = segment.segmentType === 'landing' &&
584
- previousSegment?.segmentType === 'stair' &&
585
- nextSegment?.segmentType === 'stair';
586
- const suppressLandingRailing = segment.segmentType === 'landing' &&
587
- nextSegment?.segmentType === 'landing' &&
588
- nextAttachmentSide === railingMode;
589
- const landingContinuesOnPreferredSide = segment.segmentType === 'landing'
590
- ? nextAttachmentSide == null ||
591
- nextAttachmentSide === 'front' ||
592
- nextAttachmentSide === railingMode
593
- : true;
594
- const sidePaths = suppressLandingRailing
595
- ? []
596
- : segment.segmentType !== 'landing'
597
- ? [
598
- buildSegmentRailPath(layout, railingMode, previousSegment, nextSegment, inset, landingInset),
599
- ]
600
- : isStraightLineDoubleLandingLayout
601
- ? [
602
- buildSegmentRailPath(layout, railingMode, previousSegment, nextSegment, inset, landingInset),
603
- ]
604
- : isMiddleLandingBetweenFlights && railingMode === 'left'
605
- ? nextAttachmentSide === 'right'
606
- ? [
607
- buildSegmentRailPath(layout, 'front', previousSegment, nextSegment, inset, landingInset),
608
- buildSegmentRailPath(layout, 'left', previousSegment, nextSegment, inset, landingInset),
609
- ]
610
- : []
611
- : isMiddleLandingBetweenFlights && railingMode === 'right'
612
- ? nextAttachmentSide === 'left'
613
- ? [
614
- buildSegmentRailPath(layout, 'front', previousSegment, nextSegment, inset, landingInset),
615
- buildSegmentRailPath(layout, 'right', previousSegment, nextSegment, inset, landingInset),
616
- ]
617
- : []
618
- : nextSegment?.segmentType === 'landing' &&
619
- nextAttachmentSide != null &&
620
- nextAttachmentSide !== 'front' &&
621
- nextAttachmentSide !== railingMode
622
- ? [
623
- buildSegmentRailPath(layout, 'front', previousSegment, nextSegment, inset, landingInset),
624
- buildSegmentRailPath(layout, railingMode, previousSegment, nextSegment, inset, landingInset),
625
- ]
626
- : [
627
- buildSegmentRailPath(layout, railingMode, previousSegment, nextSegment, inset, landingInset),
628
- ];
629
- resolved.push({
630
- segment,
631
- sidePaths: sidePaths.filter((entry) => entry !== null),
632
- connectFromPrevious: index > 0 &&
633
- !suppressLandingRailing &&
634
- sidePaths.length > 0 &&
635
- (segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true),
636
- });
637
- });
638
- return resolved;
639
- }
640
- function computeStairRailLayouts(segments, transforms) {
641
- return segments.map((segment, index) => {
642
- const transform = transforms[index];
643
- const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
644
- return {
645
- center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ],
646
- elevation: transform.position[1],
647
- rotation: transform.rotation,
648
- segment,
649
- };
650
- });
651
- }
652
- function buildSegmentRailPath(layout, side, previousSegment, nextSegment, inset, landingInset) {
653
- const segment = layout.segment;
654
- const segmentSteps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount);
655
- const segmentStepDepth = segment.length / segmentSteps;
656
- const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps;
657
- const segmentTopThickness = getSegmentTopThickness(segment);
658
- const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045;
659
- const flightStartX = previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2;
660
- const flightEndX = nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2;
661
- if (segment.segmentType === 'landing') {
662
- return buildLandingRailPathFromScratch(layout, side, previousSegment, nextSegment, segmentTopThickness, landingInset);
663
- }
664
- return {
665
- side,
666
- points: [
667
- ...(previousSegment?.segmentType === 'landing'
668
- ? []
669
- : [
670
- toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset),
671
- ]),
672
- ...Array.from({ length: segmentSteps }).map((_, index) => toRailLayoutWorldPoint(layout, -segment.length / 2 + segmentStepDepth * index + segmentStepDepth / 2, segmentStepHeight * (index + 1), flightSideOffset)),
673
- ...(nextSegment?.segmentType === 'landing'
674
- ? []
675
- : [
676
- toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset),
677
- ]),
678
- ],
679
- };
680
- }
681
- function buildLandingRailPathFromScratch(layout, side, previousSegment, nextSegment, topY, inset) {
682
- const segment = layout.segment;
683
- const backX = -segment.length / 2 + inset;
684
- const frontX = segment.length / 2 - inset;
685
- const leftZ = segment.width / 2 - inset;
686
- const rightZ = -segment.width / 2 + inset;
687
- const edgePoints = side === 'left'
688
- ? [
689
- toRailLayoutWorldPoint(layout, backX, topY, leftZ),
690
- toRailLayoutWorldPoint(layout, frontX, topY, leftZ),
691
- ]
692
- : side === 'right'
693
- ? [
694
- toRailLayoutWorldPoint(layout, backX, topY, rightZ),
695
- toRailLayoutWorldPoint(layout, frontX, topY, rightZ),
696
- ]
697
- : [
698
- // When the next flight turns, rail the visible leading edge nearest the turn opening.
699
- toRailLayoutWorldPoint(layout, previousSegment?.segmentType === 'stair' &&
700
- nextSegment?.attachmentSide &&
701
- nextSegment.attachmentSide !== 'front'
702
- ? backX
703
- : frontX, topY, leftZ),
704
- toRailLayoutWorldPoint(layout, previousSegment?.segmentType === 'stair' &&
705
- nextSegment?.attachmentSide &&
706
- nextSegment.attachmentSide !== 'front'
707
- ? backX
708
- : frontX, topY, rightZ),
709
- ];
710
- return {
711
- side,
712
- points: edgePoints,
713
- };
714
- }
715
- function toRailLayoutWorldPoint(layout, localX, localY, localZ) {
716
- const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation);
717
- return new THREE.Vector3(layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ);
718
- }
719
- function buildOffsetRailSegmentGeometries(points, heightOffset, radius) {
720
- const geometries = [];
721
- for (let index = 0; index < points.length - 1; index++) {
722
- const start = points[index];
723
- const end = points[index + 1];
724
- if (!(start && end))
725
- continue;
726
- const segmentGeometry = createCylinderBetweenPoints(start.clone().add(new THREE.Vector3(0, heightOffset, 0)), end.clone().add(new THREE.Vector3(0, heightOffset, 0)), radius, 8);
727
- if (segmentGeometry) {
728
- geometries.push(segmentGeometry);
729
- }
730
- }
731
- return geometries;
732
- }
733
- function buildBalusterGeometries(points, height, radius) {
734
- const geometries = [];
735
- for (const point of points) {
736
- const geometry = new THREE.CylinderGeometry(radius, radius, Math.max(height, 0.05), 8);
737
- geometry.translate(point.x, point.y + height / 2, point.z);
738
- geometries.push(geometry);
739
- }
740
- return geometries;
741
- }
742
- function getSegmentTopThickness(segment) {
743
- return Math.max(segment.thickness ?? 0.25, 0.02);
744
- }
745
- function createCylinderBetweenPoints(start, end, radius, radialSegments) {
746
- const direction = new THREE.Vector3().subVectors(end, start);
747
- const length = direction.length();
748
- if (length <= 1e-5)
749
- return null;
750
- const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
751
- const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize());
752
- const geometry = new THREE.CylinderGeometry(radius, radius, length, radialSegments);
753
- geometry.applyQuaternion(quaternion);
754
- geometry.translate(midpoint.x, midpoint.y, midpoint.z);
755
- return geometry;
756
- }
757
- /**
758
- * Computes the absolute Y height of a segment by traversing the stair's segment chain.
759
- */
760
- function computeAbsoluteHeight(node) {
761
- const nodes = useScene.getState().nodes;
762
- if (!node.parentId)
763
- return 0;
764
- const parent = nodes[node.parentId];
765
- if (!parent || parent.type !== 'stair')
766
- return 0;
767
- const stair = parent;
768
- const segments = (stair.children ?? [])
769
- .map((childId) => nodes[childId])
770
- .filter((n) => n?.type === 'stair-segment');
771
- const transforms = computeSegmentTransforms(segments);
772
- const index = segments.findIndex((s) => s.id === node.id);
773
- if (index < 0)
774
- return 0;
775
- return transforms[index]?.position[1] ?? 0;
776
- }