@pascal-app/core 0.3.2 → 0.4.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 (54) hide show
  1. package/dist/events/bus.d.ts +4 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/scene-registry.d.ts +2 -0
  4. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  5. package/dist/hooks/scene-registry/scene-registry.js +3 -0
  6. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +1 -1
  7. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  8. package/dist/hooks/spatial-grid/spatial-grid-sync.js +11 -3
  9. package/dist/index.d.ts +6 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -8
  12. package/dist/materials.d.ts +10 -0
  13. package/dist/materials.d.ts.map +1 -0
  14. package/dist/materials.js +22 -0
  15. package/dist/schema/index.d.ts +3 -1
  16. package/dist/schema/index.d.ts.map +1 -1
  17. package/dist/schema/index.js +3 -1
  18. package/dist/schema/nodes/level.d.ts +1 -1
  19. package/dist/schema/nodes/level.d.ts.map +1 -1
  20. package/dist/schema/nodes/level.js +2 -0
  21. package/dist/schema/nodes/stair-segment.d.ts +81 -0
  22. package/dist/schema/nodes/stair-segment.d.ts.map +1 -0
  23. package/dist/schema/nodes/stair-segment.js +42 -0
  24. package/dist/schema/nodes/stair.d.ts +56 -0
  25. package/dist/schema/nodes/stair.d.ts.map +1 -0
  26. package/dist/schema/nodes/stair.js +22 -0
  27. package/dist/schema/types.d.ts +119 -1
  28. package/dist/schema/types.d.ts.map +1 -1
  29. package/dist/schema/types.js +4 -0
  30. package/dist/store/actions/node-actions.d.ts.map +1 -1
  31. package/dist/store/actions/node-actions.js +25 -29
  32. package/dist/store/use-live-transforms.d.ts +14 -0
  33. package/dist/store/use-live-transforms.d.ts.map +1 -0
  34. package/dist/store/use-live-transforms.js +20 -0
  35. package/dist/store/use-scene.d.ts +2 -5
  36. package/dist/store/use-scene.d.ts.map +1 -1
  37. package/dist/store/use-scene.js +25 -15
  38. package/dist/systems/door/door-system.d.ts.map +1 -1
  39. package/dist/systems/door/door-system.js +1 -17
  40. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  41. package/dist/systems/roof/roof-system.js +18 -0
  42. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  43. package/dist/systems/slab/slab-system.js +71 -26
  44. package/dist/systems/stair/stair-system.d.ts +2 -0
  45. package/dist/systems/stair/stair-system.d.ts.map +1 -0
  46. package/dist/systems/stair/stair-system.js +354 -0
  47. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  48. package/dist/systems/wall/wall-system.js +2 -0
  49. package/dist/systems/window/window-system.d.ts.map +1 -1
  50. package/dist/systems/window/window-system.js +8 -24
  51. package/dist/utils/clone-scene-graph.d.ts +25 -1
  52. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  53. package/dist/utils/clone-scene-graph.js +160 -5
  54. package/package.json +6 -1
@@ -34,6 +34,10 @@ function updateSlabGeometry(node, mesh) {
34
34
  const newGeo = generateSlabGeometry(node);
35
35
  mesh.geometry.dispose();
36
36
  mesh.geometry = newGeo;
37
+ // For negative elevation, shift the mesh down so the top face sits at Y=elevation
38
+ // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
39
+ const elevation = node.elevation ?? 0.05;
40
+ mesh.position.y = elevation < 0 ? elevation : 0;
37
41
  }
38
42
  /** Half of default wall thickness — used to extend slab geometry under walls */
39
43
  const SLAB_OUTSET = 0.05;
@@ -89,44 +93,85 @@ function outsetPolygon(polygon, amount) {
89
93
  * Generates extruded slab geometry from polygon
90
94
  */
91
95
  export function generateSlabGeometry(slabNode) {
96
+ const elevation = slabNode.elevation ?? 0.05;
97
+ return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode);
98
+ }
99
+ /**
100
+ * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
101
+ */
102
+ function generatePositiveSlabGeometry(slabNode) {
92
103
  const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET);
