@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.
Files changed (99) hide show
  1. package/dist/events/bus.d.ts +39 -4
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/events/bus.js +1 -1
  4. package/dist/hooks/scene-registry/scene-registry.d.ts +1 -0
  5. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  6. package/dist/hooks/scene-registry/scene-registry.js +1 -0
  7. package/dist/hooks/spatial-grid/spatial-grid.d.ts +2 -0
  8. package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -1
  9. package/dist/hooks/spatial-grid/spatial-grid.js +43 -20
  10. package/dist/index.d.ts +6 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -1
  13. package/dist/lib/polygon-geometry.d.ts +3 -0
  14. package/dist/lib/polygon-geometry.d.ts.map +1 -0
  15. package/dist/lib/polygon-geometry.js +90 -0
  16. package/dist/lib/space-detection.d.ts +10 -17
  17. package/dist/lib/space-detection.d.ts.map +1 -1
  18. package/dist/lib/space-detection.js +666 -453
  19. package/dist/material-library.d.ts +18 -0
  20. package/dist/material-library.d.ts.map +1 -0
  21. package/dist/material-library.js +603 -0
  22. package/dist/schema/index.d.ts +10 -4
  23. package/dist/schema/index.d.ts.map +1 -1
  24. package/dist/schema/index.js +6 -4
  25. package/dist/schema/material.d.ts +109 -0
  26. package/dist/schema/material.d.ts.map +1 -1
  27. package/dist/schema/material.js +52 -0
  28. package/dist/schema/nodes/ceiling.d.ts +10 -0
  29. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  30. package/dist/schema/nodes/ceiling.js +6 -0
  31. package/dist/schema/nodes/door.d.ts +1 -0
  32. package/dist/schema/nodes/door.d.ts.map +1 -1
  33. package/dist/schema/nodes/fence.d.ts +85 -0
  34. package/dist/schema/nodes/fence.d.ts.map +1 -0
  35. package/dist/schema/nodes/fence.js +34 -0
  36. package/dist/schema/nodes/item.d.ts +2 -2
  37. package/dist/schema/nodes/level.d.ts +1 -1
  38. package/dist/schema/nodes/level.d.ts.map +1 -1
  39. package/dist/schema/nodes/level.js +2 -0
  40. package/dist/schema/nodes/roof-segment.d.ts +2 -0
  41. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  42. package/dist/schema/nodes/roof-segment.js +1 -0
  43. package/dist/schema/nodes/roof.d.ts +108 -0
  44. package/dist/schema/nodes/roof.d.ts.map +1 -1
  45. package/dist/schema/nodes/roof.js +58 -2
  46. package/dist/schema/nodes/site.d.ts +1 -1
  47. package/dist/schema/nodes/slab.d.ts +10 -0
  48. package/dist/schema/nodes/slab.d.ts.map +1 -1
  49. package/dist/schema/nodes/slab.js +7 -0
  50. package/dist/schema/nodes/stair-segment.d.ts +2 -0
  51. package/dist/schema/nodes/stair-segment.d.ts.map +1 -1
  52. package/dist/schema/nodes/stair-segment.js +1 -0
  53. package/dist/schema/nodes/stair.d.ts +164 -0
  54. package/dist/schema/nodes/stair.d.ts.map +1 -1
  55. package/dist/schema/nodes/stair.js +106 -5
  56. package/dist/schema/nodes/surface-hole-metadata.d.ts +10 -0
  57. package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -0
  58. package/dist/schema/nodes/surface-hole-metadata.js +5 -0
  59. package/dist/schema/nodes/wall.d.ts +87 -1
  60. package/dist/schema/nodes/wall.d.ts.map +1 -1
  61. package/dist/schema/nodes/wall.js +45 -4
  62. package/dist/schema/nodes/window.d.ts +1 -0
  63. package/dist/schema/nodes/window.d.ts.map +1 -1
  64. package/dist/schema/types.d.ts +406 -4
  65. package/dist/schema/types.d.ts.map +1 -1
  66. package/dist/schema/types.js +2 -0
  67. package/dist/store/actions/node-actions.d.ts +1 -1
  68. package/dist/store/actions/node-actions.d.ts.map +1 -1
  69. package/dist/store/actions/node-actions.js +175 -0
  70. package/dist/store/history-control.d.ts +14 -0
  71. package/dist/store/history-control.d.ts.map +1 -0
  72. package/dist/store/history-control.js +22 -0
  73. package/dist/store/use-scene.d.ts.map +1 -1
  74. package/dist/store/use-scene.js +249 -3
  75. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  76. package/dist/systems/ceiling/ceiling-system.js +7 -0
  77. package/dist/systems/fence/fence-system.d.ts +2 -0
  78. package/dist/systems/fence/fence-system.d.ts.map +1 -0
  79. package/dist/systems/fence/fence-system.js +187 -0
  80. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  81. package/dist/systems/roof/roof-system.js +31 -1
  82. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  83. package/dist/systems/slab/slab-system.js +45 -8
  84. package/dist/systems/stair/stair-opening-sync.d.ts +6 -0
  85. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -0
  86. package/dist/systems/stair/stair-opening-sync.js +515 -0
  87. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  88. package/dist/systems/stair/stair-system.js +432 -10
  89. package/dist/systems/wall/wall-curve.d.ts +43 -0
  90. package/dist/systems/wall/wall-curve.d.ts.map +1 -0
  91. package/dist/systems/wall/wall-curve.js +176 -0
  92. package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
  93. package/dist/systems/wall/wall-footprint.js +16 -2
  94. package/dist/systems/wall/wall-mitering.d.ts +7 -0
  95. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  96. package/dist/systems/wall/wall-mitering.js +76 -3
  97. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  98. package/dist/systems/wall/wall-system.js +202 -2
  99. package/package.json +3 -3
