@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.
- package/dist/events/bus.d.ts +4 -2
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts +2 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +3 -0
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +11 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -8
- package/dist/materials.d.ts +10 -0
- package/dist/materials.d.ts.map +1 -0
- package/dist/materials.js +22 -0
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/nodes/level.d.ts +1 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +2 -0
- package/dist/schema/nodes/stair-segment.d.ts +81 -0
- package/dist/schema/nodes/stair-segment.d.ts.map +1 -0
- package/dist/schema/nodes/stair-segment.js +42 -0
- package/dist/schema/nodes/stair.d.ts +56 -0
- package/dist/schema/nodes/stair.d.ts.map +1 -0
- package/dist/schema/nodes/stair.js +22 -0
- package/dist/schema/types.d.ts +119 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +4 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +25 -29
- package/dist/store/use-live-transforms.d.ts +14 -0
- package/dist/store/use-live-transforms.d.ts.map +1 -0
- package/dist/store/use-live-transforms.js +20 -0
- package/dist/store/use-scene.d.ts +2 -5
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +25 -15
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +1 -17
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +18 -0
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +71 -26
- package/dist/systems/stair/stair-system.d.ts +2 -0
- package/dist/systems/stair/stair-system.d.ts.map +1 -0
- package/dist/systems/stair/stair-system.js +354 -0
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +2 -0
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +8 -24
- package/dist/utils/clone-scene-graph.d.ts +25 -1
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +160 -5
- 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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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;
|
|
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":"
|
|
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,
|
|
75
|
-
addBox(mesh,
|
|
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,
|
|
78
|
-
addBox(mesh,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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"}
|