@pascal-app/core 1.0.0-beta.4 → 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 +75 -163
- 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/scene-migrations.d.ts +1 -0
- package/dist/utils/scene-migrations.d.ts.map +1 -1
- package/dist/utils/scene-migrations.js +1 -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 +1 -1
|
@@ -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
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { type HealSceneResult, healSceneNodes } from './heal-scene-graph';
|
|
2
2
|
export { type RetiredSceneNodeMigration, removeRetiredDrawingSheetNodes, } from './retired-scene-nodes';
|
|
3
|
+
export { migrateVerticalSceneNodes, type VerticalSceneMigration, } from './vertical-scene-migration';
|
|
3
4
|
//# sourceMappingURL=scene-migrations.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scene-migrations.d.ts","sourceRoot":"","sources":["../../src/utils/scene-migrations.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACzE,OAAO,EACL,KAAK,yBAAyB,EAC9B,8BAA8B,GAC/B,MAAM,uBAAuB,CAAA"}
|
|
1
|
+
{"version":3,"file":"scene-migrations.d.ts","sourceRoot":"","sources":["../../src/utils/scene-migrations.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACzE,OAAO,EACL,KAAK,yBAAyB,EAC9B,8BAA8B,GAC/B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EACL,yBAAyB,EACzB,KAAK,sBAAsB,GAC5B,MAAM,4BAA4B,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type VerticalSceneMigration = {
|
|
2
|
+
changed: boolean;
|
|
3
|
+
nodes: Record<string, unknown>;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Applies the vertical-model load migration to serialized scene nodes.
|
|
7
|
+
*
|
|
8
|
+
* This must remain pure, idempotent, and server-safe: the editor loader and
|
|
9
|
+
* hosted scene authority both call it so they compare and persist the same
|
|
10
|
+
* canonical fields during collaboration.
|
|
11
|
+
*/
|
|
12
|
+
export declare function migrateVerticalSceneNodes(sourceNodes: Record<string, unknown>): VerticalSceneMigration;
|
|
13
|
+
//# sourceMappingURL=vertical-scene-migration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vertical-scene-migration.d.ts","sourceRoot":"","sources":["../../src/utils/vertical-scene-migration.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B,CAAA;AAuBD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,sBAAsB,CA+KxB"}
|