93
104
  const elevation = slabNode.elevation ?? 0.05;
94
- if (polygon.length < 3) {
105
+ if (polygon.length < 3)
95
106
  return new THREE.BufferGeometry();
96
- }
97
- // Create shape from polygon
98
- // Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion
99
107
  const shape = new THREE.Shape();
100
- const firstPt = polygon[0];
101
- // Negate Y (which becomes Z) to get correct orientation after rotation
102
- shape.moveTo(firstPt[0], -firstPt[1]);
103
- for (let i = 1; i < polygon.length; i++) {
104
- const pt = polygon[i];
105
- shape.lineTo(pt[0], -pt[1]);
106
- }
108
+ shape.moveTo(polygon[0][0], -polygon[0][1]);
109
+ for (let i = 1; i < polygon.length; i++)
110
+ shape.lineTo(polygon[i][0], -polygon[i][1]);
107
111
  shape.closePath();
108
- // Add holes to the shape
109
- const holes = slabNode.holes || [];
110
- for (const holePolygon of holes) {
112
+ for (const holePolygon of slabNode.holes ?? []) {
111
113
  if (holePolygon.length < 3)
112
114
  continue;
113
115
  const holePath = new THREE.Path();
114
- const holeFirstPt = holePolygon[0];
115
- holePath.moveTo(holeFirstPt[0], -holeFirstPt[1]);
116
- for (let i = 1; i < holePolygon.length; i++) {
117
- const pt = holePolygon[i];
118
- holePath.lineTo(pt[0], -pt[1]);
119
- }
116
+ holePath.moveTo(holePolygon[0][0], -holePolygon[0][1]);
117
+ for (let i = 1; i < holePolygon.length; i++)
118
+ holePath.lineTo(holePolygon[i][0], -holePolygon[i][1]);
120
119
  holePath.closePath();
121
120
  shape.holes.push(holePath);
122
121
  }
123
- // Extrude the shape by elevation
124
- const geometry = new THREE.ExtrudeGeometry(shape, {
125
- depth: elevation,
126
- bevelEnabled: false,
127
- });
128
- // Rotate so extrusion direction (Z) becomes height direction (Y)
122
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false });
129
123
  geometry.rotateX(-Math.PI / 2);
130
124
  geometry.computeVertexNormals();
131
125
  return geometry;
132
126
  }