@@ -1,471 +1,693 @@
1
- // ============================================================================
2
- // SYNC INITIALIZATION
3
- // ============================================================================
4
- /**
5
- * Initializes space detection sync with scene and editor stores
6
- * Call this once during app initialization
7
- */
8
- export function initSpaceDetectionSync(sceneStore, // useScene store
9
- editorStore) {
10
- const prevWallsByLevel = new Map();
11
- let isProcessing = false; // Prevent re-entrant calls
12
- // Subscribe to scene changes (standard Zustand subscribe, not selector-based)
13
- const unsubscribe = sceneStore.subscribe((state) => {
14
- // Skip if already processing to avoid infinite loops
15
- if (isProcessing)
16
- return;
17
- const nodes = state.nodes;
18
- const currentWallsByLevel = new Map();
19
- // Group walls by level
20
- for (const node of Object.values(nodes)) {
21
- if (node.type === 'wall' && node.parentId) {
22
- const levelId = node.parentId;
23
- if (!currentWallsByLevel.has(levelId)) {
24
- currentWallsByLevel.set(levelId, new Set());
25
- }
26
- currentWallsByLevel.get(levelId)?.add(node.id);
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
- // Check each level for changes
30
- const levelsToUpdate = new Set();
31
- // Check for new walls (created)
32
- for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
33
- const prevWallIds = prevWallsByLevel.get(levelId);
34
- if (!prevWallIds) {
35
- // New level with walls - run detection if there are multiple walls
36
- if (wallIds.size > 1) {
37
- levelsToUpdate.add(levelId);
38
- }
39
- continue;
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
- // Find newly added walls
42
- for (const wallId of wallIds) {
43
- if (!prevWallIds.has(wallId)) {
44
- // Wall was added - check if it touches other walls
45
- const wall = nodes[wallId];
46
- const otherWalls = Array.from(wallIds)
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
- // Check for deleted walls
58
- for (const [levelId, prevWallIds] of prevWallsByLevel.entries()) {
59
- const currentWallIds = currentWallsByLevel.get(levelId);
60
- if (!currentWallIds) {
61
- // All walls deleted from level - clear spaces
62
- if (prevWallIds.size > 0) {
63
- levelsToUpdate.add(levelId);
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
- // Check if any walls were deleted
68
- for (const wallId of prevWallIds) {
69
- if (!currentWallIds.has(wallId)) {
70
- // Wall was deleted - run detection
71
- levelsToUpdate.add(levelId);
72
- break;
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
- // Run detection for affected levels
77
- if (levelsToUpdate.size > 0) {
78
- isProcessing = true;
79
- sceneStore.temporal.getState().pause();
80
- try {
81
- runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes);
82
- }
83
- finally {
84
- sceneStore.temporal.getState().resume();
85
- isProcessing = false;
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
- // Update previous walls reference
89
- prevWallsByLevel.clear();
90
- for (const [levelId, wallIds] of currentWallsByLevel.entries()) {
91
- prevWallsByLevel.set(levelId, wallIds);
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
- return unsubscribe;
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 { updateNode } = sceneStore.getState();
102
- const { setSpaces } = editorStore.getState();
103
- const allSpaces = {};
104
- for (const levelId of levelIds) {
105
- // Get walls for this level
106
- const walls = Object.values(nodes).filter((node) => node.type === 'wall' && node.parentId === levelId);
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
- // Run detection
112
- const { wallUpdates, spaces } = detectSpacesForLevel(levelId, walls);
113
- // Update wall nodes (only if values changed to avoid infinite loop)
114
- for (const update of wallUpdates) {
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
- if (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) {
117
- updateNode(update.wallId, {
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
- // Store spaces
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
- allSpaces[space.id] = space;
630
+ nextSpaces[space.id] = space;
126
631
  }
127
632
  }
128
- // Update editor spaces
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
- // GRID BUILDING
157
- // ============================================================================
158
- /**
159
- * Builds a discrete grid and marks cells occupied by walls
160
- */
161
- function buildGrid(walls, resolution) {
162
- // Find bounds
163
- let minX = Number.POSITIVE_INFINITY;
164
- let minZ = Number.POSITIVE_INFINITY;
165
- let maxX = Number.NEGATIVE_INFINITY;
166
- let maxZ = Number.NEGATIVE_INFINITY;
167
- for (const wall of walls) {
168
- minX = Math.min(minX, wall.start[0], wall.end[0]);
169
- minZ = Math.min(minZ, wall.start[1], wall.end[1]);
170
- maxX = Math.max(maxX, wall.start[0], wall.end[0]);
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
- // Flood fill
259
- while (queue.length > 0) {
260
- const key = queue.shift();
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
- // INTERIOR SPACE DETECTION
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
- // Found interior cell - flood fill to find full space
302
- const spaceCells = new Set();
303
- const queue = [key];
304
- visited.add(key);
305
- spaceCells.add(key);
306
- // Mark the seed cell as interior in the grid
307
- grid.cells.set(key, 'interior');
308
- while (queue.length > 0) {
309
- const curKey = queue.shift();
310
- const [cx, cz] = parseCellKey(curKey);
311
- const neighbors = [
312
- [cx + 1, cz],
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
- // Create space from cells
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 spaces;
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; // 10cm connection threshold
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, pz] = point;
483
- const [x1, z1] = segStart;
484
- const [x2, z2] = segEnd;
701
+ const [px, py] = point;
702
+ const [x1, y1] = segStart;
703
+ const [x2, y2] = segEnd;
485
704
  const dx = x2 - x1;
486
- const dz = z2 - z1;
487
- const lenSq = dx * dx + dz * dz;
705
+ const dy = y2 - y1;
706
+ const lenSq = dx * dx + dy * dy;
488
707
  if (lenSq < 0.0001) {
489
- // Segment is a point
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
- // Project point onto line
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 projZ = z1 + t * dz;
498
- const distX = px - projX;
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
  }