@pascal-app/core 1.0.0-beta.3 → 1.0.0-beta.5
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/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/room-topology-index.d.ts +50 -0
- package/dist/lib/room-topology-index.d.ts.map +1 -0
- package/dist/lib/room-topology-index.js +237 -0
- package/dist/lib/space-detection.d.ts +29 -3
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +858 -242
- package/dist/registry/index.d.ts +3 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +2 -1
- package/dist/registry/registry.d.ts +17 -1
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +74 -0
- package/dist/registry/types.d.ts +35 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry/use-registry-version.d.ts +11 -0
- package/dist/registry/use-registry-version.d.ts.map +1 -0
- package/dist/registry/use-registry-version.js +15 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -0
- package/dist/schema/nodes/level.d.ts +3 -0
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +9 -0
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/storey.d.ts +3 -10
- package/dist/services/storey.d.ts.map +1 -1
- package/dist/services/storey.js +45 -15
- package/dist/store/actions/node-actions.d.ts +8 -4
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +31 -4
- package/dist/store/history-control.d.ts +5 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +68 -6
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +86 -172
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-service.js +16 -11
- package/dist/systems/slab/slab-support.d.ts +0 -31
- package/dist/systems/slab/slab-support.d.ts.map +1 -1
- package/dist/systems/slab/slab-support.js +26 -4
- package/dist/systems/stair/stair-opening-preview.d.ts +1 -0
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-rise.d.ts.map +1 -1
- package/dist/systems/stair/stair-rise.js +4 -2
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +88 -9
- package/dist/systems/wall/wall-topology.d.ts +53 -0
- package/dist/systems/wall/wall-topology.d.ts.map +1 -0
- package/dist/systems/wall/wall-topology.js +414 -0
- package/dist/utils/clone-scene-graph.d.ts +2 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +13 -2
- package/dist/utils/heal-scene-graph.d.ts +5 -0
- package/dist/utils/heal-scene-graph.d.ts.map +1 -1
- package/dist/utils/heal-scene-graph.js +46 -1
- package/dist/utils/scene-migrations.d.ts +4 -0
- package/dist/utils/scene-migrations.d.ts.map +1 -0
- package/dist/utils/scene-migrations.js +7 -0
- package/dist/utils/vertical-scene-migration.d.ts +13 -0
- package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
- package/dist/utils/vertical-scene-migration.js +179 -0
- package/package.json +4 -4
|
@@ -52,6 +52,49 @@ function pointOnWallSegment(point, wall, tolerance = TOLERANCE) {
|
|
|
52
52
|
const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2);
|
|
53
53
|
return dist < tolerance;
|
|
54
54
|
}
|
|
55
|
+
// --- Uniform grid used to prefilter T-junction candidates --------------------
|
|
56
|
+
// 2 m cells: small enough that a dense imported floor spreads across many
|
|
57
|
+
// buckets, large enough that an ordinary room wall touches only a few.
|
|
58
|
+
const JUNCTION_GRID_CELL = 2.0;
|
|
59
|
+
// A wall whose AABB would touch more than this many cells (a very long diagonal)
|
|
60
|
+
// is kept in a fallback list checked against every junction. Such walls are rare,
|
|
61
|
+
// and a model made only of them is a model with very few walls — where the naive
|
|
62
|
+
// scan was never the problem.
|
|
63
|
+
const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64;
|
|
64
|
+
function cellKey(x, y) {
|
|
65
|
+
return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}`;
|
|
66
|
+
}
|
|
67
|
+
function buildJunctionGrid(walls) {
|
|
68
|
+
const grid = new Map();
|
|
69
|
+
const oversized = [];
|
|
70
|
+
for (const wall of walls) {
|
|
71
|
+
// Pad by TOLERANCE so a point sitting exactly on the AABB edge still lands
|
|
72
|
+
// in a covered cell.
|
|
73
|
+
const minX = Math.min(wall.start[0], wall.end[0]) - TOLERANCE;
|
|
74
|
+
const maxX = Math.max(wall.start[0], wall.end[0]) + TOLERANCE;
|
|
75
|
+
const minY = Math.min(wall.start[1], wall.end[1]) - TOLERANCE;
|
|
76
|
+
const maxY = Math.max(wall.start[1], wall.end[1]) + TOLERANCE;
|
|
77
|
+
const cx0 = Math.floor(minX / JUNCTION_GRID_CELL);
|
|
78
|
+
const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL);
|
|
79
|
+
const cy0 = Math.floor(minY / JUNCTION_GRID_CELL);
|
|
80
|
+
const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL);
|
|
81
|
+
if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) {
|
|
82
|
+
oversized.push(wall);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (let cx = cx0; cx <= cx1; cx++) {
|
|
86
|
+
for (let cy = cy0; cy <= cy1; cy++) {
|
|
87
|
+
const key = `${cx},${cy}`;
|
|
88
|
+
const bucket = grid.get(key);
|
|
89
|
+
if (bucket)
|
|
90
|
+
bucket.push(wall);
|
|
91
|
+
else
|
|
92
|
+
grid.set(key, [wall]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { grid, oversized };
|
|
97
|
+
}
|
|
55
98
|
function findJunctions(walls) {
|
|
56
99
|
const junctions = new Map();
|
|
57
100
|
// First pass: group walls by their endpoints
|
|
@@ -69,17 +112,47 @@ function findJunctions(walls) {
|
|
|
69
112
|
}
|
|
70
113
|
junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' });
|
|
71
114
|
}
|
|
72
|
-
// Second pass: detect T-junctions (walls passing through junction points)
|
|
115
|
+
// Second pass: detect T-junctions (walls passing through junction points).
|
|
116
|
+
//
|
|
117
|
+
// The naive form of this pass is `for each junction: for each wall` — O(J×N).
|
|
118
|
+
// On a real imported floor (1081 walls, 2047 endpoint keys) that is ~2.2M
|
|
119
|
+
// pointOnWallSegment calls and measured 584 ms per findJunctions() call, which
|
|
120
|
+
// WallSystem then repeats every frame while progressively rebuilding.
|
|
121
|
+
//
|
|
122
|
+
// A T-junction can only exist where the junction point lies ON the wall
|
|
123
|
+
// segment, so it must lie inside the wall's AABB. Bucketing walls by the grid
|
|
124
|
+
// cells their AABB covers therefore loses nothing: the cell containing the
|
|
125
|
+
// point is always one of the cells the wall was indexed into. With the input
|
|
126
|
+
// ordering restored below, the result matches the naive pass exactly; measured
|
|
127
|
+
// 11 ms on the same geometry.
|
|
128
|
+
const { grid, oversized } = buildJunctionGrid(walls);
|
|
129
|
+
const wallOrder = new Map(walls.map((wall, index) => [wall.id, index]));
|
|
73
130
|
for (const [_key, junction] of junctions.entries()) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
131
|
+
const p = junction.meetingPoint;
|
|
132
|
+
const cellCandidates = grid.get(cellKey(p.x, p.y));
|
|
133
|
+
const passthrough = [];
|
|
134
|
+
for (const bucket of [cellCandidates, oversized]) {
|
|
135
|
+
if (!bucket || bucket.length === 0)
|
|
77
136
|
continue;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
junction.connectedWalls.
|
|
137
|
+
for (const wall of bucket) {
|
|
138
|
+
// Skip if wall already in this junction
|
|
139
|
+
if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id))
|
|
140
|
+
continue;
|
|
141
|
+
// Check if junction point lies on this wall's segment (not at endpoints)
|
|
142
|
+
if (pointOnWallSegment(junction.meetingPoint, wall)) {
|
|
143
|
+
passthrough.push(wall);
|
|
144
|
+
}
|
|
81
145
|
}
|
|
82
146
|
}
|
|
147
|
+
// Append in input order, not bucket order. Two collinear walls overlapping a
|
|
148
|
+
// junction tie on angle in `calculateJunctionIntersections`, so its stable
|
|
149
|
+
// sort leaves them in the order they were appended here — and an oversized
|
|
150
|
+
// wall would otherwise land after a shorter collinear neighbour it precedes
|
|
151
|
+
// in `walls`, picking the other wall's thickness for the miter.
|
|
152
|
+
passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0));
|
|
153
|
+
for (const wall of passthrough) {
|
|
154
|
+
junction.connectedWalls.push({ wall, endType: 'passthrough' });
|
|
155
|
+
}
|
|
83
156
|
}
|
|
84
157
|
// Filter to only junctions with 2+ walls
|
|
85
158
|
const actualJunctions = new Map();
|
|
@@ -186,8 +259,14 @@ function calculateJunctionIntersections(junction, getThickness) {
|
|
|
186
259
|
});
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
|
-
// Sort by outgoing angle
|
|
190
|
-
|
|
262
|
+
// Sort by outgoing angle, then by wall ID so equal-angle walls produce the
|
|
263
|
+
// same pairing regardless of scene iteration order.
|
|
264
|
+
processedWalls.sort((a, b) => {
|
|
265
|
+
const angleOrder = a.angle - b.angle;
|
|
266
|
+
if (angleOrder !== 0)
|
|
267
|
+
return angleOrder;
|
|
268
|
+
return a.wallId < b.wallId ? -1 : a.wallId > b.wallId ? 1 : 0;
|
|
269
|
+
});
|
|
191
270
|
const wallIntersections = new Map();
|
|
192
271
|
const n = processedWalls.length;
|
|
193
272
|
if (n < 2)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, type WallNode } from '../../schema';
|
|
2
|
+
import type { WallPlanPoint } from './wall-move';
|
|
3
|
+
export type WallTopologyChanges = {
|
|
4
|
+
create: Array<{
|
|
5
|
+
node: AnyNode;
|
|
6
|
+
parentId?: AnyNodeId;
|
|
7
|
+
}>;
|
|
8
|
+
update: Array<{
|
|
9
|
+
id: AnyNodeId;
|
|
10
|
+
data: Partial<AnyNode>;
|
|
11
|
+
}>;
|
|
12
|
+
delete: AnyNodeId[];
|
|
13
|
+
};
|
|
14
|
+
export type WallInsertionPlan = {
|
|
15
|
+
changes: WallTopologyChanges;
|
|
16
|
+
insertedWalls: WallNode[];
|
|
17
|
+
terminalWallId: WallNode['id'];
|
|
18
|
+
resolvedStart: WallPlanPoint;
|
|
19
|
+
resolvedEnd: WallPlanPoint;
|
|
20
|
+
};
|
|
21
|
+
export type WallTopologyRejection = {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: 'covered-existing-wall' | 'segment-too-short';
|
|
24
|
+
};
|
|
25
|
+
export type WallInsertionResult = {
|
|
26
|
+
ok: true;
|
|
27
|
+
plan: WallInsertionPlan;
|
|
28
|
+
} | WallTopologyRejection;
|
|
29
|
+
export type WallPointSplitPlan = {
|
|
30
|
+
changes: WallTopologyChanges;
|
|
31
|
+
point: WallPlanPoint;
|
|
32
|
+
};
|
|
33
|
+
export type WallPointSplitResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
plan: WallPointSplitPlan;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: 'no-host';
|
|
39
|
+
};
|
|
40
|
+
export declare function planWallSplitAtPoint(nodes: Record<AnyNodeId, AnyNode>, args: {
|
|
41
|
+
levelId: AnyNodeId | null;
|
|
42
|
+
point: WallPlanPoint;
|
|
43
|
+
radius: number;
|
|
44
|
+
ignoreWallIds?: readonly string[];
|
|
45
|
+
}): WallPointSplitResult;
|
|
46
|
+
export declare function planWallInsertion(nodes: Record<AnyNodeId, AnyNode>, args: {
|
|
47
|
+
levelId: AnyNodeId;
|
|
48
|
+
start: WallPlanPoint;
|
|
49
|
+
end: WallPlanPoint;
|
|
50
|
+
joinRadius: number;
|
|
51
|
+
wallDefaults?: Partial<WallNode>;
|
|
52
|
+
}): WallInsertionResult;
|
|
53
|
+
//# sourceMappingURL=wall-topology.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wall-topology.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-topology.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAId,KAAK,QAAQ,EAGd,MAAM,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAMhD,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;KAAE,CAAC,CAAA;IACtD,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,SAAS,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,CAAC,CAAA;IACxD,MAAM,EAAE,SAAS,EAAE,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,mBAAmB,CAAA;IAC5B,aAAa,EAAE,QAAQ,EAAE,CAAA;IACzB,cAAc,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC9B,aAAa,EAAE,aAAa,CAAA;IAC5B,WAAW,EAAE,aAAa,CAAA;CAC3B,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,KAAK,CAAA;IACT,MAAM,EAAE,uBAAuB,GAAG,mBAAmB,CAAA;CACtD,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,GAAG,qBAAqB,CAAA;AAE/F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,EAAE,mBAAmB,CAAA;IAC5B,KAAK,EAAE,aAAa,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,kBAAkB,CAAA;CAAE,GACtC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,CAAA;AA8GpC,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EACjC,IAAI,EAAE;IACJ,OAAO,EAAE,SAAS,GAAG,IAAI,CAAA;IACzB,KAAK,EAAE,aAAa,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAClC,GACA,oBAAoB,CAqCtB;AA4OD,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EACjC,IAAI,EAAE;IACJ,OAAO,EAAE,SAAS,CAAA;IAClB,KAAK,EAAE,aAAa,CAAA;IACpB,GAAG,EAAE,aAAa,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;CACjC,GACA,mBAAmB,CAiGrB"}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { GROUND_SUPPORT_ID } from '../../hooks/spatial-grid/support-host-id';
|
|
2
|
+
import { terrainSupportLift } from '../../lib/terrain-support';
|
|
3
|
+
import { getScaledDimensions, WallNode as WallSchema, } from '../../schema';
|
|
4
|
+
import { getWallArcData, getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from './wall-curve';
|
|
5
|
+
const WALL_MIN_LENGTH = 0.01;
|
|
6
|
+
const WALL_SPLIT_ENDPOINT_EPSILON = 0.02;
|
|
7
|
+
const WALL_INTERSECTION_EPSILON = 1e-6;
|
|
8
|
+
function distanceSquared(a, b) {
|
|
9
|
+
const dx = a[0] - b[0];
|
|
10
|
+
const dz = a[1] - b[1];
|
|
11
|
+
return dx * dx + dz * dz;
|
|
12
|
+
}
|
|
13
|
+
function isSegmentLongEnough(start, end) {
|
|
14
|
+
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH;
|
|
15
|
+
}
|
|
16
|
+
function wallSegmentsCoverSegment(start, end, walls) {
|
|
17
|
+
const dx = end[0] - start[0];
|
|
18
|
+
const dz = end[1] - start[1];
|
|
19
|
+
const lengthSquared = dx * dx + dz * dz;
|
|
20
|
+
if (lengthSquared <= WALL_INTERSECTION_EPSILON * WALL_INTERSECTION_EPSILON)
|
|
21
|
+
return false;
|
|
22
|
+
const length = Math.sqrt(lengthSquared);
|
|
23
|
+
const intervals = [];
|
|
24
|
+
for (const wall of walls) {
|
|
25
|
+
if (Math.abs(wall.curveOffset ?? 0) > WALL_INTERSECTION_EPSILON)
|
|
26
|
+
continue;
|
|
27
|
+
const startDistance = Math.abs((wall.start[0] - start[0]) * dz - (wall.start[1] - start[1]) * dx) / length;
|
|
28
|
+
const endDistance = Math.abs((wall.end[0] - start[0]) * dz - (wall.end[1] - start[1]) * dx) / length;
|
|
29
|
+
if (startDistance > WALL_INTERSECTION_EPSILON || endDistance > WALL_INTERSECTION_EPSILON) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const wallStartT = ((wall.start[0] - start[0]) * dx + (wall.start[1] - start[1]) * dz) / lengthSquared;
|
|
33
|
+
const wallEndT = ((wall.end[0] - start[0]) * dx + (wall.end[1] - start[1]) * dz) / lengthSquared;
|
|
34
|
+
const intervalStart = Math.max(0, Math.min(wallStartT, wallEndT));
|
|
35
|
+
const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT));
|
|
36
|
+
if (intervalEnd >= intervalStart)
|
|
37
|
+
intervals.push([intervalStart, intervalEnd]);
|
|
38
|
+
}
|
|
39
|
+
intervals.sort((left, right) => left[0] - right[0]);
|
|
40
|
+
const parameterTolerance = WALL_INTERSECTION_EPSILON / length;
|
|
41
|
+
let coveredUntil = 0;
|
|
42
|
+
for (const [intervalStart, intervalEnd] of intervals) {
|
|
43
|
+
if (intervalStart > coveredUntil + parameterTolerance)
|
|
44
|
+
return false;
|
|
45
|
+
coveredUntil = Math.max(coveredUntil, intervalEnd);
|
|
46
|
+
if (coveredUntil >= 1 - parameterTolerance)
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function projectPointOntoWallCenterline(point, wall) {
|
|
52
|
+
if (isCurvedWall(wall)) {
|
|
53
|
+
const arc = getWallArcData(wall);
|
|
54
|
+
if (!arc)
|
|
55
|
+
return null;
|
|
56
|
+
const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x);
|
|
57
|
+
let directedAngle = (pointAngle - arc.startAngle) * arc.direction;
|
|
58
|
+
while (directedAngle < 0)
|
|
59
|
+
directedAngle += Math.PI * 2;
|
|
60
|
+
const wallT = directedAngle / Math.abs(arc.delta);
|
|
61
|
+
if (wallT <= 0 || wallT >= 1)
|
|
62
|
+
return null;
|
|
63
|
+
return { point: wallPointAt(wall, wallT), wallT };
|
|
64
|
+
}
|
|
65
|
+
const dx = wall.end[0] - wall.start[0];
|
|
66
|
+
const dz = wall.end[1] - wall.start[1];
|
|
67
|
+
const lengthSquared = dx * dx + dz * dz;
|
|
68
|
+
if (lengthSquared < 1e-9)
|
|
69
|
+
return null;
|
|
70
|
+
const wallT = ((point[0] - wall.start[0]) * dx + (point[1] - wall.start[1]) * dz) / lengthSquared;
|
|
71
|
+
if (wallT <= 0 || wallT >= 1)
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
point: [wall.start[0] + dx * wallT, wall.start[1] + dz * wallT],
|
|
75
|
+
wallT,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function nearestWallProjection(point, walls, radius, ignoreWallIds = new Set()) {
|
|
79
|
+
let best = null;
|
|
80
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
81
|
+
for (const wall of walls) {
|
|
82
|
+
if (ignoreWallIds.has(wall.id))
|
|
83
|
+
continue;
|
|
84
|
+
const projection = projectPointOntoWallCenterline(point, wall);
|
|
85
|
+
if (!projection)
|
|
86
|
+
continue;
|
|
87
|
+
const candidateDistance = distanceSquared(point, projection.point);
|
|
88
|
+
if (candidateDistance > radius * radius || candidateDistance >= bestDistance)
|
|
89
|
+
continue;
|
|
90
|
+
const corner = [wall.start, wall.end].find((candidate) => distanceSquared(projection.point, candidate) <=
|
|
91
|
+
WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON);
|
|
92
|
+
best = corner
|
|
93
|
+
? { wall: null, point: [corner[0], corner[1]], wallT: projection.wallT }
|
|
94
|
+
: { wall, ...projection };
|
|
95
|
+
bestDistance = candidateDistance;
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
export function planWallSplitAtPoint(nodes, args) {
|
|
100
|
+
if (!args.levelId)
|
|
101
|
+
return { ok: false, reason: 'no-host' };
|
|
102
|
+
const walls = Object.values(nodes).filter((node) => node.type === 'wall' && node.parentId === args.levelId);
|
|
103
|
+
const projection = nearestWallProjection(args.point, walls, args.radius, new Set(args.ignoreWallIds ?? []));
|
|
104
|
+
if (!projection)
|
|
105
|
+
return { ok: false, reason: 'no-host' };
|
|
106
|
+
if (!projection.wall) {
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
plan: { point: projection.point, changes: { create: [], update: [], delete: [] } },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const split = splitWall(projection.wall, [projection.wallT], nodes);
|
|
113
|
+
if (!split) {
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
plan: { point: projection.point, changes: { create: [], update: [], delete: [] } },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
plan: {
|
|
122
|
+
point: projection.point,
|
|
123
|
+
changes: {
|
|
124
|
+
create: split.create.map((node) => ({ node, parentId: args.levelId ?? undefined })),
|
|
125
|
+
update: split.update,
|
|
126
|
+
delete: [projection.wall.id],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function straightSegmentIntersection(start, end, wall) {
|
|
132
|
+
const rx = end[0] - start[0];
|
|
133
|
+
const rz = end[1] - start[1];
|
|
134
|
+
const sx = wall.end[0] - wall.start[0];
|
|
135
|
+
const sz = wall.end[1] - wall.start[1];
|
|
136
|
+
const denominator = rx * sz - rz * sx;
|
|
137
|
+
if (Math.abs(denominator) < 1e-9)
|
|
138
|
+
return null;
|
|
139
|
+
const offsetX = wall.start[0] - start[0];
|
|
140
|
+
const offsetZ = wall.start[1] - start[1];
|
|
141
|
+
const draftT = (offsetX * sz - offsetZ * sx) / denominator;
|
|
142
|
+
const wallT = (offsetX * rz - offsetZ * rx) / denominator;
|
|
143
|
+
if (draftT <= 0 || draftT >= 1 || wallT < 0 || wallT > 1)
|
|
144
|
+
return null;
|
|
145
|
+
return {
|
|
146
|
+
wallId: wall.id,
|
|
147
|
+
point: [start[0] + draftT * rx, start[1] + draftT * rz],
|
|
148
|
+
draftT,
|
|
149
|
+
wallT,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function curvedSegmentIntersections(start, end, wall) {
|
|
153
|
+
const arc = getWallArcData(wall);
|
|
154
|
+
if (!arc)
|
|
155
|
+
return [];
|
|
156
|
+
const dx = end[0] - start[0];
|
|
157
|
+
const dz = end[1] - start[1];
|
|
158
|
+
const offsetX = start[0] - arc.center.x;
|
|
159
|
+
const offsetZ = start[1] - arc.center.y;
|
|
160
|
+
const a = dx * dx + dz * dz;
|
|
161
|
+
if (a < 1e-12)
|
|
162
|
+
return [];
|
|
163
|
+
const b = 2 * (offsetX * dx + offsetZ * dz);
|
|
164
|
+
const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius;
|
|
165
|
+
const discriminant = b * b - 4 * a * c;
|
|
166
|
+
if (discriminant < -1e-9)
|
|
167
|
+
return [];
|
|
168
|
+
const root = Math.sqrt(Math.max(0, discriminant));
|
|
169
|
+
const results = [];
|
|
170
|
+
for (const rawDraftT of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
|
|
171
|
+
if (rawDraftT < -1e-9 || rawDraftT > 1 + 1e-9)
|
|
172
|
+
continue;
|
|
173
|
+
const point = [start[0] + rawDraftT * dx, start[1] + rawDraftT * dz];
|
|
174
|
+
const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x);
|
|
175
|
+
let directedAngle = (angle - arc.startAngle) * arc.direction;
|
|
176
|
+
while (directedAngle < 0)
|
|
177
|
+
directedAngle += Math.PI * 2;
|
|
178
|
+
const rawWallT = directedAngle / Math.abs(arc.delta);
|
|
179
|
+
if (rawWallT < -1e-9 || rawWallT > 1 + 1e-9)
|
|
180
|
+
continue;
|
|
181
|
+
if (results.some((candidate) => distanceSquared(candidate.point, point) < 1e-12))
|
|
182
|
+
continue;
|
|
183
|
+
results.push({
|
|
184
|
+
wallId: wall.id,
|
|
185
|
+
point,
|
|
186
|
+
draftT: Math.max(0, Math.min(1, rawDraftT)),
|
|
187
|
+
wallT: Math.max(0, Math.min(1, rawWallT)),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
function joinCrossingAtNearbyWallEndpoint(crossing, walls) {
|
|
193
|
+
const wall = walls.find((candidate) => candidate.id === crossing.wallId);
|
|
194
|
+
if (!wall)
|
|
195
|
+
return crossing;
|
|
196
|
+
const endpointIndex = [wall.start, wall.end].findIndex((endpoint) => distanceSquared(crossing.point, endpoint) <=
|
|
197
|
+
WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON);
|
|
198
|
+
if (endpointIndex < 0)
|
|
199
|
+
return crossing;
|
|
200
|
+
const endpoint = endpointIndex === 0 ? wall.start : wall.end;
|
|
201
|
+
return { ...crossing, point: [endpoint[0], endpoint[1]], wallT: endpointIndex };
|
|
202
|
+
}
|
|
203
|
+
function wallLength(wall) {
|
|
204
|
+
return isCurvedWall(wall)
|
|
205
|
+
? getWallCurveLength(wall)
|
|
206
|
+
: Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]);
|
|
207
|
+
}
|
|
208
|
+
function wallPointAt(wall, wallT) {
|
|
209
|
+
if (wallT <= WALL_INTERSECTION_EPSILON)
|
|
210
|
+
return wall.start;
|
|
211
|
+
if (wallT >= 1 - WALL_INTERSECTION_EPSILON)
|
|
212
|
+
return wall.end;
|
|
213
|
+
const frame = getWallCurveFrameAt(wall, wallT);
|
|
214
|
+
return [frame.point.x, frame.point.y];
|
|
215
|
+
}
|
|
216
|
+
function segmentCurveOffset(wall, startT, endT) {
|
|
217
|
+
const arc = getWallArcData(wall);
|
|
218
|
+
if (!arc)
|
|
219
|
+
return wall.curveOffset;
|
|
220
|
+
const angle = Math.abs(arc.delta) * (endT - startT);
|
|
221
|
+
return arc.direction * arc.radius * (1 - Math.cos(angle / 2));
|
|
222
|
+
}
|
|
223
|
+
function attachmentSpan(node) {
|
|
224
|
+
if (node.type === 'door') {
|
|
225
|
+
const door = node;
|
|
226
|
+
return {
|
|
227
|
+
min: door.position[0] - door.width / 2,
|
|
228
|
+
max: door.position[0] + door.width / 2,
|
|
229
|
+
center: door.position[0],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (node.type === 'window') {
|
|
233
|
+
const window = node;
|
|
234
|
+
return {
|
|
235
|
+
min: window.position[0] - window.width / 2,
|
|
236
|
+
max: window.position[0] + window.width / 2,
|
|
237
|
+
center: window.position[0],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (node.type === 'item') {
|
|
241
|
+
const item = node;
|
|
242
|
+
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side')
|
|
243
|
+
return null;
|
|
244
|
+
const [width] = getScaledDimensions(item);
|
|
245
|
+
return {
|
|
246
|
+
min: item.position[0] - width / 2,
|
|
247
|
+
max: item.position[0] + width / 2,
|
|
248
|
+
center: item.position[0],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
function wallAttachments(wall, nodes) {
|
|
254
|
+
const ids = new Set((wall.children ?? []));
|
|
255
|
+
for (const node of Object.values(nodes)) {
|
|
256
|
+
if (node.parentId === wall.id ||
|
|
257
|
+
('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id)) {
|
|
258
|
+
ids.add(node.id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return [...ids].flatMap((id) => {
|
|
262
|
+
const node = nodes[id];
|
|
263
|
+
return node ? [node] : [];
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function remapAttachment(node, wall, nextLocalX) {
|
|
267
|
+
if (!(node.type === 'door' || node.type === 'window' || node.type === 'item'))
|
|
268
|
+
return null;
|
|
269
|
+
const nextLength = wallLength(wall);
|
|
270
|
+
const clampedX = Math.max(0, Math.min(nextLength, nextLocalX));
|
|
271
|
+
return {
|
|
272
|
+
parentId: wall.id,
|
|
273
|
+
wallId: wall.id,
|
|
274
|
+
position: [clampedX, node.position[1], node.position[2]],
|
|
275
|
+
...(node.type === 'item' ? { wallT: nextLength > 1e-6 ? clampedX / nextLength : 0 } : {}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function splitWall(wall, splitParameters, nodes) {
|
|
279
|
+
const parameters = [
|
|
280
|
+
0,
|
|
281
|
+
...splitParameters
|
|
282
|
+
.filter((wallT) => wallT > WALL_INTERSECTION_EPSILON && wallT < 1 - WALL_INTERSECTION_EPSILON)
|
|
283
|
+
.sort((left, right) => left - right),
|
|
284
|
+
1,
|
|
285
|
+
];
|
|
286
|
+
const { id: _id, parentId: _parentId, children: _children, ...properties } = wall;
|
|
287
|
+
const parsedSegments = parameters.slice(0, -1).map((startT, index) => {
|
|
288
|
+
const endT = parameters[index + 1];
|
|
289
|
+
return WallSchema.parse({
|
|
290
|
+
...properties,
|
|
291
|
+
start: wallPointAt(wall, startT),
|
|
292
|
+
end: wallPointAt(wall, endT),
|
|
293
|
+
curveOffset: segmentCurveOffset(wall, startT, endT),
|
|
294
|
+
children: [],
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
const originalElevation = wall.supportSlabId === GROUND_SUPPORT_ID && wall.parentId
|
|
298
|
+
? (terrainSupportLift(nodes, wall.parentId, wall.start[0], wall.start[1]) ?? 0) +
|
|
299
|
+
(wall.supportOffset ?? 0)
|
|
300
|
+
: null;
|
|
301
|
+
const segments = parsedSegments.map((segment) => {
|
|
302
|
+
if (originalElevation === null || !wall.parentId)
|
|
303
|
+
return segment;
|
|
304
|
+
const terrainElevation = terrainSupportLift(nodes, wall.parentId, segment.start[0], segment.start[1]) ?? 0;
|
|
305
|
+
const supportOffset = originalElevation - terrainElevation;
|
|
306
|
+
return {
|
|
307
|
+
...segment,
|
|
308
|
+
supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
const totalLength = wallLength(wall);
|
|
312
|
+
const segmentChildren = segments.map(() => []);
|
|
313
|
+
const updates = [];
|
|
314
|
+
for (const attachment of wallAttachments(wall, nodes)) {
|
|
315
|
+
const span = attachmentSpan(attachment);
|
|
316
|
+
if (!span)
|
|
317
|
+
return null;
|
|
318
|
+
const segmentIndex = parameters.slice(0, -1).findIndex((startT, index) => {
|
|
319
|
+
const endT = parameters[index + 1];
|
|
320
|
+
return span.min >= totalLength * startT - 1e-4 && span.max <= totalLength * endT + 1e-4;
|
|
321
|
+
});
|
|
322
|
+
if (segmentIndex < 0)
|
|
323
|
+
return null;
|
|
324
|
+
const segment = segments[segmentIndex];
|
|
325
|
+
const update = remapAttachment(attachment, segment, span.center - totalLength * parameters[segmentIndex]);
|
|
326
|
+
if (!update)
|
|
327
|
+
return null;
|
|
328
|
+
segmentChildren[segmentIndex].push(attachment.id);
|
|
329
|
+
updates.push({ id: attachment.id, data: update });
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
create: segments.map((segment, index) => WallSchema.parse({ ...segment, children: segmentChildren[index] })),
|
|
333
|
+
update: updates,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function planWallInsertion(nodes, args) {
|
|
337
|
+
const walls = Object.values(nodes).filter((node) => node.type === 'wall' && node.parentId === args.levelId);
|
|
338
|
+
const endProjection = nearestWallProjection(args.end, walls, args.joinRadius);
|
|
339
|
+
const startProjection = nearestWallProjection(args.start, walls, args.joinRadius);
|
|
340
|
+
const resolvedStart = startProjection?.point ?? args.start;
|
|
341
|
+
const resolvedEnd = endProjection?.point ?? args.end;
|
|
342
|
+
if (wallSegmentsCoverSegment(resolvedStart, resolvedEnd, walls)) {
|
|
343
|
+
return { ok: false, reason: 'covered-existing-wall' };
|
|
344
|
+
}
|
|
345
|
+
const crossings = walls
|
|
346
|
+
.flatMap((wall) => isCurvedWall(wall)
|
|
347
|
+
? curvedSegmentIntersections(resolvedStart, resolvedEnd, wall)
|
|
348
|
+
: [straightSegmentIntersection(resolvedStart, resolvedEnd, wall)].filter((crossing) => crossing !== null))
|
|
349
|
+
.map((crossing) => joinCrossingAtNearbyWallEndpoint(crossing, walls))
|
|
350
|
+
.filter(({ draftT }) => draftT > WALL_INTERSECTION_EPSILON && draftT < 1 - WALL_INTERSECTION_EPSILON)
|
|
351
|
+
.sort((left, right) => left.draftT - right.draftT);
|
|
352
|
+
const splitPoints = crossings.reduce((points, crossing) => {
|
|
353
|
+
if (!points.some((point) => distanceSquared(point, crossing.point) <= 1e-12)) {
|
|
354
|
+
points.push(crossing.point);
|
|
355
|
+
}
|
|
356
|
+
return points;
|
|
357
|
+
}, []);
|
|
358
|
+
const vertices = [resolvedStart, ...splitPoints, resolvedEnd];
|
|
359
|
+
if (vertices.some((start, index) => index < vertices.length - 1 && !isSegmentLongEnough(start, vertices[index + 1]))) {
|
|
360
|
+
return { ok: false, reason: 'segment-too-short' };
|
|
361
|
+
}
|
|
362
|
+
const wallProperties = { ...(args.wallDefaults ?? {}) };
|
|
363
|
+
delete wallProperties.id;
|
|
364
|
+
delete wallProperties.parentId;
|
|
365
|
+
delete wallProperties.children;
|
|
366
|
+
const existingWallCount = Object.values(nodes).filter((node) => node.type === 'wall').length;
|
|
367
|
+
const insertedWalls = vertices.slice(0, -1).map((start, index) => WallSchema.parse({
|
|
368
|
+
...wallProperties,
|
|
369
|
+
name: `Wall ${existingWallCount + index + 1}`,
|
|
370
|
+
start,
|
|
371
|
+
end: vertices[index + 1],
|
|
372
|
+
}));
|
|
373
|
+
const splitWalls = new Map();
|
|
374
|
+
const addSplitParameter = (wallId, wallT) => {
|
|
375
|
+
const parameters = splitWalls.get(wallId) ?? [];
|
|
376
|
+
if (!parameters.some((candidate) => Math.abs(candidate - wallT) <= WALL_INTERSECTION_EPSILON)) {
|
|
377
|
+
parameters.push(wallT);
|
|
378
|
+
}
|
|
379
|
+
splitWalls.set(wallId, parameters);
|
|
380
|
+
};
|
|
381
|
+
for (const projection of [startProjection, endProjection]) {
|
|
382
|
+
if (projection?.wall) {
|
|
383
|
+
addSplitParameter(projection.wall.id, projection.wallT);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const crossing of crossings) {
|
|
387
|
+
if (crossing.wallT <= WALL_INTERSECTION_EPSILON ||
|
|
388
|
+
crossing.wallT >= 1 - WALL_INTERSECTION_EPSILON) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
addSplitParameter(crossing.wallId, crossing.wallT);
|
|
392
|
+
}
|
|
393
|
+
const splitPlans = [...splitWalls].flatMap(([wallId, parameters]) => {
|
|
394
|
+
const wall = walls.find((candidate) => candidate.id === wallId);
|
|
395
|
+
const split = wall ? splitWall(wall, parameters, nodes) : null;
|
|
396
|
+
return split ? [[wallId, split]] : [];
|
|
397
|
+
});
|
|
398
|
+
const replacementWalls = splitPlans.flatMap(([, split]) => split.create);
|
|
399
|
+
const plan = {
|
|
400
|
+
changes: {
|
|
401
|
+
create: [...replacementWalls, ...insertedWalls].map((node) => ({
|
|
402
|
+
node,
|
|
403
|
+
parentId: args.levelId,
|
|
404
|
+
})),
|
|
405
|
+
update: splitPlans.flatMap(([, split]) => split.update),
|
|
406
|
+
delete: splitPlans.map(([wallId]) => wallId),
|
|
407
|
+
},
|
|
408
|
+
insertedWalls,
|
|
409
|
+
terminalWallId: insertedWalls.at(-1).id,
|
|
410
|
+
resolvedStart,
|
|
411
|
+
resolvedEnd,
|
|
412
|
+
};
|
|
413
|
+
return { ok: true, plan };
|
|
414
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { AnyNode, AnyNodeId } from '../schema';
|
|
2
2
|
import type { Collection, CollectionId } from '../schema/collections';
|
|
3
|
+
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material';
|
|
3
4
|
export type SceneGraph = {
|
|
4
5
|
nodes: Record<AnyNodeId, AnyNode>;
|
|
5
6
|
rootNodeIds: AnyNodeId[];
|
|
6
7
|
collections?: Record<CollectionId, Collection>;
|
|
8
|
+
materials?: Record<SceneMaterialId, SceneMaterial>;
|
|
7
9
|
installedPlugins?: string[];
|
|
8
10
|
};
|
|
9
11
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clone-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/clone-scene-graph.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;
|
|
1
|
+
{"version":3,"file":"clone-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/clone-scene-graph.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAE9E,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;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,eAAe,EAAE,aAAa,CAAC,CAAA;IAClD,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;CAC5B,CAAA;AAUD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CA6IlE;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,CAsG/E;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB,CAAA;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,qBAA0B,GAClC,UAAU,CA0EZ"}
|
|
@@ -18,7 +18,7 @@ function extractIdPrefix(id) {
|
|
|
18
18
|
* - Multi-scene in-memory scenarios
|
|
19
19
|
*/
|
|
20
20
|
export function cloneSceneGraph(sceneGraph) {
|
|
21
|
-
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph;
|
|
21
|
+
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph;
|
|
22
22
|
// Build ID mapping: old ID -> new ID
|
|
23
23
|
const idMap = new Map();
|
|
24
24
|
// Pass 1: Generate new IDs for all nodes
|
|
@@ -127,6 +127,12 @@ export function cloneSceneGraph(sceneGraph) {
|
|
|
127
127
|
nodes: clonedNodes,
|
|
128
128
|
rootNodeIds: clonedRootNodeIds,
|
|
129
129
|
...(clonedCollections && { collections: clonedCollections }),
|
|
130
|
+
// Material ids are deliberately *not* remapped. Nodes point at these
|
|
131
|
+
// through `slots` values shaped `scene:mat_…` — opaque strings that the
|
|
132
|
+
// node remapping above copies verbatim, since `idMap` only covers node
|
|
133
|
+
// ids. Minting fresh material ids here would orphan every one of those
|
|
134
|
+
// refs and the clone would render with default materials.
|
|
135
|
+
...(materials && { materials: structuredClone(materials) }),
|
|
130
136
|
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
|
|
131
137
|
};
|
|
132
138
|
}
|
|
@@ -247,7 +253,7 @@ export function forkSceneGraph(sceneGraph, options = {}) {
|
|
|
247
253
|
if (options.preserveScans) {
|
|
248
254
|
return cloneSceneGraph(sceneGraph);
|
|
249
255
|
}
|
|
250
|
-
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph;
|
|
256
|
+
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph;
|
|
251
257
|
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
|
252
258
|
const excludedNodeIds = new Set();
|
|
253
259
|
for (const [nodeId, node] of Object.entries(nodes)) {
|
|
@@ -299,6 +305,11 @@ export function forkSceneGraph(sceneGraph, options = {}) {
|
|
|
299
305
|
nodes: filteredNodes,
|
|
300
306
|
rootNodeIds: filteredRootNodeIds,
|
|
301
307
|
...(filteredCollections && { collections: filteredCollections }),
|
|
308
|
+
// Kept whole rather than filtered to the surviving nodes: a palette entry
|
|
309
|
+
// is authored content in its own right, and dropping the scan node that
|
|
310
|
+
// happened to be its only user would silently delete a material the fork's
|
|
311
|
+
// owner can still pick from the palette.
|
|
312
|
+
...(materials && { materials }),
|
|
302
313
|
...(installedPlugins && { installedPlugins }),
|
|
303
314
|
});
|
|
304
315
|
}
|
|
@@ -9,6 +9,11 @@ export interface HealSceneResult {
|
|
|
9
9
|
* a different node (stale reparent leftovers), plus same-array duplicates.
|
|
10
10
|
*/
|
|
11
11
|
strippedStaleChildRefs: number;
|
|
12
|
+
/**
|
|
13
|
+
* Ids of nodes whose null `parentId` was repaired to the one parent that
|
|
14
|
+
* still claims them via `children`.
|
|
15
|
+
*/
|
|
16
|
+
repairedParentLinkNodeIds: string[];
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* Returns a healed copy of a `nodes` map. Pure — does not mutate `input`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"heal-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/heal-scene-graph.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"heal-scene-graph.d.ts","sourceRoot":"","sources":["../../src/utils/heal-scene-graph.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,kDAAkD;IAClD,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,sFAAsF;IACtF,iBAAiB,EAAE,MAAM,CAAA;IACzB;;;OAGG;IACH,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,yBAAyB,EAAE,MAAM,EAAE,CAAA;CACpC;AAgBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,CAiH9E"}
|