127
+ /**
128
+ * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
129
+ * No top cap — the opening at ground level is handled by the ground occluder hole.
130
+ * mesh.position.y must be set to elevation so the floor sits at the correct world Y.
131
+ *
132
+ * Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
133
+ * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
134
+ * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
135
+ */
136
+ function generatePoolGeometry(slabNode) {
137
+ const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET);
138
+ const depth = Math.abs(slabNode.elevation ?? 0.05);
139
+ if (polygon.length < 3)
140
+ return new THREE.BufferGeometry();
141
+ const positions = [];
142
+ const indices = [];
143
+ const n = polygon.length;
144
+ // --- Floor at Y=0 ---
145
+ for (const [x, z] of polygon)
146
+ positions.push(x, 0, z);
147
+ const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x, z));
148
+ const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x, z)));
149
+ for (const hole of slabNode.holes ?? []) {
150
+ for (const [x, z] of hole)
151
+ positions.push(x, 0, z);
152
+ }
153
+ const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d);
154
+ for (const tri of floorTris) {
155
+ // Reversed winding → normals point +Y (upward) in XZ plane
156
+ indices.push(tri[0], tri[2], tri[1]);
157
+ }
158
+ // --- Inner walls (no top cap at Y=depth) ---
159
+ // Standard winding on a CCW polygon in XZ gives inward-facing normals.
160
+ for (let i = 0; i < n; i++) {
161
+ const j = (i + 1) % n;
162
+ const [x0, z0] = polygon[i];
163
+ const [x1, z1] = polygon[j];
164
+ const vBase = positions.length / 3;
165
+ positions.push(x0, 0, z0); // v0 — floor level
166
+ positions.push(x1, 0, z1); // v1 — floor level
167
+ positions.push(x1, depth, z1); // v2 — ground level
168
+ positions.push(x0, depth, z0); // v3 — ground level
169
+ indices.push(vBase, vBase + 1, vBase + 2);
170
+ indices.push(vBase, vBase + 2, vBase + 3);
171
+ }
172
+ const geo = new THREE.BufferGeometry();
173
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
174
+ geo.setIndex(indices);
175
+ geo.computeVertexNormals();
176
+ return geo;
177
+ }
@@ -0,0 +1,2 @@
1
+ export declare const StairSystem: () => null;
2
+ //# sourceMappingURL=stair-system.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stair-system.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-system.tsx"],"names":[],"mappings":"AAiBA,eAAO,MAAM,WAAW,YA4FvB,CAAA"}
@@ -0,0 +1,354 @@
1
+ import { useFrame } from '@react-three/fiber';
2
+ import * as THREE from 'three';
3
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
4
+ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
5
+ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager';
6
+ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync';
7
+ import useScene from '../../store/use-scene';
8
+ const pendingStairUpdates = new Set();
9
+ const MAX_STAIRS_PER_FRAME = 2;
10
+ const MAX_SEGMENTS_PER_FRAME = 4;
11
+ // ============================================================================
12
+ // STAIR SYSTEM
13
+ // ============================================================================
14
+ export const StairSystem = () => {
15
+ const dirtyNodes = useScene((state) => state.dirtyNodes);
16
+ const clearDirty = useScene((state) => state.clearDirty);
17
+ const rootNodeIds = useScene((state) => state.rootNodeIds);
18
+ useFrame(() => {
19
+ if (rootNodeIds.length === 0) {
20
+ pendingStairUpdates.clear();
21
+ return;
22
+ }
23
+ if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0)
24
+ return;
25
+ const nodes = useScene.getState().nodes;
26
+ // --- Pass 1: Process dirty stair-segments (throttled) ---
27
+ // Collect parent stair IDs that need segment transform recomputation
28
+ const parentsNeedingSegmentSync = new Set();
29
+ let segmentsProcessed = 0;
30
+ dirtyNodes.forEach((id) => {
31
+ const node = nodes[id];
32
+ if (!node)
33
+ return;
34
+ if (node.type === 'stair-segment') {
35
+ const mesh = sceneRegistry.nodes.get(id);
36
+ if (mesh) {
37
+ const isVisible = mesh.parent?.visible !== false;
38
+ if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
39
+ // Geometry will be updated; chained position is applied in the parent sync pass below
40
+ updateStairSegmentGeometry(node, mesh);
41
+ if (node.parentId)
42
+ parentsNeedingSegmentSync.add(node.parentId);
43
+ segmentsProcessed++;
44
+ }
45
+ else if (isVisible) {
46
+ return; // Over budget — keep dirty, process next frame
47
+ }
48
+ else if (mesh.geometry.type === 'BoxGeometry') {
49
+ // Replace BoxGeometry placeholder with empty geometry
50
+ mesh.geometry.dispose();
51
+ const placeholder = new THREE.BufferGeometry();
52
+ placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
53
+ mesh.geometry = placeholder;
54
+ }
55
+ clearDirty(id);
56
+ }
57
+ else {
58
+ clearDirty(id);
59
+ }
60
+ // Queue the parent stair for a merged geometry update
61
+ if (node.parentId) {
62
+ pendingStairUpdates.add(node.parentId);
63
+ }
64
+ }
65
+ else if (node.type === 'stair') {
66
+ pendingStairUpdates.add(id);
67
+ // Also sync individual segment positions when in edit mode
68
+ parentsNeedingSegmentSync.add(id);
69
+ clearDirty(id);
70
+ }
71
+ });
72
+ // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
73
+ for (const stairId of parentsNeedingSegmentSync) {
74
+ const stairNode = nodes[stairId];
75
+ if (!stairNode || stairNode.type !== 'stair')
76
+ continue;
77
+ const group = sceneRegistry.nodes.get(stairId);
78
+ if (group) {
79
+ syncStairGroupElevation(stairNode, group, nodes);
80
+ }
81
+ syncSegmentMeshTransforms(stairNode, nodes);
82
+ }
83
+ // --- Pass 2: Process pending merged-stair updates (throttled) ---
84
+ let stairsProcessed = 0;
85
+ for (const id of pendingStairUpdates) {
86
+ if (stairsProcessed >= MAX_STAIRS_PER_FRAME)
87
+ break;
88
+ const node = nodes[id];
89
+ if (!node || node.type !== 'stair') {
90
+ pendingStairUpdates.delete(id);
91
+ continue;
92
+ }
93
+ const group = sceneRegistry.nodes.get(id);
94
+ if (group) {
95
+ const mergedMesh = group.getObjectByName('merged-stair');
96
+ if (mergedMesh?.visible !== false) {
97
+ updateMergedStairGeometry(node, group, nodes);
98
+ stairsProcessed++;
99
+ }
100
+ }
101
+ pendingStairUpdates.delete(id);
102
+ }
103
+ }, 5);
104
+ return null;
105
+ };
106
+ // ============================================================================
107
+ // SEGMENT GEOMETRY
108
+ // ============================================================================
109
+ /**
110
+ * Generates the step/landing profile as a THREE.Shape (in the XY plane),
111
+ * then extrudes along Z for the segment width.
112
+ */
113
+ function generateStairSegmentGeometry(segment, absoluteHeight) {
114
+ const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment;
115
+ const shape = new THREE.Shape();
116
+ if (segmentType === 'landing') {
117
+ shape.moveTo(0, 0);
118
+ shape.lineTo(length, 0);
119
+ if (fillToFloor) {
120
+ shape.lineTo(length, -absoluteHeight);
121
+ shape.lineTo(0, -absoluteHeight);
122
+ }
123
+ else {
124
+ shape.lineTo(length, -thickness);
125
+ shape.lineTo(0, -thickness);
126
+ }
127
+ }
128
+ else {
129
+ const riserHeight = height / stepCount;
130
+ const treadDepth = length / stepCount;
131
+ shape.moveTo(0, 0);
132
+ // Draw step profile
133
+ for (let i = 0; i < stepCount; i++) {
134
+ shape.lineTo(i * treadDepth, (i + 1) * riserHeight);
135
+ shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight);
136
+ }
137
+ if (fillToFloor) {
138
+ shape.lineTo(length, -absoluteHeight);
139
+ shape.lineTo(0, -absoluteHeight);
140
+ }
141
+ else {
142
+ // Sloped bottom with consistent thickness
143
+ const angle = Math.atan(riserHeight / treadDepth);
144
+ const vOff = thickness / Math.cos(angle);
145
+ // Bottom-back corner
146
+ shape.lineTo(length, height - vOff);
147
+ if (absoluteHeight === 0) {
148
+ // Ground floor: slope hits the ground (y=0)
149
+ const m = riserHeight / treadDepth;
150
+ const xGround = length - (height - vOff) / m;
151
+ if (xGround > 0) {
152
+ shape.lineTo(xGround, 0);
153
+ }
154
+ }
155
+ else {
156
+ // Floating: parallel slope
157
+ shape.lineTo(0, -vOff);
158
+ }
159
+ }
160
+ }
161
+ shape.lineTo(0, 0);
162
+ const geometry = new THREE.ExtrudeGeometry(shape, {
163
+ steps: 1,
164
+ depth: width,
165
+ bevelEnabled: false,
166
+ });
167
+ // Rotate so extrusion is along X (width), and the shape is in the XZ plane
168
+ // Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset
169
+ const matrix = new THREE.Matrix4();
170
+ matrix.makeRotationY(-Math.PI / 2);
171
+ matrix.setPosition(width / 2, 0, 0);
172
+ geometry.applyMatrix4(matrix);
173
+ return geometry;
174
+ }
175
+ function updateStairSegmentGeometry(node, mesh) {
176
+ // Compute absolute height from parent chain
177
+ const absoluteHeight = computeAbsoluteHeight(node);
178
+ const newGeometry = generateStairSegmentGeometry(node, absoluteHeight);
179
+ mesh.geometry.dispose();
180
+ mesh.geometry = newGeometry;
181
+ // NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms
182
+ // which computes the chained position based on segment order and attachmentSide.
183
+ }
184
+ /**
185
+ * Applies chained transforms to individual segment meshes (edit mode).
186
+ * Each segment's world position is determined by the chain of previous segments,
187
+ * not by the node's stored position field.
188
+ */
189
+ function syncSegmentMeshTransforms(stairNode, nodes) {
190
+ const segments = (stairNode.children ?? [])
191
+ .map((childId) => nodes[childId])
192
+ .filter((n) => n?.type === 'stair-segment');
193
+ if (segments.length === 0)
194
+ return;
195
+ const transforms = computeSegmentTransforms(segments);
196
+ for (let i = 0; i < segments.length; i++) {
197
+ const segment = segments[i];
198
+ const transform = transforms[i];
199
+ const mesh = sceneRegistry.nodes.get(segment.id);
200
+ if (mesh) {
201
+ mesh.position.set(transform.position[0], transform.position[1], transform.position[2]);
202
+ mesh.rotation.y = transform.rotation;
203
+ }
204
+ }
205
+ }
206
+ function syncStairGroupElevation(stairNode, group, nodes) {
207
+ const levelId = resolveLevelId(stairNode, nodes);
208
+ const slabElevation = getStairSlabElevation(levelId, stairNode, nodes);
209
+ group.position.y = stairNode.position[1] + slabElevation;
210
+ }
211
+ function getStairSlabElevation(levelId, stairNode, nodes) {
212
+ const segments = (stairNode.children ?? [])
213
+ .map((childId) => nodes[childId])
214
+ .filter((n) => n?.type === 'stair-segment');
215
+ if (segments.length === 0)
216
+ return 0;
217
+ const transforms = computeSegmentTransforms(segments);
218
+ let maxElevation = Number.NEGATIVE_INFINITY;
219
+ for (let i = 0; i < segments.length; i++) {
220
+ const segment = segments[i];
221
+ const transform = transforms[i];
222
+ const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
223
+ const centerInGroupX = transform.position[0] + centerOffsetX;
224
+ const centerInGroupZ = transform.position[2] + centerOffsetZ;
225
+ const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(centerInGroupX, centerInGroupZ, stairNode.rotation);
226
+ const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, [
227
+ stairNode.position[0] + centerOffsetWorldX,
228
+ stairNode.position[1] + transform.position[1],
229
+ stairNode.position[2] + centerOffsetWorldZ,
230
+ ], [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], [0, stairNode.rotation + transform.rotation, 0]);
231
+ if (slabElevation > maxElevation) {
232
+ maxElevation = slabElevation;
233
+ }
234
+ }
235
+ return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation;
236
+ }
237
+ // ============================================================================
238
+ // MERGED STAIR GEOMETRY
239
+ // ============================================================================
240
+ const _matrix = new THREE.Matrix4();
241
+ const _position = new THREE.Vector3();
242
+ const _quaternion = new THREE.Quaternion();
243
+ const _scale = new THREE.Vector3(1, 1, 1);
244
+ const _yAxis = new THREE.Vector3(0, 1, 0);
245
+ function updateMergedStairGeometry(stairNode, group, nodes) {
246
+ const mergedMesh = group.getObjectByName('merged-stair');
247
+ if (!mergedMesh)
248
+ return;
249
+ const children = stairNode.children ?? [];
250
+ const segments = children
251
+ .map((childId) => nodes[childId])
252
+ .filter((n) => n?.type === 'stair-segment');
253
+ if (segments.length === 0) {
254
+ mergedMesh.geometry.dispose();
255
+ mergedMesh.geometry = new THREE.BufferGeometry();
256
+ mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
257
+ return;
258
+ }
259
+ // Compute chained transforms for segments
260
+ const transforms = computeSegmentTransforms(segments);
261
+ const geometries = [];
262
+ for (let i = 0; i < segments.length; i++) {
263
+ const segment = segments[i];
264
+ const transform = transforms[i];
265
+ const absoluteHeight = transform.position[1];
266
+ const geo = generateStairSegmentGeometry(segment, absoluteHeight);
267
+ // Apply segment transform (position + rotation) relative to parent stair
268
+ _position.set(transform.position[0], transform.position[1], transform.position[2]);
269
+ _quaternion.setFromAxisAngle(_yAxis, transform.rotation);
270
+ _matrix.compose(_position, _quaternion, _scale);
271
+ geo.applyMatrix4(_matrix);
272
+ geometries.push(geo);
273
+ }
274
+ const merged = mergeGeometries(geometries, false);
275
+ if (merged) {
276
+ mergedMesh.geometry.dispose();
277
+ mergedMesh.geometry = merged;
278
+ }
279
+ // Dispose individual geometries
280
+ for (const geo of geometries) {
281
+ geo.dispose();
282
+ }
283
+ }
284
+ /**
285
+ * Computes world-relative transforms for each segment by chaining
286
+ * based on attachmentSide. This mirrors the prototype's StairSystem logic.
287
+ */
288
+ function computeSegmentTransforms(segments) {
289
+ const transforms = [];
290
+ let currentPos = new THREE.Vector3(0, 0, 0);
291
+ let currentRot = 0;
292
+ for (let i = 0; i < segments.length; i++) {
293
+ const segment = segments[i];
294
+ if (i === 0) {
295
+ transforms.push({
296
+ position: [currentPos.x, currentPos.y, currentPos.z],
297
+ rotation: currentRot,
298
+ });
299
+ }
300
+ else {
301
+ const prev = segments[i - 1];
302
+ const localAttachPos = new THREE.Vector3();
303
+ let rotChange = 0;
304
+ switch (segment.attachmentSide) {
305
+ case 'front':
306
+ localAttachPos.set(0, prev.height, prev.length);
307
+ rotChange = 0;
308
+ break;
309
+ case 'left':
310
+ localAttachPos.set(prev.width / 2, prev.height, prev.length / 2);
311
+ rotChange = Math.PI / 2;
312
+ break;
313
+ case 'right':
314
+ localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2);
315
+ rotChange = -Math.PI / 2;
316
+ break;
317
+ }
318
+ // Rotate local attachment point by previous global rotation
319
+ localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot);
320
+ currentPos = currentPos.clone().add(localAttachPos);
321
+ currentRot += rotChange;
322
+ transforms.push({
323
+ position: [currentPos.x, currentPos.y, currentPos.z],
324
+ rotation: currentRot,
325
+ });
326
+ }
327
+ }
328
+ return transforms;
329
+ }
330
+ function rotateXZ(x, z, angle) {
331
+ const cos = Math.cos(angle);
332
+ const sin = Math.sin(angle);
333
+ return [x * cos + z * sin, -x * sin + z * cos];
334
+ }
335
+ /**
336
+ * Computes the absolute Y height of a segment by traversing the stair's segment chain.
337
+ */
338
+ function computeAbsoluteHeight(node) {
339
+ const nodes = useScene.getState().nodes;
340
+ if (!node.parentId)
341
+ return 0;
342
+ const parent = nodes[node.parentId];
343
+ if (!parent || parent.type !== 'stair')
344
+ return 0;
345
+ const stair = parent;
346
+ const segments = (stair.children ?? [])
347
+ .map((childId) => nodes[childId])
348
+ .filter((n) => n?.type === 'stair-segment');
349
+ const transforms = computeSegmentTransforms(segments);
350
+ const index = segments.findIndex((s) => s.id === node.id);
351
+ if (index < 0)
352
+ return 0;
353
+ return transforms[index]?.position[1] ?? 0;
354
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"wall-system.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAM9B,OAAO,KAAK,EAAE,OAAO,EAAa,QAAQ,EAAE,MAAM,cAAc,CAAA;AAGhE,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAA;AASxB,eAAO,MAAM,UAAU,YAsDtB,CAAA;AA0DD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,OAAO,EAAE,EACxB,SAAS,EAAE,aAAa,EACxB,aAAa,SAAI,oFA+FlB"}
1
+ {"version":3,"file":"wall-system.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAM9B,OAAO,KAAK,EAAE,OAAO,EAAa,QAAQ,EAAE,MAAM,cAAc,CAAA;AAGhE,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAA;AAUxB,eAAO,MAAM,UAAU,YAuDtB,CAAA;AA0DD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,OAAO,EAAE,EACxB,SAAS,EAAE,aAAa,EACxB,aAAa,SAAI,oFA+FlB"}
@@ -13,6 +13,7 @@ const csgEvaluator = new Evaluator();
13
13
  // ============================================================================
