@pascal-app/core 0.5.0 → 0.6.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 +39 -4
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +1 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts +1 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +1 -0
- package/dist/hooks/spatial-grid/spatial-grid.d.ts +2 -0
- package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid.js +43 -20
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/lib/polygon-geometry.d.ts +3 -0
- package/dist/lib/polygon-geometry.d.ts.map +1 -0
- package/dist/lib/polygon-geometry.js +90 -0
- package/dist/lib/space-detection.d.ts +10 -17
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +666 -453
- package/dist/material-library.d.ts +18 -0
- package/dist/material-library.d.ts.map +1 -0
- package/dist/material-library.js +603 -0
- package/dist/schema/index.d.ts +10 -4
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +6 -4
- package/dist/schema/material.d.ts +109 -0
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +52 -0
- package/dist/schema/nodes/ceiling.d.ts +10 -0
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +6 -0
- package/dist/schema/nodes/door.d.ts +1 -0
- package/dist/schema/nodes/door.d.ts.map +1 -1
- package/dist/schema/nodes/fence.d.ts +85 -0
- package/dist/schema/nodes/fence.d.ts.map +1 -0
- package/dist/schema/nodes/fence.js +34 -0
- package/dist/schema/nodes/item.d.ts +2 -2
- 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/roof-segment.d.ts +2 -0
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +1 -0
- package/dist/schema/nodes/roof.d.ts +108 -0
- package/dist/schema/nodes/roof.d.ts.map +1 -1
- package/dist/schema/nodes/roof.js +58 -2
- package/dist/schema/nodes/site.d.ts +1 -1
- package/dist/schema/nodes/slab.d.ts +10 -0
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +7 -0
- package/dist/schema/nodes/stair-segment.d.ts +2 -0
- package/dist/schema/nodes/stair-segment.d.ts.map +1 -1
- package/dist/schema/nodes/stair-segment.js +1 -0
- package/dist/schema/nodes/stair.d.ts +164 -0
- package/dist/schema/nodes/stair.d.ts.map +1 -1
- package/dist/schema/nodes/stair.js +106 -5
- package/dist/schema/nodes/surface-hole-metadata.d.ts +10 -0
- package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -0
- package/dist/schema/nodes/surface-hole-metadata.js +5 -0
- package/dist/schema/nodes/wall.d.ts +87 -1
- package/dist/schema/nodes/wall.d.ts.map +1 -1
- package/dist/schema/nodes/wall.js +45 -4
- package/dist/schema/nodes/window.d.ts +1 -0
- package/dist/schema/nodes/window.d.ts.map +1 -1
- package/dist/schema/types.d.ts +406 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +2 -0
- package/dist/store/actions/node-actions.d.ts +1 -1
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +175 -0
- package/dist/store/history-control.d.ts +14 -0
- package/dist/store/history-control.d.ts.map +1 -0
- package/dist/store/history-control.js +22 -0
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +249 -3
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +7 -0
- package/dist/systems/fence/fence-system.d.ts +2 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -0
- package/dist/systems/fence/fence-system.js +187 -0
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +31 -1
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +45 -8
- package/dist/systems/stair/stair-opening-sync.d.ts +6 -0
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-sync.js +515 -0
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +432 -10
- package/dist/systems/wall/wall-curve.d.ts +43 -0
- package/dist/systems/wall/wall-curve.d.ts.map +1 -0
- package/dist/systems/wall/wall-curve.js +176 -0
- package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
- package/dist/systems/wall/wall-footprint.js +16 -2
- package/dist/systems/wall/wall-mitering.d.ts +7 -0
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +76 -3
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +202 -2
- package/package.json +3 -3
|
@@ -1,471 +1,693 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
|
|
2
|
+
import { CeilingNode, SlabNode } from '../schema';
|
|
3
|
+
import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
|
|
4
|
+
import { simplifyClosedPolygon } from './polygon-geometry';
|
|
5
|
+
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05;
|
|
6
|
+
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5;
|
|
7
|
+
const ROOM_CURVE_TOLERANCE = 0.04;
|
|
8
|
+
const MAX_CURVE_SUBDIVISION_DEPTH = 6;
|
|
9
|
+
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08;
|
|
10
|
+
function pointFromTuple(point) {
|
|
11
|
+
return { x: point[0], y: point[1] };
|
|
12
|
+
}
|
|
13
|
+
function pointToTuple(point) {
|
|
14
|
+
return [point.x, point.y];
|
|
15
|
+
}
|
|
16
|
+
function pointKey(point) {
|
|
17
|
+
return `${point.x.toFixed(3)},${point.y.toFixed(3)}`;
|
|
18
|
+
}
|
|
19
|
+
function polygonArea(points) {
|
|
20
|
+
let area = 0;
|
|
21
|
+
for (let i = 0; i < points.length; i++) {
|
|
22
|
+
const a = points[i];
|
|
23
|
+
const b = points[(i + 1) % points.length];
|
|
24
|
+
if (!(a && b))
|
|
25
|
+
continue;
|
|
26
|
+
area += a.x * b.y - b.x * a.y;
|
|
27
|
+
}
|
|
28
|
+
return area / 2;
|
|
29
|
+
}
|
|
30
|
+
function minRotationSignature(keys) {
|
|
31
|
+
if (keys.length === 0)
|
|
32
|
+
return '';
|
|
33
|
+
let best = '';
|
|
34
|
+
for (let i = 0; i < keys.length; i++) {
|
|
35
|
+
const rotated = [...keys.slice(i), ...keys.slice(0, i)];
|
|
36
|
+
const value = rotated.join('|');
|
|
37
|
+
if (!best || value < best)
|
|
38
|
+
best = value;
|
|
39
|
+
}
|
|
40
|
+
return best;
|
|
41
|
+
}
|
|
42
|
+
function polygonSignature(points) {
|
|
43
|
+
const keys = points.map(pointKey);
|
|
44
|
+
const forward = minRotationSignature(keys);
|
|
45
|
+
const reversed = minRotationSignature([...keys].reverse());
|
|
46
|
+
return forward < reversed ? forward : reversed;
|
|
47
|
+
}
|
|
48
|
+
function samePointWithinTolerance(a, b, tolerance = 1e-4) {
|
|
49
|
+
return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance;
|
|
50
|
+
}
|
|
51
|
+
function dedupeSequentialPoints(points, tolerance = 1e-4) {
|
|
52
|
+
const deduped = [];
|
|
53
|
+
for (const point of points) {
|
|
54
|
+
const previous = deduped[deduped.length - 1];
|
|
55
|
+
if (previous && samePointWithinTolerance(previous, point, tolerance)) {
|
|
56
|
+
continue;
|
|
28
57
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
deduped.push(point);
|
|
59
|
+
}
|
|
60
|
+
const firstPoint = deduped[0];
|
|
61
|
+
const lastPoint = deduped[deduped.length - 1];
|
|
62
|
+
if (deduped.length > 2 &&
|
|
63
|
+
firstPoint &&
|
|
64
|
+
lastPoint &&
|
|
65
|
+
samePointWithinTolerance(firstPoint, lastPoint, tolerance)) {
|
|
66
|
+
deduped.pop();
|
|
67
|
+
}
|
|
68
|
+
return deduped;
|
|
69
|
+
}
|
|
70
|
+
function pointInPolygon(point, polygon) {
|
|
71
|
+
if (polygon.length < 3)
|
|
72
|
+
return false;
|
|
73
|
+
let inside = false;
|
|
74
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
75
|
+
const xi = polygon[i]?.x ?? 0;
|
|
76
|
+
const yi = polygon[i]?.y ?? 0;
|
|
77
|
+
const xj = polygon[j]?.x ?? 0;
|
|
78
|
+
const yj = polygon[j]?.y ?? 0;
|
|
79
|
+
const intersect = yi > point.y !== yj > point.y &&
|
|
80
|
+
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi;
|
|
81
|
+
if (intersect)
|
|
82
|
+
inside = !inside;
|
|
83
|
+
}
|
|
84
|
+
return inside;
|
|
85
|
+
}
|
|
86
|
+
function pointInAnyPolygon(point, polygons) {
|
|
87
|
+
return polygons.some((polygon) => pointInPolygon(point, polygon));
|
|
88
|
+
}
|
|
89
|
+
function polygonCentroid(points) {
|
|
90
|
+
const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), {
|
|
91
|
+
x: 0,
|
|
92
|
+
y: 0,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
x: sum.x / Math.max(points.length, 1),
|
|
96
|
+
y: sum.y / Math.max(points.length, 1),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function bboxOf(points) {
|
|
100
|
+
let minX = Infinity;
|
|
101
|
+
let minY = Infinity;
|
|
102
|
+
let maxX = -Infinity;
|
|
103
|
+
let maxY = -Infinity;
|
|
104
|
+
for (const point of points) {
|
|
105
|
+
minX = Math.min(minX, point.x);
|
|
106
|
+
minY = Math.min(minY, point.y);
|
|
107
|
+
maxX = Math.max(maxX, point.x);
|
|
108
|
+
maxY = Math.max(maxY, point.y);
|
|
109
|
+
}
|
|
110
|
+
return { minX, minY, maxX, maxY };
|
|
111
|
+
}
|
|
112
|
+
function bboxOverlapArea(a, b) {
|
|
113
|
+
const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX));
|
|
114
|
+
const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY));
|
|
115
|
+
return ix * iy;
|
|
116
|
+
}
|
|
117
|
+
function getWallDirection(wall) {
|
|
118
|
+
const dx = wall.end[0] - wall.start[0];
|
|
119
|
+
const dy = wall.end[1] - wall.start[1];
|
|
120
|
+
const length = Math.hypot(dx, dy);
|
|
121
|
+
if (length < 1e-9) {
|
|
122
|
+
return {
|
|
123
|
+
point: pointFromTuple(wall.start),
|
|
124
|
+
tangent: { x: 1, y: 0 },
|
|
125
|
+
normal: { x: 0, y: 1 },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const tangent = { x: dx / length, y: dy / length };
|
|
129
|
+
return {
|
|
130
|
+
point: {
|
|
131
|
+
x: (wall.start[0] + wall.end[0]) / 2,
|
|
132
|
+
y: (wall.start[1] + wall.end[1]) / 2,
|
|
133
|
+
},
|
|
134
|
+
tangent,
|
|
135
|
+
normal: { x: -tangent.y, y: tangent.x },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function pointLineDistance(point, start, end) {
|
|
139
|
+
const dx = end.x - start.x;
|
|
140
|
+
const dy = end.y - start.y;
|
|
141
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
142
|
+
if (lengthSquared < 1e-9) {
|
|
143
|
+
return Math.hypot(point.x - start.x, point.y - start.y);
|
|
144
|
+
}
|
|
145
|
+
const cross = (point.x - start.x) * dy - (point.y - start.y) * dx;
|
|
146
|
+
return Math.abs(cross) / Math.sqrt(lengthSquared);
|
|
147
|
+
}
|
|
148
|
+
function sampleWallPointsForRoomDetection(wall, tolerance = ROOM_CURVE_TOLERANCE) {
|
|
149
|
+
const start = { x: wall.start[0], y: wall.start[1] };
|
|
150
|
+
const end = { x: wall.end[0], y: wall.end[1] };
|
|
151
|
+
if (!isCurvedWall(wall)) {
|
|
152
|
+
return [start, end];
|
|
153
|
+
}
|
|
154
|
+
const subdivide = (t0, p0, t1, p1, depth) => {
|
|
155
|
+
const midT = (t0 + t1) / 2;
|
|
156
|
+
const midPoint = getWallCurveFrameAt(wall, midT).point;
|
|
157
|
+
const deviation = pointLineDistance(midPoint, p0, p1);
|
|
158
|
+
if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) {
|
|
159
|
+
return [p0, p1];
|
|
160
|
+
}
|
|
161
|
+
const left = subdivide(t0, p0, midT, midPoint, depth + 1);
|
|
162
|
+
const right = subdivide(midT, midPoint, t1, p1, depth + 1);
|
|
163
|
+
return [...left.slice(0, -1), ...right];
|
|
164
|
+
};
|
|
165
|
+
return subdivide(0, start, 1, end, 0);
|
|
166
|
+
}
|
|
167
|
+
function getDirectedWallBoundaryPoints(wall, forward) {
|
|
168
|
+
const points = sampleWallPointsForRoomDetection(wall);
|
|
169
|
+
return forward ? points : [...points].reverse();
|
|
170
|
+
}
|
|
171
|
+
function extractRoomPolygons(walls) {
|
|
172
|
+
if (walls.length < 3)
|
|
173
|
+
return [];
|
|
174
|
+
const graph = new Map();
|
|
175
|
+
const halfEdges = new Map();
|
|
176
|
+
const upsertNode = (point) => {
|
|
177
|
+
const key = pointKey(point);
|
|
178
|
+
if (!graph.has(key)) {
|
|
179
|
+
graph.set(key, { point: { ...point }, outgoing: [] });
|
|
180
|
+
}
|
|
181
|
+
return key;
|
|
182
|
+
};
|
|
183
|
+
for (const wall of walls) {
|
|
184
|
+
const start = pointFromTuple(wall.start);
|
|
185
|
+
const end = pointFromTuple(wall.end);
|
|
186
|
+
const startKey = upsertNode(start);
|
|
187
|
+
const endKey = upsertNode(end);
|
|
188
|
+
if (startKey === endKey)
|
|
189
|
+
continue;
|
|
190
|
+
const forwardDirection = getWallDirection(wall);
|
|
191
|
+
const reverseDirection = getWallDirection({ start: wall.end, end: wall.start });
|
|
192
|
+
const forwardId = `${wall.id}:f`;
|
|
193
|
+
const reverseId = `${wall.id}:r`;
|
|
194
|
+
halfEdges.set(forwardId, {
|
|
195
|
+
id: forwardId,
|
|
196
|
+
reverseId,
|
|
197
|
+
fromKey: startKey,
|
|
198
|
+
toKey: endKey,
|
|
199
|
+
angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
|
|
200
|
+
points: getDirectedWallBoundaryPoints(wall, true),
|
|
201
|
+
});
|
|
202
|
+
halfEdges.set(reverseId, {
|
|
203
|
+
id: reverseId,
|
|
204
|
+
reverseId: forwardId,
|
|
205
|
+
fromKey: endKey,
|
|
206
|
+
toKey: startKey,
|
|
207
|
+
angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
|
|
208
|
+
points: getDirectedWallBoundaryPoints(wall, false),
|
|
209
|
+
});
|
|
210
|
+
graph.get(startKey)?.outgoing.push(forwardId);
|
|
211
|
+
graph.get(endKey)?.outgoing.push(reverseId);
|
|
212
|
+
}
|
|
213
|
+
const sortedOutgoing = new Map();
|
|
214
|
+
for (const [key, node] of graph.entries()) {
|
|
215
|
+
const outgoing = [...node.outgoing];
|
|
216
|
+
outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0));
|
|
217
|
+
sortedOutgoing.set(key, outgoing);
|
|
218
|
+
}
|
|
219
|
+
const nextEdge = (edgeId) => {
|
|
220
|
+
const edge = halfEdges.get(edgeId);
|
|
221
|
+
if (!edge)
|
|
222
|
+
return null;
|
|
223
|
+
const outgoing = sortedOutgoing.get(edge.toKey);
|
|
224
|
+
if (!outgoing || outgoing.length === 0)
|
|
225
|
+
return null;
|
|
226
|
+
const idx = outgoing.indexOf(edge.reverseId);
|
|
227
|
+
if (idx === -1)
|
|
228
|
+
return null;
|
|
229
|
+
const nextIdx = (idx - 1 + outgoing.length) % outgoing.length;
|
|
230
|
+
return outgoing[nextIdx] ?? null;
|
|
231
|
+
};
|
|
232
|
+
const visitedDirected = new Set();
|
|
233
|
+
const faces = [];
|
|
234
|
+
const maxSteps = Math.min(500, walls.length * 8 + 20);
|
|
235
|
+
for (const edgeId of halfEdges.keys()) {
|
|
236
|
+
if (visitedDirected.has(edgeId))
|
|
237
|
+
continue;
|
|
238
|
+
const cycleEdgeIds = [];
|
|
239
|
+
let currentEdgeId = edgeId;
|
|
240
|
+
let valid = true;
|
|
241
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
242
|
+
const currentEdge = halfEdges.get(currentEdgeId);
|
|
243
|
+
if (!currentEdge) {
|
|
244
|
+
valid = false;
|
|
245
|
+
break;
|
|
40
246
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.filter((id) => id !== wallId)
|
|
48
|
-
.map((id) => nodes[id])
|
|
49
|
-
.filter(Boolean);
|
|
50
|
-
if (wallTouchesOthers(wall, otherWalls)) {
|
|
51
|
-
levelsToUpdate.add(levelId);
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
247
|
+
visitedDirected.add(currentEdgeId);
|
|
248
|
+
cycleEdgeIds.push(currentEdgeId);
|
|
249
|
+
const next = nextEdge(currentEdgeId);
|
|
250
|
+
if (!next) {
|
|
251
|
+
valid = false;
|
|
252
|
+
break;
|
|
55
253
|
}
|
|
254
|
+
currentEdgeId = next;
|
|
255
|
+
if (currentEdgeId === edgeId)
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
if (!valid || cycleEdgeIds.length < 3)
|
|
259
|
+
continue;
|
|
260
|
+
const polygon = dedupeSequentialPoints(cycleEdgeIds.flatMap((id, index) => {
|
|
261
|
+
const points = halfEdges.get(id)?.points ?? [];
|
|
262
|
+
return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1);
|
|
263
|
+
}));
|
|
264
|
+
if (polygon.length < 3)
|
|
265
|
+
continue;
|
|
266
|
+
const signedArea = polygonArea(polygon);
|
|
267
|
+
if (signedArea <= 0)
|
|
268
|
+
continue;
|
|
269
|
+
if (signedArea < 0.5 || signedArea > 10000)
|
|
270
|
+
continue;
|
|
271
|
+
const signature = polygonSignature(polygon);
|
|
272
|
+
if (faces.some((face) => polygonSignature(face) === signature))
|
|
273
|
+
continue;
|
|
274
|
+
faces.push(polygon);
|
|
275
|
+
}
|
|
276
|
+
faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)));
|
|
277
|
+
return faces;
|
|
278
|
+
}
|
|
279
|
+
export function resolveWallSurfaceSides(wall, roomPolygons) {
|
|
280
|
+
if (roomPolygons.length === 0) {
|
|
281
|
+
return {
|
|
282
|
+
frontSide: 'unknown',
|
|
283
|
+
backSide: 'unknown',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const frame = getWallDirection(wall);
|
|
287
|
+
const normalLength = Math.hypot(frame.normal.x, frame.normal.y);
|
|
288
|
+
if (normalLength < 1e-9) {
|
|
289
|
+
return {
|
|
290
|
+
frontSide: wall.frontSide,
|
|
291
|
+
backSide: wall.backSide,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const normalX = frame.normal.x / normalLength;
|
|
295
|
+
const normalY = frame.normal.y / normalLength;
|
|
296
|
+
const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16);
|
|
297
|
+
const frontPoint = {
|
|
298
|
+
x: frame.point.x + normalX * sampleDistance,
|
|
299
|
+
y: frame.point.y + normalY * sampleDistance,
|
|
300
|
+
};
|
|
301
|
+
const backPoint = {
|
|
302
|
+
x: frame.point.x - normalX * sampleDistance,
|
|
303
|
+
y: frame.point.y - normalY * sampleDistance,
|
|
304
|
+
};
|
|
305
|
+
const frontInside = pointInAnyPolygon(frontPoint, roomPolygons);
|
|
306
|
+
const backInside = pointInAnyPolygon(backPoint, roomPolygons);
|
|
307
|
+
if (frontInside === backInside) {
|
|
308
|
+
return {
|
|
309
|
+
frontSide: wall.frontSide,
|
|
310
|
+
backSide: wall.backSide,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
frontSide: frontInside ? 'interior' : 'exterior',
|
|
315
|
+
backSide: backInside ? 'interior' : 'exterior',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function nextAutoRoomName(nodes, suffix) {
|
|
319
|
+
let maxIndex = 0;
|
|
320
|
+
for (const node of nodes) {
|
|
321
|
+
const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim());
|
|
322
|
+
if (!match)
|
|
323
|
+
continue;
|
|
324
|
+
const index = Number(match[1]);
|
|
325
|
+
if (Number.isFinite(index)) {
|
|
326
|
+
maxIndex = Math.max(maxIndex, index);
|
|
56
327
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
328
|
+
}
|
|
329
|
+
return `Room ${maxIndex + 1} ${suffix}`;
|
|
330
|
+
}
|
|
331
|
+
function sameTuplePolygon(current, next) {
|
|
332
|
+
return (current.length === next.length &&
|
|
333
|
+
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]));
|
|
334
|
+
}
|
|
335
|
+
function wallGeometrySignature(wall) {
|
|
336
|
+
return [
|
|
337
|
+
wall.id,
|
|
338
|
+
wall.start[0].toFixed(4),
|
|
339
|
+
wall.start[1].toFixed(4),
|
|
340
|
+
wall.end[0].toFixed(4),
|
|
341
|
+
wall.end[1].toFixed(4),
|
|
342
|
+
(wall.thickness ?? 0.2).toFixed(4),
|
|
343
|
+
getClampedWallCurveOffset(wall).toFixed(4),
|
|
344
|
+
].join('|');
|
|
345
|
+
}
|
|
346
|
+
function levelWallSnapshot(walls) {
|
|
347
|
+
return walls.map(wallGeometrySignature).sort().join('||');
|
|
348
|
+
}
|
|
349
|
+
function buildSpace(levelId, polygon) {
|
|
350
|
+
const signature = polygonSignature(polygon);
|
|
351
|
+
return {
|
|
352
|
+
id: `space-${levelId}-${signature.slice(0, 12)}`,
|
|
353
|
+
levelId,
|
|
354
|
+
polygon: polygon.map(pointToTuple),
|
|
355
|
+
wallIds: [],
|
|
356
|
+
isExterior: false,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore) {
|
|
360
|
+
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
|
|
361
|
+
const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
|
|
362
|
+
const detected = roomPolygons
|
|
363
|
+
.map((poly) => ({
|
|
364
|
+
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
|
|
365
|
+
sig: '',
|
|
366
|
+
centroid: { x: 0, y: 0 },
|
|
367
|
+
area: 0,
|
|
368
|
+
bbox: bboxOf([]),
|
|
369
|
+
}))
|
|
370
|
+
.map((room) => ({
|
|
371
|
+
...room,
|
|
372
|
+
sig: polygonSignature(room.poly),
|
|
373
|
+
centroid: polygonCentroid(room.poly),
|
|
374
|
+
area: Math.abs(polygonArea(room.poly)),
|
|
375
|
+
bbox: bboxOf(room.poly),
|
|
376
|
+
}))
|
|
377
|
+
.filter(({ sig }) => !manualSignatures.has(sig));
|
|
378
|
+
const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
|
|
379
|
+
const existingAutoMeta = existingAuto.map((slab) => {
|
|
380
|
+
const poly = slab.polygon.map(pointFromTuple);
|
|
381
|
+
return {
|
|
382
|
+
slab,
|
|
383
|
+
sig: polygonSignature(poly),
|
|
384
|
+
centroid: polygonCentroid(poly),
|
|
385
|
+
area: Math.abs(polygonArea(poly)),
|
|
386
|
+
bbox: bboxOf(poly),
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
const matchedSlabIds = new Set();
|
|
390
|
+
const matchedDetectedIdx = new Set();
|
|
391
|
+
const updatesById = new Map();
|
|
392
|
+
const autoBySignature = new Map();
|
|
393
|
+
for (const entry of existingAutoMeta) {
|
|
394
|
+
autoBySignature.set(entry.sig, entry);
|
|
395
|
+
}
|
|
396
|
+
detected.forEach((room, index) => {
|
|
397
|
+
const existing = autoBySignature.get(room.sig);
|
|
398
|
+
if (!existing)
|
|
399
|
+
return;
|
|
400
|
+
matchedDetectedIdx.add(index);
|
|
401
|
+
matchedSlabIds.add(existing.slab.id);
|
|
402
|
+
updatesById.set(existing.slab.id, room.poly.map(pointToTuple));
|
|
403
|
+
});
|
|
404
|
+
const remainingDetected = detected
|
|
405
|
+
.map((room, index) => ({ room, index }))
|
|
406
|
+
.filter(({ index }) => !matchedDetectedIdx.has(index))
|
|
407
|
+
.sort((a, b) => b.room.area - a.room.area);
|
|
408
|
+
const remainingAuto = existingAutoMeta.filter((entry) => !matchedSlabIds.has(entry.slab.id));
|
|
409
|
+
for (const { room, index } of remainingDetected) {
|
|
410
|
+
let bestMatch = null;
|
|
411
|
+
for (const entry of remainingAuto) {
|
|
412
|
+
if (matchedSlabIds.has(entry.slab.id))
|
|
65
413
|
continue;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
414
|
+
const dx = room.centroid.x - entry.centroid.x;
|
|
415
|
+
const dy = room.centroid.y - entry.centroid.y;
|
|
416
|
+
const dist = Math.hypot(dx, dy);
|
|
417
|
+
const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
|
|
418
|
+
const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
|
|
419
|
+
const overlap = bboxOverlapArea(room.bbox, entry.bbox);
|
|
420
|
+
if (overlap <= 0.0001 && dist > 1.5)
|
|
421
|
+
continue;
|
|
422
|
+
const score = dist + areaPenalty * 0.35;
|
|
423
|
+
if (!bestMatch || score < bestMatch.score) {
|
|
424
|
+
bestMatch = { entry, score };
|
|
74
425
|
}
|
|
75
426
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
427
|
+
if (!bestMatch)
|
|
428
|
+
continue;
|
|
429
|
+
matchedDetectedIdx.add(index);
|
|
430
|
+
matchedSlabIds.add(bestMatch.entry.slab.id);
|
|
431
|
+
updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple));
|
|
432
|
+
}
|
|
433
|
+
const slabsToDelete = existingAuto
|
|
434
|
+
.filter((slab) => !updatesById.has(slab.id))
|
|
435
|
+
.map((slab) => slab.id);
|
|
436
|
+
const slabsToUpdate = existingAuto
|
|
437
|
+
.filter((slab) => updatesById.has(slab.id))
|
|
438
|
+
.flatMap((slab) => {
|
|
439
|
+
const polygon = updatesById.get(slab.id);
|
|
440
|
+
if (!polygon)
|
|
441
|
+
return [];
|
|
442
|
+
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }];
|
|
443
|
+
});
|
|
444
|
+
const plannedSlabsForNaming = [...existingSlabs];
|
|
445
|
+
const slabsToCreate = [];
|
|
446
|
+
for (let index = 0; index < detected.length; index += 1) {
|
|
447
|
+
if (matchedDetectedIdx.has(index))
|
|
448
|
+
continue;
|
|
449
|
+
const room = detected[index];
|
|
450
|
+
if (!room)
|
|
451
|
+
continue;
|
|
452
|
+
const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab');
|
|
453
|
+
plannedSlabsForNaming.push({ name });
|
|
454
|
+
slabsToCreate.push(SlabNode.parse({
|
|
455
|
+
name,
|
|
456
|
+
polygon: room.poly.map(pointToTuple),
|
|
457
|
+
holes: [],
|
|
458
|
+
elevation: DEFAULT_AUTO_SLAB_ELEVATION,
|
|
459
|
+
autoFromWalls: true,
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
if (slabsToDelete.length > 0) {
|
|
463
|
+
sceneStore.getState().deleteNodes(slabsToDelete);
|
|
464
|
+
}
|
|
465
|
+
if (slabsToUpdate.length > 0) {
|
|
466
|
+
sceneStore.getState().updateNodes(slabsToUpdate);
|
|
467
|
+
}
|
|
468
|
+
if (slabsToCreate.length > 0) {
|
|
469
|
+
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore) {
|
|
473
|
+
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
|
|
474
|
+
const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
|
|
475
|
+
const detected = roomPolygons
|
|
476
|
+
.map((poly) => ({
|
|
477
|
+
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
|
|
478
|
+
sig: '',
|
|
479
|
+
centroid: { x: 0, y: 0 },
|
|
480
|
+
area: 0,
|
|
481
|
+
bbox: bboxOf([]),
|
|
482
|
+
}))
|
|
483
|
+
.map((room) => ({
|
|
484
|
+
...room,
|
|
485
|
+
sig: polygonSignature(room.poly),
|
|
486
|
+
centroid: polygonCentroid(room.poly),
|
|
487
|
+
area: Math.abs(polygonArea(room.poly)),
|
|
488
|
+
bbox: bboxOf(room.poly),
|
|
489
|
+
}))
|
|
490
|
+
.filter(({ sig }) => !manualSignatures.has(sig));
|
|
491
|
+
const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
|
|
492
|
+
const existingAutoMeta = existingAuto.map((ceiling) => {
|
|
493
|
+
const poly = ceiling.polygon.map(pointFromTuple);
|
|
494
|
+
return {
|
|
495
|
+
ceiling,
|
|
496
|
+
sig: polygonSignature(poly),
|
|
497
|
+
centroid: polygonCentroid(poly),
|
|
498
|
+
area: Math.abs(polygonArea(poly)),
|
|
499
|
+
bbox: bboxOf(poly),
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
const matchedCeilingIds = new Set();
|
|
503
|
+
const matchedDetectedIdx = new Set();
|
|
504
|
+
const updatesById = new Map();
|
|
505
|
+
const autoBySignature = new Map();
|
|
506
|
+
for (const entry of existingAutoMeta) {
|
|
507
|
+
autoBySignature.set(entry.sig, entry);
|
|
508
|
+
}
|
|
509
|
+
detected.forEach((room, index) => {
|
|
510
|
+
const existing = autoBySignature.get(room.sig);
|
|
511
|
+
if (!existing)
|
|
512
|
+
return;
|
|
513
|
+
matchedDetectedIdx.add(index);
|
|
514
|
+
matchedCeilingIds.add(existing.ceiling.id);
|
|
515
|
+
updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple));
|
|
516
|
+
});
|
|
517
|
+
const remainingDetected = detected
|
|
518
|
+
.map((room, index) => ({ room, index }))
|
|
519
|
+
.filter(({ index }) => !matchedDetectedIdx.has(index))
|
|
520
|
+
.sort((a, b) => b.room.area - a.room.area);
|
|
521
|
+
const remainingAuto = existingAutoMeta.filter((entry) => !matchedCeilingIds.has(entry.ceiling.id));
|
|
522
|
+
for (const { room, index } of remainingDetected) {
|
|
523
|
+
let bestMatch = null;
|
|
524
|
+
for (const entry of remainingAuto) {
|
|
525
|
+
if (matchedCeilingIds.has(entry.ceiling.id))
|
|
526
|
+
continue;
|
|
527
|
+
const dx = room.centroid.x - entry.centroid.x;
|
|
528
|
+
const dy = room.centroid.y - entry.centroid.y;
|
|
529
|
+
const dist = Math.hypot(dx, dy);
|
|
530
|
+
const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
|
|
531
|
+
const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
|
|
532
|
+
const overlap = bboxOverlapArea(room.bbox, entry.bbox);
|
|
533
|
+
if (overlap <= 0.0001 && dist > 1.5)
|
|
534
|
+
continue;
|
|
535
|
+
const score = dist + areaPenalty * 0.35;
|
|
536
|
+
if (!bestMatch || score < bestMatch.score) {
|
|
537
|
+
bestMatch = { entry, score };
|
|
86
538
|
}
|
|
87
539
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
540
|
+
if (!bestMatch)
|
|
541
|
+
continue;
|
|
542
|
+
matchedDetectedIdx.add(index);
|
|
543
|
+
matchedCeilingIds.add(bestMatch.entry.ceiling.id);
|
|
544
|
+
updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple));
|
|
545
|
+
}
|
|
546
|
+
const ceilingsToDelete = existingAuto
|
|
547
|
+
.filter((ceiling) => !updatesById.has(ceiling.id))
|
|
548
|
+
.map((ceiling) => ceiling.id);
|
|
549
|
+
const ceilingsToUpdate = existingAuto
|
|
550
|
+
.filter((ceiling) => updatesById.has(ceiling.id))
|
|
551
|
+
.flatMap((ceiling) => {
|
|
552
|
+
const polygon = updatesById.get(ceiling.id);
|
|
553
|
+
if (!polygon)
|
|
554
|
+
return [];
|
|
555
|
+
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }];
|
|
93
556
|
});
|
|
94
|
-
|
|
557
|
+
const plannedCeilingsForNaming = [...existingCeilings];
|
|
558
|
+
const ceilingsToCreate = [];
|
|
559
|
+
for (let index = 0; index < detected.length; index += 1) {
|
|
560
|
+
if (matchedDetectedIdx.has(index))
|
|
561
|
+
continue;
|
|
562
|
+
const room = detected[index];
|
|
563
|
+
if (!room)
|
|
564
|
+
continue;
|
|
565
|
+
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling');
|
|
566
|
+
plannedCeilingsForNaming.push({ name });
|
|
567
|
+
ceilingsToCreate.push(CeilingNode.parse({
|
|
568
|
+
name,
|
|
569
|
+
polygon: room.poly.map(pointToTuple),
|
|
570
|
+
holes: [],
|
|
571
|
+
height: DEFAULT_AUTO_CEILING_HEIGHT,
|
|
572
|
+
autoFromWalls: true,
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
if (ceilingsToDelete.length > 0) {
|
|
576
|
+
sceneStore.getState().deleteNodes(ceilingsToDelete);
|
|
577
|
+
}
|
|
578
|
+
if (ceilingsToUpdate.length > 0) {
|
|
579
|
+
sceneStore.getState().updateNodes(ceilingsToUpdate);
|
|
580
|
+
}
|
|
581
|
+
if (ceilingsToCreate.length > 0) {
|
|
582
|
+
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function detectSpacesFromWalls(levelId, walls) {
|
|
586
|
+
const roomPolygons = extractRoomPolygons(walls);
|
|
587
|
+
const wallUpdates = walls.map((wall) => ({
|
|
588
|
+
wallId: wall.id,
|
|
589
|
+
...resolveWallSurfaceSides(wall, roomPolygons),
|
|
590
|
+
}));
|
|
591
|
+
return {
|
|
592
|
+
roomPolygons,
|
|
593
|
+
spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
|
|
594
|
+
wallUpdates,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
export function detectSpacesForLevel(levelId, walls) {
|
|
598
|
+
return detectSpacesFromWalls(levelId, walls);
|
|
95
599
|
}
|
|
96
|
-
/**
|
|
97
|
-
* Runs space detection for the given levels
|
|
98
|
-
* Updates wall nodes and editor spaces
|
|
99
|
-
*/
|
|
100
600
|
function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
|
|
101
|
-
const {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
for (const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (walls.length === 0) {
|
|
108
|
-
// No walls - clear any spaces for this level
|
|
109
|
-
continue;
|
|
601
|
+
const { updateNodes } = sceneStore.getState();
|
|
602
|
+
const existingSpaces = editorStore.getState().spaces;
|
|
603
|
+
const nextSpaces = {};
|
|
604
|
+
for (const [spaceId, space] of Object.entries(existingSpaces)) {
|
|
605
|
+
if (!levelIds.includes(space.levelId)) {
|
|
606
|
+
nextSpaces[spaceId] = space;
|
|
110
607
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
608
|
+
}
|
|
609
|
+
for (const levelId of levelIds) {
|
|
610
|
+
const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
|
|
611
|
+
const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
|
|
612
|
+
const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
|
|
613
|
+
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
|
|
614
|
+
const changedWallUpdates = wallUpdates.filter((update) => {
|
|
115
615
|
const wall = nodes[update.wallId];
|
|
116
|
-
|
|
117
|
-
|
|
616
|
+
return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide);
|
|
617
|
+
});
|
|
618
|
+
if (changedWallUpdates.length > 0) {
|
|
619
|
+
updateNodes(changedWallUpdates.map((update) => ({
|
|
620
|
+
id: update.wallId,
|
|
621
|
+
data: {
|
|
118
622
|
frontSide: update.frontSide,
|
|
119
623
|
backSide: update.backSide,
|
|
120
|
-
}
|
|
121
|
-
}
|
|
624
|
+
},
|
|
625
|
+
})));
|
|
122
626
|
}
|
|
123
|
-
|
|
627
|
+
syncAutoSlabsForLevel(levelId, roomPolygons, slabs.map((slab) => SlabNode.parse(slab)), sceneStore);
|
|
628
|
+
syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore);
|
|
124
629
|
for (const space of spaces) {
|
|
125
|
-
|
|
630
|
+
nextSpaces[space.id] = space;
|
|
126
631
|
}
|
|
127
632
|
}
|
|
128
|
-
|
|
129
|
-
setSpaces(allSpaces);
|
|
130
|
-
}
|
|
131
|
-
// ============================================================================
|
|
132
|
-
// MAIN DETECTION FUNCTION
|
|
133
|
-
// ============================================================================
|
|
134
|
-
/**
|
|
135
|
-
* Detects spaces for a level by flood-filling a grid from the edges
|
|
136
|
-
* Returns wall side updates and detected spaces
|
|
137
|
-
*/
|
|
138
|
-
export function detectSpacesForLevel(levelId, walls, gridResolution = 0.5) {
|
|
139
|
-
if (walls.length === 0) {
|
|
140
|
-
return { wallUpdates: [], spaces: [] };
|
|
141
|
-
}
|
|
142
|
-
// Build grid from walls
|
|
143
|
-
const grid = buildGrid(walls, gridResolution);
|
|
144
|
-
// Flood fill from edges to mark exterior
|
|
145
|
-
floodFillFromEdges(grid);
|
|
146
|
-
// Find interior spaces
|
|
147
|
-
const interiorSpaces = findInteriorSpaces(grid, levelId);
|
|
148
|
-
// Assign wall sides
|
|
149
|
-
const wallUpdates = assignWallSides(walls, grid);
|
|
150
|
-
return {
|
|
151
|
-
wallUpdates,
|
|
152
|
-
spaces: interiorSpaces,
|
|
153
|
-
};
|
|
633
|
+
editorStore.getState().setSpaces(nextSpaces);
|
|
154
634
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
maxZ = Math.max(maxZ, wall.start[1], wall.end[1]);
|
|
172
|
-
}
|
|
173
|
-
// Add padding around bounds
|
|
174
|
-
const padding = 2; // meters
|
|
175
|
-
minX -= padding;
|
|
176
|
-
minZ -= padding;
|
|
177
|
-
maxX += padding;
|
|
178
|
-
maxZ += padding;
|
|
179
|
-
const width = Math.ceil((maxX - minX) / resolution);
|
|
180
|
-
const height = Math.ceil((maxZ - minZ) / resolution);
|
|
181
|
-
const grid = {
|
|
182
|
-
cells: new Map(),
|
|
183
|
-
resolution,
|
|
184
|
-
minX,
|
|
185
|
-
minZ,
|
|
186
|
-
maxX,
|
|
187
|
-
maxZ,
|
|
188
|
-
width,
|
|
189
|
-
height,
|
|
190
|
-
};
|
|
191
|
-
// Mark wall cells
|
|
192
|
-
for (const wall of walls) {
|
|
193
|
-
markWallCells(grid, wall);
|
|
194
|
-
}
|
|
195
|
-
return grid;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Marks all grid cells occupied by a wall using line rasterization
|
|
199
|
-
* Uses denser sampling to ensure continuous barriers
|
|
200
|
-
*/
|
|
201
|
-
function markWallCells(grid, wall) {
|
|
202
|
-
const thickness = wall.thickness ?? 0.2;
|
|
203
|
-
const halfThickness = thickness / 2;
|
|
204
|
-
const [x1, z1] = wall.start;
|
|
205
|
-
const [x2, z2] = wall.end;
|
|
206
|
-
// Wall direction vector
|
|
207
|
-
const dx = x2 - x1;
|
|
208
|
-
const dz = z2 - z1;
|
|
209
|
-
const len = Math.sqrt(dx * dx + dz * dz);
|
|
210
|
-
if (len < 0.001)
|
|
211
|
-
return;
|
|
212
|
-
// Normalized direction and perpendicular
|
|
213
|
-
const dirX = dx / len;
|
|
214
|
-
const dirZ = dz / len;
|
|
215
|
-
const perpX = -dirZ;
|
|
216
|
-
const perpZ = dirX;
|
|
217
|
-
// Denser sampling along wall length (at least 2x resolution)
|
|
218
|
-
const steps = Math.max(Math.ceil(len / (grid.resolution * 0.5)), 2);
|
|
219
|
-
for (let i = 0; i <= steps; i++) {
|
|
220
|
-
const t = i / steps;
|
|
221
|
-
const x = x1 + dx * t;
|
|
222
|
-
const z = z1 + dz * t;
|
|
223
|
-
// Denser sampling across wall thickness
|
|
224
|
-
const thicknessSteps = Math.max(Math.ceil(thickness / (grid.resolution * 0.5)), 2);
|
|
225
|
-
for (let j = 0; j <= thicknessSteps; j++) {
|
|
226
|
-
const offset = (j / thicknessSteps - 0.5) * thickness;
|
|
227
|
-
const wx = x + perpX * offset;
|
|
228
|
-
const wz = z + perpZ * offset;
|
|
229
|
-
const key = getCellKey(grid, wx, wz);
|
|
230
|
-
if (key) {
|
|
231
|
-
grid.cells.set(key, 'wall');
|
|
635
|
+
export function initSpaceDetectionSync(sceneStore, editorStore) {
|
|
636
|
+
const previousSnapshots = new Map();
|
|
637
|
+
let isProcessing = false;
|
|
638
|
+
const unsubscribe = sceneStore.subscribe((state) => {
|
|
639
|
+
if (isProcessing)
|
|
640
|
+
return;
|
|
641
|
+
if (getSceneHistoryPauseDepth() > 0)
|
|
642
|
+
return;
|
|
643
|
+
const nodes = state.nodes;
|
|
644
|
+
const wallsByLevel = new Map();
|
|
645
|
+
for (const node of Object.values(nodes)) {
|
|
646
|
+
if (node && node.type === 'wall' && node.parentId) {
|
|
647
|
+
const levelId = node.parentId;
|
|
648
|
+
const levelWalls = wallsByLevel.get(levelId) ?? [];
|
|
649
|
+
levelWalls.push(node);
|
|
650
|
+
wallsByLevel.set(levelId, levelWalls);
|
|
232
651
|
}
|
|
233
652
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// FLOOD FILL
|
|
238
|
-
// ============================================================================
|
|
239
|
-
/**
|
|
240
|
-
* Flood fills from all edge cells to mark exterior space
|
|
241
|
-
*/
|
|
242
|
-
function floodFillFromEdges(grid) {
|
|
243
|
-
const queue = [];
|
|
244
|
-
// Add all edge cells to queue
|
|
245
|
-
for (let x = 0; x < grid.width; x++) {
|
|
246
|
-
for (let z = 0; z < grid.height; z++) {
|
|
247
|
-
// Only process edge cells
|
|
248
|
-
if (x === 0 || x === grid.width - 1 || z === 0 || z === grid.height - 1) {
|
|
249
|
-
const key = getCellKeyFromIndex(x, z, grid.width);
|
|
250
|
-
const cell = grid.cells.get(key);
|
|
251
|
-
if (cell !== 'wall') {
|
|
252
|
-
grid.cells.set(key, 'exterior');
|
|
253
|
-
queue.push(key);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
653
|
+
const currentSnapshots = new Map();
|
|
654
|
+
for (const [levelId, walls] of wallsByLevel.entries()) {
|
|
655
|
+
currentSnapshots.set(levelId, levelWallSnapshot(walls));
|
|
256
656
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const [x, z] = parseCellKey(key);
|
|
262
|
-
// Check 4 neighbors
|
|
263
|
-
const neighbors = [
|
|
264
|
-
[x + 1, z],
|
|
265
|
-
[x - 1, z],
|
|
266
|
-
[x, z + 1],
|
|
267
|
-
[x, z - 1],
|
|
268
|
-
];
|
|
269
|
-
for (const [nx, nz] of neighbors) {
|
|
270
|
-
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height)
|
|
271
|
-
continue;
|
|
272
|
-
const nKey = getCellKeyFromIndex(nx, nz, grid.width);
|
|
273
|
-
const cell = grid.cells.get(nKey);
|
|
274
|
-
if (cell !== 'wall' && cell !== 'exterior') {
|
|
275
|
-
grid.cells.set(nKey, 'exterior');
|
|
276
|
-
queue.push(nKey);
|
|
657
|
+
const levelsToUpdate = new Set();
|
|
658
|
+
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
|
|
659
|
+
if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {
|
|
660
|
+
levelsToUpdate.add(levelId);
|
|
277
661
|
}
|
|
278
662
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
// ============================================================================
|
|
284
|
-
/**
|
|
285
|
-
* Finds all interior spaces (connected regions not marked as exterior or wall)
|
|
286
|
-
*/
|
|
287
|
-
function findInteriorSpaces(grid, levelId) {
|
|
288
|
-
const spaces = [];
|
|
289
|
-
const visited = new Set();
|
|
290
|
-
// Scan grid for interior cells
|
|
291
|
-
for (let x = 0; x < grid.width; x++) {
|
|
292
|
-
for (let z = 0; z < grid.height; z++) {
|
|
293
|
-
const key = getCellKeyFromIndex(x, z, grid.width);
|
|
294
|
-
if (visited.has(key))
|
|
295
|
-
continue;
|
|
296
|
-
const cell = grid.cells.get(key);
|
|
297
|
-
if (cell === 'wall' || cell === 'exterior') {
|
|
298
|
-
visited.add(key);
|
|
299
|
-
continue;
|
|
663
|
+
if (levelsToUpdate.size === 0) {
|
|
664
|
+
previousSnapshots.clear();
|
|
665
|
+
for (const [levelId, snapshot] of currentSnapshots.entries()) {
|
|
666
|
+
previousSnapshots.set(levelId, snapshot);
|
|
300
667
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
[cx - 1, cz],
|
|
314
|
-
[cx, cz + 1],
|
|
315
|
-
[cx, cz - 1],
|
|
316
|
-
];
|
|
317
|
-
for (const [nx, nz] of neighbors) {
|
|
318
|
-
if (nx < 0 || nx >= grid.width || nz < 0 || nz >= grid.height)
|
|
319
|
-
continue;
|
|
320
|
-
const nKey = getCellKeyFromIndex(nx, nz, grid.width);
|
|
321
|
-
if (visited.has(nKey))
|
|
322
|
-
continue;
|
|
323
|
-
const nCell = grid.cells.get(nKey);
|
|
324
|
-
if (nCell === 'wall' || nCell === 'exterior') {
|
|
325
|
-
visited.add(nKey);
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
visited.add(nKey);
|
|
329
|
-
spaceCells.add(nKey);
|
|
330
|
-
// Mark as interior in grid
|
|
331
|
-
grid.cells.set(nKey, 'interior');
|
|
332
|
-
queue.push(nKey);
|
|
333
|
-
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
isProcessing = true;
|
|
671
|
+
pauseSceneHistory(sceneStore);
|
|
672
|
+
try {
|
|
673
|
+
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
resumeSceneHistory(sceneStore);
|
|
677
|
+
previousSnapshots.clear();
|
|
678
|
+
for (const [levelId, snapshot] of currentSnapshots.entries()) {
|
|
679
|
+
previousSnapshots.set(levelId, snapshot);
|
|
334
680
|
}
|
|
335
|
-
|
|
336
|
-
const polygon = extractPolygonFromCells(spaceCells, grid);
|
|
337
|
-
spaces.push({
|
|
338
|
-
id: `space-${spaces.length}`,
|
|
339
|
-
levelId,
|
|
340
|
-
polygon,
|
|
341
|
-
wallIds: [],
|
|
342
|
-
isExterior: false,
|
|
343
|
-
});
|
|
681
|
+
isProcessing = false;
|
|
344
682
|
}
|
|
345
|
-
}
|
|
346
|
-
return
|
|
347
|
-
}
|
|
348
|
-
/**
|
|
349
|
-
* Extracts a simplified polygon from a set of grid cells
|
|
350
|
-
* Returns bounding box for now (can be improved to trace actual boundary)
|
|
351
|
-
*/
|
|
352
|
-
function extractPolygonFromCells(cells, grid) {
|
|
353
|
-
let minX = Number.POSITIVE_INFINITY;
|
|
354
|
-
let minZ = Number.POSITIVE_INFINITY;
|
|
355
|
-
let maxX = Number.NEGATIVE_INFINITY;
|
|
356
|
-
let maxZ = Number.NEGATIVE_INFINITY;
|
|
357
|
-
for (const key of cells) {
|
|
358
|
-
const [x, z] = parseCellKey(key);
|
|
359
|
-
const worldX = grid.minX + x * grid.resolution;
|
|
360
|
-
const worldZ = grid.minZ + z * grid.resolution;
|
|
361
|
-
minX = Math.min(minX, worldX);
|
|
362
|
-
minZ = Math.min(minZ, worldZ);
|
|
363
|
-
maxX = Math.max(maxX, worldX);
|
|
364
|
-
maxZ = Math.max(maxZ, worldZ);
|
|
365
|
-
}
|
|
366
|
-
// Return bounding box as polygon
|
|
367
|
-
return [
|
|
368
|
-
[minX, minZ],
|
|
369
|
-
[maxX, minZ],
|
|
370
|
-
[maxX, maxZ],
|
|
371
|
-
[minX, maxZ],
|
|
372
|
-
];
|
|
373
|
-
}
|
|
374
|
-
// ============================================================================
|
|
375
|
-
// WALL SIDE ASSIGNMENT
|
|
376
|
-
// ============================================================================
|
|
377
|
-
/**
|
|
378
|
-
* Assigns front/back side classification to each wall based on grid
|
|
379
|
-
*/
|
|
380
|
-
function assignWallSides(walls, grid) {
|
|
381
|
-
const updates = [];
|
|
382
|
-
for (const wall of walls) {
|
|
383
|
-
const thickness = wall.thickness ?? 0.2;
|
|
384
|
-
const [x1, z1] = wall.start;
|
|
385
|
-
const [x2, z2] = wall.end;
|
|
386
|
-
// Wall direction and perpendicular
|
|
387
|
-
const dx = x2 - x1;
|
|
388
|
-
const dz = z2 - z1;
|
|
389
|
-
const len = Math.sqrt(dx * dx + dz * dz);
|
|
390
|
-
if (len < 0.001)
|
|
391
|
-
continue;
|
|
392
|
-
const perpX = -dz / len;
|
|
393
|
-
const perpZ = dx / len;
|
|
394
|
-
// Sample point on front side (perpendicular direction)
|
|
395
|
-
const midX = (x1 + x2) / 2;
|
|
396
|
-
const midZ = (z1 + z2) / 2;
|
|
397
|
-
// Sample beyond wall thickness + one full grid cell to ensure we're in the next cell
|
|
398
|
-
const offset = thickness / 2 + grid.resolution;
|
|
399
|
-
const frontX = midX + perpX * offset;
|
|
400
|
-
const frontZ = midZ + perpZ * offset;
|
|
401
|
-
const backX = midX - perpX * offset;
|
|
402
|
-
const backZ = midZ - perpZ * offset;
|
|
403
|
-
// Check what space each side faces
|
|
404
|
-
const frontKey = getCellKey(grid, frontX, frontZ);
|
|
405
|
-
const backKey = getCellKey(grid, backX, backZ);
|
|
406
|
-
const frontCell = frontKey ? grid.cells.get(frontKey) : undefined;
|
|
407
|
-
const backCell = backKey ? grid.cells.get(backKey) : undefined;
|
|
408
|
-
const frontSide = classifySide(frontCell);
|
|
409
|
-
const backSide = classifySide(backCell);
|
|
410
|
-
updates.push({
|
|
411
|
-
wallId: wall.id,
|
|
412
|
-
frontSide,
|
|
413
|
-
backSide,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
return updates;
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Classifies a cell as interior, exterior, or unknown
|
|
420
|
-
*/
|
|
421
|
-
function classifySide(cell) {
|
|
422
|
-
if (cell === 'exterior')
|
|
423
|
-
return 'exterior';
|
|
424
|
-
if (cell === 'interior')
|
|
425
|
-
return 'interior';
|
|
426
|
-
// Wall cells or out-of-bounds (undefined) are unknown
|
|
427
|
-
return 'unknown';
|
|
428
|
-
}
|
|
429
|
-
// ============================================================================
|
|
430
|
-
// GRID UTILITIES
|
|
431
|
-
// ============================================================================
|
|
432
|
-
/**
|
|
433
|
-
* Gets grid cell key from world coordinates
|
|
434
|
-
*/
|
|
435
|
-
function getCellKey(grid, x, z) {
|
|
436
|
-
const cellX = Math.floor((x - grid.minX) / grid.resolution);
|
|
437
|
-
const cellZ = Math.floor((z - grid.minZ) / grid.resolution);
|
|
438
|
-
if (cellX < 0 || cellX >= grid.width || cellZ < 0 || cellZ >= grid.height) {
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
return `${cellX},${cellZ}`;
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Gets cell key from grid indices
|
|
445
|
-
*/
|
|
446
|
-
function getCellKeyFromIndex(x, z, width) {
|
|
447
|
-
return `${x},${z}`;
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* Parses cell key back to indices
|
|
451
|
-
*/
|
|
452
|
-
function parseCellKey(key) {
|
|
453
|
-
const parts = key.split(',');
|
|
454
|
-
return [Number.parseInt(parts[0], 10), Number.parseInt(parts[1], 10)];
|
|
455
|
-
}
|
|
456
|
-
// ============================================================================
|
|
457
|
-
// WALL CONNECTIVITY DETECTION
|
|
458
|
-
// ============================================================================
|
|
459
|
-
/**
|
|
460
|
-
* Checks if a wall touches any other walls
|
|
461
|
-
* Used to determine if space detection should run
|
|
462
|
-
*/
|
|
683
|
+
});
|
|
684
|
+
return unsubscribe;
|
|
685
|
+
}
|
|
463
686
|
export function wallTouchesOthers(wall, otherWalls) {
|
|
464
|
-
const threshold = 0.1;
|
|
687
|
+
const threshold = 0.1;
|
|
465
688
|
for (const other of otherWalls) {
|
|
466
689
|
if (other.id === wall.id)
|
|
467
690
|
continue;
|
|
468
|
-
// Check if any endpoint of wall is close to any endpoint or segment of other
|
|
469
691
|
if (distanceToSegment(wall.start, other.start, other.end) < threshold ||
|
|
470
692
|
distanceToSegment(wall.end, other.start, other.end) < threshold ||
|
|
471
693
|
distanceToSegment(other.start, wall.start, wall.end) < threshold ||
|
|
@@ -475,27 +697,18 @@ export function wallTouchesOthers(wall, otherWalls) {
|
|
|
475
697
|
}
|
|
476
698
|
return false;
|
|
477
699
|
}
|
|
478
|
-
/**
|
|
479
|
-
* Distance from point to line segment
|
|
480
|
-
*/
|
|
481
700
|
function distanceToSegment(point, segStart, segEnd) {
|
|
482
|
-
const [px,
|
|
483
|
-
const [x1,
|
|
484
|
-
const [x2,
|
|
701
|
+
const [px, py] = point;
|
|
702
|
+
const [x1, y1] = segStart;
|
|
703
|
+
const [x2, y2] = segEnd;
|
|
485
704
|
const dx = x2 - x1;
|
|
486
|
-
const
|
|
487
|
-
const lenSq = dx * dx +
|
|
705
|
+
const dy = y2 - y1;
|
|
706
|
+
const lenSq = dx * dx + dy * dy;
|
|
488
707
|
if (lenSq < 0.0001) {
|
|
489
|
-
|
|
490
|
-
const dpx = px - x1;
|
|
491
|
-
const dpz = pz - z1;
|
|
492
|
-
return Math.sqrt(dpx * dpx + dpz * dpz);
|
|
708
|
+
return Math.hypot(px - x1, py - y1);
|
|
493
709
|
}
|
|
494
|
-
|
|
495
|
-
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq));
|
|
710
|
+
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
|
|
496
711
|
const projX = x1 + t * dx;
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
const distZ = pz - projZ;
|
|
500
|
-
return Math.sqrt(distX * distX + distZ * distZ);
|
|
712
|
+
const projY = y1 + t * dy;
|
|
713
|
+
return Math.hypot(px - projX, py - projY);
|
|
501
714
|
}
|