14
14
  // WALL SYSTEM
15
15
  // ============================================================================
16
+ let useFrameNb = 0;
16
17
  export const WallSystem = () => {
17
18
  const dirtyNodes = useScene((state) => state.dirtyNodes);
18
19
  const clearDirty = useScene((state) => state.clearDirty);
@@ -22,6 +23,7 @@ export const WallSystem = () => {
22
23
  const nodes = useScene.getState().nodes;
23
24
  // Collect dirty walls and their levels
24
25
  const dirtyWallsByLevel = new Map();
26
+ useFrameNb += 1;
25
27
  dirtyNodes.forEach((id) => {
26
28
  const node = nodes[id];
27
29
  if (!node || node.type !== 'wall')
@@ -1 +1 @@
1
- {"version":3,"file":"window-system.d.ts","sourceRoot":"","sources":["../../../src/systems/window/window-system.tsx"],"names":[],"mappings":"AA4BA,eAAO,MAAM,YAAY,YA2BxB,CAAA"}
1
+ {"version":3,"file":"window-system.d.ts","sourceRoot":"","sources":["../../../src/systems/window/window-system.tsx"],"names":[],"mappings":"AAUA,eAAO,MAAM,YAAY,YA2BxB,CAAA"}
@@ -1,24 +1,8 @@
1
1
  import { useFrame } from '@react-three/fiber';
2
2
  import * as THREE from 'three';
3
- import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu';
4
3
  import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
4
+ import { baseMaterial, glassMaterial } from '../../materials';
5
5
  import useScene from '../../store/use-scene';
6
- const glassMaterial = new MeshStandardNodeMaterial({
7
- name: 'glass',
8
- color: 'lightblue',
9
- roughness: 0.05,
10
- metalness: 0.1,
11
- transparent: true,
12
- opacity: 0.3,
13
- side: DoubleSide,
14
- depthWrite: false,
15
- });
16
- const frameMaterial = new MeshStandardNodeMaterial({
17
- name: 'window-frame',
18
- color: '#e8e8e8',
19
- roughness: 0.6,
20
- metalness: 0,
21
- });
22
6
  // Invisible material for root mesh — used as selection hitbox only
23
7
  const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
24
8
  export const WindowSystem = () => {
@@ -71,11 +55,11 @@ function updateWindowMesh(node, mesh) {
71
55
  const innerH = height - 2 * frameThickness;
72
56
  // ── Frame members ──
73
57
  // Top / bottom — full width
74
- addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
75
- addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
58
+ addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
59
+ addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
76
60
  // Left / right — inner height to avoid corner overlap
77
- addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
78
- addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
61
+ addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
62
+ addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
79
63
  // ── Pane grid ──
80
64
  const numCols = columnRatios.length;
81
65
  const numRows = rowRatios.length;
@@ -107,7 +91,7 @@ function updateWindowMesh(node, mesh) {
107
91
  cx = -innerW / 2;
108
92
  for (let c = 0; c < numCols - 1; c++) {
109
93
  cx += colWidths[c];
110
- addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0);
94
+ addBox(mesh, baseMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0);
111
95
  cx += columnDividerThickness;
112
96
  }
113
97
  // Row dividers — per column width, so they don't overlap column dividers (top to bottom)
@@ -116,7 +100,7 @@ function updateWindowMesh(node, mesh) {
116
100
  cy -= rowHeights[r];
117
101
  const divY = cy - rowDividerThickness / 2;
118
102
  for (let c = 0; c < numCols; c++) {
119
- addBox(mesh, frameMaterial, colWidths[c], rowDividerThickness, frameDepth, colXCenters[c], divY, 0);
103
+ addBox(mesh, baseMaterial, colWidths[c], rowDividerThickness, frameDepth, colXCenters[c], divY, 0);
120
104
  }
121
105
  cy -= rowDividerThickness;
122
106
  }
@@ -132,7 +116,7 @@ function updateWindowMesh(node, mesh) {
132
116
  const sillW = width + sillDepth * 0.4; // slightly wider than frame
133
117
  // Protrudes from the front face of the frame (+Z)
134
118
  const sillZ = frameDepth / 2 + sillDepth / 2;
135
- addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
119
+ addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
136
120
  }
137
121
  // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
138
122
  let cutout = mesh.getObjectByName('cutout');
@@ -10,9 +10,33 @@ export type SceneGraph = {
10
10
  * parent-child relationships and other internal references.
11
11
  *
12
12
  * This is useful for:
13
+ * - Duplicating a project (host app creates a new project record, then loads the cloned scene)
13
14
  * - Copying nodes between different projects
14
- * - Duplicating a subset of a scene within the same project
15
15
  * - Multi-scene in-memory scenarios
16
16
  */
17
17
  export declare function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph;
18
+ /**
19
+ * Deep clones a level node and all its descendants with fresh IDs.
20
+ * All internal references (parentId, children, wallId) are remapped to the new IDs.
21
+ * The cloned level node's parentId is preserved (building ID) — not remapped.
22
+ *
23
+ * Unlike `cloneSceneGraph` (which operates on serialized data), this function works
24
+ * on live runtime nodes that may have non-serializable properties (Three.js objects,
25
+ * etc.). It uses JSON roundtrip to safely strip them.
26
+ *
27
+ * @returns clonedNodes - flat array of all cloned nodes (level + descendants)
28
+ * @returns newLevelId - the ID of the cloned level node
29
+ * @returns idMap - old ID → new ID mapping
30
+ */
31
+ export declare function cloneLevelSubtree(nodes: Record<AnyNodeId, AnyNode>, levelId: AnyNodeId): {
32
+ clonedNodes: AnyNode[];
33
+ newLevelId: AnyNodeId;
34
+ idMap: Map<string, string>;
35
+ };
36
+ /**
37
+ * Forks a scene graph for use as a new project: clones with new IDs and strips
38
+ * scan and guide nodes (and their references) since those contain user-uploaded
39
+ * imagery that shouldn't carry over to a forked project.
40
+ */
41
+ export declare function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph;
18
42
  //# sourceMappingURL=clone-scene-graph.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"clone-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/clone-scene-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAErE,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IACjC,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC,YAAY,EAAE,UAAU,CAAC,CAAA;CAC/C,CAAA;AAUD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CA4FlE"}
1
+ {"version":3,"file":"clone-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/clone-scene-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAErE,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IACjC,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC,YAAY,EAAE,UAAU,CAAC,CAAA;CAC/C,CAAA;AAUD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CAuGlE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EACjC,OAAO,EAAE,SAAS,GACjB;IAAE,WAAW,EAAE,OAAO,EAAE,CAAC;IAAC,UAAU,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CA8E/E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CAgEjE"}