@pascal-app/core 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
  2. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  3. package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -1
  7. package/dist/lib/room-topology-index.d.ts +50 -0
  8. package/dist/lib/room-topology-index.d.ts.map +1 -0
  9. package/dist/lib/room-topology-index.js +237 -0
  10. package/dist/lib/space-detection.d.ts +29 -3
  11. package/dist/lib/space-detection.d.ts.map +1 -1
  12. package/dist/lib/space-detection.js +858 -242
  13. package/dist/registry/index.d.ts +3 -2
  14. package/dist/registry/index.d.ts.map +1 -1
  15. package/dist/registry/index.js +2 -1
  16. package/dist/registry/registry.d.ts +17 -1
  17. package/dist/registry/registry.d.ts.map +1 -1
  18. package/dist/registry/registry.js +74 -0
  19. package/dist/registry/types.d.ts +35 -0
  20. package/dist/registry/types.d.ts.map +1 -1
  21. package/dist/registry/use-registry-version.d.ts +11 -0
  22. package/dist/registry/use-registry-version.d.ts.map +1 -0
  23. package/dist/registry/use-registry-version.js +15 -0
  24. package/dist/schema/index.d.ts +1 -0
  25. package/dist/schema/index.d.ts.map +1 -1
  26. package/dist/schema/index.js +2 -0
  27. package/dist/schema/nodes/level.d.ts +3 -0
  28. package/dist/schema/nodes/level.d.ts.map +1 -1
  29. package/dist/schema/nodes/level.js +9 -0
  30. package/dist/schema/types.d.ts +1 -0
  31. package/dist/schema/types.d.ts.map +1 -1
  32. package/dist/services/index.d.ts +1 -1
  33. package/dist/services/index.d.ts.map +1 -1
  34. package/dist/services/index.js +1 -1
  35. package/dist/services/storey.d.ts +3 -10
  36. package/dist/services/storey.d.ts.map +1 -1
  37. package/dist/services/storey.js +45 -15
  38. package/dist/store/actions/node-actions.d.ts +8 -4
  39. package/dist/store/actions/node-actions.d.ts.map +1 -1
  40. package/dist/store/actions/node-actions.js +31 -4
  41. package/dist/store/history-control.d.ts +5 -0
  42. package/dist/store/history-control.d.ts.map +1 -1
  43. package/dist/store/history-control.js +68 -6
  44. package/dist/store/use-scene.d.ts.map +1 -1
  45. package/dist/store/use-scene.js +86 -172
  46. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  47. package/dist/systems/elevator/elevator-service.js +16 -11
  48. package/dist/systems/slab/slab-support.d.ts +0 -31
  49. package/dist/systems/slab/slab-support.d.ts.map +1 -1
  50. package/dist/systems/slab/slab-support.js +26 -4
  51. package/dist/systems/stair/stair-opening-preview.d.ts +1 -0
  52. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  53. package/dist/systems/stair/stair-rise.d.ts.map +1 -1
  54. package/dist/systems/stair/stair-rise.js +4 -2
  55. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  56. package/dist/systems/wall/wall-mitering.js +88 -9
  57. package/dist/systems/wall/wall-topology.d.ts +53 -0
  58. package/dist/systems/wall/wall-topology.d.ts.map +1 -0
  59. package/dist/systems/wall/wall-topology.js +414 -0
  60. package/dist/utils/clone-scene-graph.d.ts +2 -0
  61. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  62. package/dist/utils/clone-scene-graph.js +13 -2
  63. package/dist/utils/heal-scene-graph.d.ts +5 -0
  64. package/dist/utils/heal-scene-graph.d.ts.map +1 -1
  65. package/dist/utils/heal-scene-graph.js +46 -1
  66. package/dist/utils/scene-migrations.d.ts +4 -0
  67. package/dist/utils/scene-migrations.d.ts.map +1 -0
  68. package/dist/utils/scene-migrations.js +7 -0
  69. package/dist/utils/vertical-scene-migration.d.ts +13 -0
  70. package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
  71. package/dist/utils/vertical-scene-migration.js +179 -0
  72. package/package.json +4 -4
@@ -1,12 +1,13 @@
1
1
  import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id';
2
2
  import { CeilingNode, SlabNode, ZoneNode, } from '../schema';
3
3
  import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height';
4
- import { CEILING_CLAMP_MARGIN, findLevelAboveId, getCeilingClampBound, getLevelElevations, getStoredLevelHeight, } from '../services/storey';
5
- import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
4
+ import { CEILING_CLAMP_MARGIN, findLevelAboveId, getCeilingClampBound, getLevelBelow, getLevelElevations, getStoredLevelHeight, } from '../services/storey';
5
+ import { activeSceneCommitNodeIds, getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, subscribeSceneCommits, } from '../store/history-control';
6
6
  import { computeWallSlabSupport } from '../systems/slab/slab-support';
7
7
  import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
8
8
  import { resolveWallTop } from '../systems/wall/wall-top';
9
9
  import { simplifyClosedPolygon } from './polygon-geometry';
10
+ import { distanceToSegment, RoomTopologyIndex, } from './room-topology-index';
10
11
  import { levelBaseElevationAt } from './terrain-support';
11
12
  const DEFAULT_AUTO_SLAB_ELEVATION = 0.05;
12
13
  const CEILING_HEIGHT_EPSILON = 1e-6;
@@ -645,6 +646,234 @@ function sameTuplePolygon(current, next) {
645
646
  return (current.length === next.length &&
646
647
  current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]));
647
648
  }
649
+ function sameTuplePolygons(current, next) {
650
+ return (current.length === next.length &&
651
+ current.every((polygon, index) => {
652
+ const nextPolygon = next[index];
653
+ return nextPolygon ? sameTuplePolygon(polygon, nextPolygon) : false;
654
+ }));
655
+ }
656
+ function crossProduct(a, b, c) {
657
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
658
+ }
659
+ function lineIntersection(start, end, clipStart, clipEnd) {
660
+ const segment = { x: end.x - start.x, y: end.y - start.y };
661
+ const clip = { x: clipEnd.x - clipStart.x, y: clipEnd.y - clipStart.y };
662
+ const denominator = segment.x * clip.y - segment.y * clip.x;
663
+ if (Math.abs(denominator) < 1e-9)
664
+ return end;
665
+ const offset = { x: clipStart.x - start.x, y: clipStart.y - start.y };
666
+ const t = (offset.x * clip.y - offset.y * clip.x) / denominator;
667
+ return { x: start.x + segment.x * t, y: start.y + segment.y * t };
668
+ }
669
+ function clipPolygonToConvex(subject, clipPolygon) {
670
+ if (subject.length < 3 || clipPolygon.length < 3)
671
+ return [];
672
+ const orientation = polygonArea(clipPolygon) >= 0 ? 1 : -1;
673
+ let output = [...subject];
674
+ for (let index = 0; index < clipPolygon.length; index += 1) {
675
+ const clipStart = clipPolygon[index];
676
+ const clipEnd = clipPolygon[(index + 1) % clipPolygon.length];
677
+ const input = output;
678
+ output = [];
679
+ if (input.length === 0)
680
+ break;
681
+ let previous = input[input.length - 1];
682
+ let previousInside = orientation * crossProduct(clipStart, clipEnd, previous) >= -1e-8;
683
+ for (const current of input) {
684
+ const currentInside = orientation * crossProduct(clipStart, clipEnd, current) >= -1e-8;
685
+ if (currentInside !== previousInside) {
686
+ output.push(lineIntersection(previous, current, clipStart, clipEnd));
687
+ }
688
+ if (currentInside)
689
+ output.push(current);
690
+ previous = current;
691
+ previousInside = currentInside;
692
+ }
693
+ output = dedupeSequentialPoints(output, 1e-7);
694
+ }
695
+ return output.length >= 3 && Math.abs(polygonArea(output)) > 1e-8 ? output : [];
696
+ }
697
+ function isConvexPolygon(polygon) {
698
+ let direction = 0;
699
+ for (let index = 0; index < polygon.length; index += 1) {
700
+ const cross = crossProduct(polygon[index], polygon[(index + 1) % polygon.length], polygon[(index + 2) % polygon.length]);
701
+ if (Math.abs(cross) < 1e-8)
702
+ continue;
703
+ const nextDirection = Math.sign(cross);
704
+ if (direction !== 0 && nextDirection !== direction)
705
+ return false;
706
+ direction = nextDirection;
707
+ }
708
+ return true;
709
+ }
710
+ function pointInTriangle(point, a, b, c) {
711
+ return (crossProduct(a, b, point) >= -1e-8 &&
712
+ crossProduct(b, c, point) >= -1e-8 &&
713
+ crossProduct(c, a, point) >= -1e-8);
714
+ }
715
+ function triangulatePolygon(polygon) {
716
+ const points = polygonArea(polygon) >= 0 ? [...polygon] : [...polygon].reverse();
717
+ const indices = points.map((_, index) => index);
718
+ const triangles = [];
719
+ let attempts = 0;
720
+ while (indices.length > 3 && attempts < points.length * points.length) {
721
+ let clippedEar = false;
722
+ for (let index = 0; index < indices.length; index += 1) {
723
+ const previousIndex = indices[(index - 1 + indices.length) % indices.length];
724
+ const currentIndex = indices[index];
725
+ const nextIndex = indices[(index + 1) % indices.length];
726
+ const previous = points[previousIndex];
727
+ const current = points[currentIndex];
728
+ const next = points[nextIndex];
729
+ if (crossProduct(previous, current, next) <= 1e-8)
730
+ continue;
731
+ if (indices.some((candidateIndex) => candidateIndex !== previousIndex &&
732
+ candidateIndex !== currentIndex &&
733
+ candidateIndex !== nextIndex &&
734
+ pointInTriangle(points[candidateIndex], previous, current, next))) {
735
+ continue;
736
+ }
737
+ triangles.push([previous, current, next]);
738
+ indices.splice(index, 1);
739
+ clippedEar = true;
740
+ break;
741
+ }
742
+ if (!clippedEar)
743
+ break;
744
+ attempts += 1;
745
+ }
746
+ if (indices.length === 3)
747
+ triangles.push(indices.map((index) => points[index]));
748
+ return triangles;
749
+ }
750
+ function clipOpeningToRoom(opening, room) {
751
+ const openingIsInside = opening.every((point) => pointInPolygon(point, room) || pointDistanceToPolygonBoundary(point, room) <= 1e-7);
752
+ if (openingIsInside)
753
+ return [opening];
754
+ const clipRegions = isConvexPolygon(room) ? [room] : triangulatePolygon(room);
755
+ return clipRegions
756
+ .map((region) => clipPolygonToConvex(opening, region))
757
+ .filter((polygon) => polygon.length >= 3);
758
+ }
759
+ function partitionSurfaceOpenings(surface, roomIndices, detected) {
760
+ const assignments = new Map();
761
+ for (const roomIndex of roomIndices) {
762
+ assignments.set(roomIndex, { holes: [], holeMetadata: [] });
763
+ }
764
+ surface.holes.forEach((hole, holeIndex) => {
765
+ const holePolygon = hole.map(pointFromTuple);
766
+ for (const roomIndex of roomIndices) {
767
+ const room = detected[roomIndex];
768
+ const assignment = assignments.get(roomIndex);
769
+ if (!(room && assignment))
770
+ continue;
771
+ for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) {
772
+ assignment.holes.push(clipped.map(pointToTuple));
773
+ assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' });
774
+ }
775
+ }
776
+ });
777
+ return assignments;
778
+ }
779
+ function partitionCeilingChildren(ceiling, roomIndices, detected, fallbackRoomIndex, childPosition) {
780
+ const assignments = new Map();
781
+ for (const roomIndex of roomIndices)
782
+ assignments.set(roomIndex, []);
783
+ for (const childId of ceiling.children) {
784
+ const tuple = childPosition?.(childId);
785
+ const point = tuple ? pointFromTuple(tuple) : undefined;
786
+ const boundaryRoomIndices = point
787
+ ? roomIndices.filter((roomIndex) => {
788
+ const room = detected[roomIndex];
789
+ return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false;
790
+ })
791
+ : [];
792
+ const interiorRoomIndex = point && boundaryRoomIndices.length === 0
793
+ ? roomIndices.find((roomIndex) => {
794
+ const room = detected[roomIndex];
795
+ return room ? pointInPolygon(point, room.poly) : false;
796
+ })
797
+ : undefined;
798
+ const roomIndex = interiorRoomIndex ??
799
+ (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex)
800
+ ? fallbackRoomIndex
801
+ : boundaryRoomIndices[0]) ??
802
+ fallbackRoomIndex ??
803
+ roomIndices[0];
804
+ if (roomIndex !== undefined)
805
+ assignments.get(roomIndex)?.push(childId);
806
+ }
807
+ return assignments;
808
+ }
809
+ function sameHoleMetadata(current, next) {
810
+ return (current.length === next.length &&
811
+ current.every((metadata, index) => {
812
+ const candidate = next[index];
813
+ return (candidate?.source === metadata.source &&
814
+ candidate.stairId === metadata.stairId &&
815
+ candidate.elevatorId === metadata.elevatorId);
816
+ }));
817
+ }
818
+ function mergedSurfaceOpenings(surfaces) {
819
+ const holes = [];
820
+ const holeMetadata = [];
821
+ const seen = new Set();
822
+ for (const surface of surfaces) {
823
+ surface.holes.forEach((hole, index) => {
824
+ const metadata = surface.holeMetadata[index] ?? { source: 'manual' };
825
+ const key = JSON.stringify([hole, metadata]);
826
+ if (seen.has(key))
827
+ return;
828
+ seen.add(key);
829
+ holes.push(hole);
830
+ holeMetadata.push(metadata);
831
+ });
832
+ }
833
+ return { holes, holeMetadata };
834
+ }
835
+ function ceilingMergeSettingsSignature(ceiling) {
836
+ return JSON.stringify([
837
+ ceiling.height ?? null,
838
+ ceiling.material ?? null,
839
+ ceiling.materialPreset ?? null,
840
+ ceiling.slots ?? null,
841
+ ceiling.visible,
842
+ ]);
843
+ }
844
+ function slabMergeSettingsSignature(slab) {
845
+ return JSON.stringify([
846
+ slab.elevation,
847
+ slab.thickness,
848
+ slab.recessed,
849
+ slab.recessedRimElevation ?? null,
850
+ slab.fillToTerrain ?? null,
851
+ slab.material ?? null,
852
+ slab.materialPreset ?? null,
853
+ slab.slots ?? null,
854
+ slab.visible,
855
+ ]);
856
+ }
857
+ function slabElevationForReconciledRoom(source, polygon, context) {
858
+ const currentDerived = context.elevationForRoom?.(polygon);
859
+ if (!context.previousElevationForRoom)
860
+ return currentDerived ?? source.elevation;
861
+ const previousDerived = context.previousElevationForRoom(source.polygon);
862
+ const sourceWasDerived = previousDerived !== undefined &&
863
+ Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON;
864
+ return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation;
865
+ }
866
+ function ceilingHeightForReconciledRoom(source, polygon, context) {
867
+ if (source.height === undefined)
868
+ return undefined;
869
+ const currentDerived = context.heightForRoom?.(polygon);
870
+ if (!context.previousHeightForRoom)
871
+ return currentDerived ?? source.height;
872
+ const previousDerived = context.previousHeightForRoom(source.polygon);
873
+ const sourceWasDerived = previousDerived !== undefined &&
874
+ Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON;
875
+ return sourceWasDerived ? (currentDerived ?? source.height) : source.height;
876
+ }
648
877
  function wallGeometrySignature(wall, nodes, levelId) {
649
878
  return [
650
879
  wall.id,
@@ -771,53 +1000,158 @@ function buildSpace(levelId, room) {
771
1000
  isExterior: false,
772
1001
  };
773
1002
  }
774
- function sameStringSet(a, b) {
775
- if (a.length !== b.length)
1003
+ function surfaceTouchesRooms(surface, rooms) {
1004
+ const polygon = surface.polygon.map(pointFromTuple);
1005
+ return rooms.some((room) => polygonCoverageRatio(polygon, [room.polygon]) > 0 ||
1006
+ polygonCoverageRatio(room.polygon, [polygon]) > 0);
1007
+ }
1008
+ function roomsAreRelated(beforeRoom, currentRoom) {
1009
+ const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId));
1010
+ const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId));
1011
+ const sharedWallCount = [...currentIds].filter((wallId) => beforeIds.has(wallId)).length;
1012
+ const smallerBoundarySize = Math.min(beforeIds.size, currentIds.size);
1013
+ if (sharedWallCount >= 2 && sharedWallCount >= Math.ceil(smallerBoundarySize / 2))
1014
+ return true;
1015
+ if (bboxOverlapArea(bboxOf(beforeRoom.polygon), bboxOf(currentRoom.polygon)) <= 1e-6)
776
1016
  return false;
777
- const right = new Set(b);
778
- return a.every((value) => right.has(value));
1017
+ return (polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 ||
1018
+ polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0);
779
1019
  }
780
- export function planAutoZonesForLevel(spaces, existingZones) {
781
- const update = [];
782
- for (const zone of existingZones) {
783
- const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
784
- const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
785
- ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
786
- : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
787
- if (!matchingSpace)
1020
+ function roomHasAutoSurface(room, surfaces) {
1021
+ return matchesManualFootprint(room.polygon, surfaces
1022
+ .filter((surface) => surface.autoFromWalls)
1023
+ .map((surface) => surface.polygon.map(pointFromTuple)));
1024
+ }
1025
+ function roomsEligibleForAutoSurface(beforeRooms, currentRooms, currentSurfaces) {
1026
+ return currentRooms.filter((currentRoom) => {
1027
+ const related = beforeRooms.flatMap((beforeRoom) => {
1028
+ if (!roomsAreRelated(beforeRoom, currentRoom))
1029
+ return [];
1030
+ return [
1031
+ {
1032
+ room: beforeRoom,
1033
+ coverage: polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]),
1034
+ },
1035
+ ];
1036
+ });
1037
+ const maxCoverage = Math.max(0, ...related.map(({ coverage }) => coverage));
1038
+ const predecessors = currentRooms.length >= beforeRooms.length && maxCoverage > 0
1039
+ ? related.filter(({ coverage }) => coverage >= maxCoverage - 1e-6).map(({ room }) => room)
1040
+ : related.map(({ room }) => room);
1041
+ if (predecessors.length === 0)
1042
+ return true;
1043
+ return predecessors.every((beforeRoom) => roomHasAutoSurface(beforeRoom, currentSurfaces));
1044
+ });
1045
+ }
1046
+ function detectedRoomsByLevel(nodes) {
1047
+ const wallsByLevel = new Map();
1048
+ for (const node of Object.values(nodes)) {
1049
+ if (node?.type !== 'wall' || !node.parentId)
788
1050
  continue;
789
- const data = {};
790
- if (!zone.autoFromWalls)
791
- data.autoFromWalls = true;
792
- if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
793
- data.boundaryWallIds = matchingSpace.wallIds;
1051
+ const walls = wallsByLevel.get(node.parentId) ?? [];
1052
+ walls.push(node);
1053
+ wallsByLevel.set(node.parentId, walls);
1054
+ }
1055
+ return new Map([...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)]));
1056
+ }
1057
+ function levelChildren(nodes, levelId) {
1058
+ const level = nodes[levelId];
1059
+ if (level?.type !== 'level')
1060
+ return [];
1061
+ return level.children.flatMap((id) => {
1062
+ const node = nodes[id];
1063
+ return node ? [node] : [];
1064
+ });
1065
+ }
1066
+ function changedWallIdsByLevel(before, current, candidateIds) {
1067
+ const changes = new Map();
1068
+ const wallIds = new Set(candidateIds);
1069
+ if (!candidateIds) {
1070
+ for (const node of Object.values(before)) {
1071
+ if (node?.type === 'wall')
1072
+ wallIds.add(node.id);
794
1073
  }
795
- if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
796
- data.polygon = matchingSpace.polygon;
1074
+ for (const node of Object.values(current)) {
1075
+ if (node?.type === 'wall')
1076
+ wallIds.add(node.id);
797
1077
  }
798
- if (Object.keys(data).length > 0)
799
- update.push({ id: zone.id, data });
800
1078
  }
801
- return { update };
1079
+ const markChanged = (levelId, wallId) => {
1080
+ if (!levelId)
1081
+ return;
1082
+ const ids = changes.get(levelId) ?? new Set();
1083
+ ids.add(wallId);
1084
+ changes.set(levelId, ids);
1085
+ };
1086
+ for (const wallId of wallIds) {
1087
+ const previous = before[wallId]?.type === 'wall' ? before[wallId] : null;
1088
+ const next = current[wallId]?.type === 'wall' ? current[wallId] : null;
1089
+ if (previous === next)
1090
+ continue;
1091
+ markChanged(previous?.parentId, wallId);
1092
+ markChanged(next?.parentId, wallId);
1093
+ }
1094
+ return changes;
802
1095
  }
803
- export function resolveAutoZonePolygon(zone, resolve) {
804
- if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
805
- return zone.polygon;
806
- const walls = zone.boundaryWallIds.flatMap((id) => {
807
- const node = resolve(id);
808
- return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
809
- ? [node]
810
- : [];
811
- });
812
- if (walls.length !== zone.boundaryWallIds.length)
813
- return zone.polygon;
814
- const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
815
- return room ? room.polygon.map(pointToTuple) : zone.polygon;
1096
+ function descendantLevelIds(nodes, rootId) {
1097
+ const levelIds = new Set();
1098
+ const queue = [rootId];
1099
+ const visited = new Set();
1100
+ while (queue.length > 0) {
1101
+ const id = queue.pop();
1102
+ if (visited.has(id))
1103
+ continue;
1104
+ visited.add(id);
1105
+ const node = nodes[id];
1106
+ if (!node)
1107
+ continue;
1108
+ if (node.type === 'level')
1109
+ levelIds.add(node.id);
1110
+ if ('children' in node && Array.isArray(node.children))
1111
+ queue.push(...node.children);
1112
+ }
1113
+ return levelIds;
816
1114
  }
817
- export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {}) {
818
- const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
819
- const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
820
- const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
1115
+ function fallbackLevelIdsForCandidates(before, current, candidateIds) {
1116
+ const levelIds = new Set();
1117
+ const addLevelAndLower = (levelId, nodes) => {
1118
+ if (!levelId)
1119
+ return;
1120
+ levelIds.add(levelId);
1121
+ const lower = getLevelBelow(levelId, nodes);
1122
+ if (lower)
1123
+ levelIds.add(lower.id);
1124
+ };
1125
+ for (const id of candidateIds) {
1126
+ for (const nodes of [before, current]) {
1127
+ const node = nodes[id];
1128
+ if (!node)
1129
+ continue;
1130
+ if (node.type === 'level' || node.type === 'building' || node.type === 'site') {
1131
+ for (const levelId of descendantLevelIds(nodes, node.id))
1132
+ levelIds.add(levelId);
1133
+ }
1134
+ else if (node.type === 'slab') {
1135
+ addLevelAndLower(node.parentId, nodes);
1136
+ }
1137
+ else if (node.type === 'zone') {
1138
+ if (node.parentId)
1139
+ levelIds.add(node.parentId);
1140
+ }
1141
+ }
1142
+ }
1143
+ return levelIds;
1144
+ }
1145
+ function sameStringSet(a, b) {
1146
+ if (a.length !== b.length)
1147
+ return false;
1148
+ const right = new Set(b);
1149
+ return a.every((value) => right.has(value));
1150
+ }
1151
+ function matchAutoSurfaces(roomPolygons, existingSurfaces, mergeSettingsSignature) {
1152
+ const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls);
1153
+ const manualSignatures = new Set(manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))));
1154
+ const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple));
821
1155
  const detectedAll = roomPolygons
822
1156
  .map((poly) => ({
823
1157
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
@@ -834,86 +1168,185 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
834
1168
  bbox: bboxOf(room.poly),
835
1169
  }));
836
1170
  const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
837
- const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
838
- const existingAutoMeta = existingAuto.map((slab) => {
839
- const poly = slab.polygon.map(pointFromTuple);
1171
+ const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls);
1172
+ const metadata = existingAuto.map((surface) => {
1173
+ const poly = surface.polygon.map(pointFromTuple);
840
1174
  return {
841
- slab,
1175
+ surface,
842
1176
  sig: polygonSignature(poly),
843
1177
  centroid: polygonCentroid(poly),
844
1178
  area: Math.abs(polygonArea(poly)),
845
1179
  bbox: bboxOf(poly),
846
1180
  };
847
1181
  });
848
- const matchedSlabIds = new Set();
849
- const matchedDetectedIdx = new Set();
850
- const updatesById = new Map();
1182
+ const conflictingSurfaceIds = new Set();
1183
+ const conflictingRoomIndices = new Set();
1184
+ const compatibleMergesByRoomIndex = new Map();
1185
+ detected.forEach((room, roomIndex) => {
1186
+ const contributors = existingAuto.filter((surface) => polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >=
1187
+ ORPHAN_MERGE_COVERAGE_THRESHOLD);
1188
+ if (contributors.length < 2)
1189
+ return;
1190
+ if (new Set(contributors.map(mergeSettingsSignature)).size > 1) {
1191
+ conflictingRoomIndices.add(roomIndex);
1192
+ for (const surface of contributors)
1193
+ conflictingSurfaceIds.add(surface.id);
1194
+ return;
1195
+ }
1196
+ compatibleMergesByRoomIndex.set(roomIndex, contributors);
1197
+ });
1198
+ const matchedSurfaceIds = new Set();
1199
+ const matchedDetectedIndices = new Set();
1200
+ const roomIndexBySurfaceId = new Map();
1201
+ const sourceSurfaceIdByRoomIndex = new Map();
1202
+ const polygonBySurfaceId = new Map();
851
1203
  const autoBySignature = new Map();
852
- for (const entry of existingAutoMeta) {
1204
+ for (const entry of metadata) {
853
1205
  const bucket = autoBySignature.get(entry.sig) ?? [];
854
1206
  bucket.push(entry);
855
1207
  autoBySignature.set(entry.sig, bucket);
856
1208
  }
857
1209
  detected.forEach((room, index) => {
1210
+ if (conflictingRoomIndices.has(index)) {
1211
+ matchedDetectedIndices.add(index);
1212
+ return;
1213
+ }
858
1214
  const existing = autoBySignature.get(room.sig)?.shift();
859
1215
  if (!existing)
860
1216
  return;
861
- matchedDetectedIdx.add(index);
862
- matchedSlabIds.add(existing.slab.id);
863
- const polygon = room.poly.map(pointToTuple);
864
- updatesById.set(existing.slab.id, {
865
- polygon,
866
- elevation: context.elevationForRoom?.(polygon),
867
- });
1217
+ matchedDetectedIndices.add(index);
1218
+ matchedSurfaceIds.add(existing.surface.id);
1219
+ roomIndexBySurfaceId.set(existing.surface.id, index);
1220
+ sourceSurfaceIdByRoomIndex.set(index, existing.surface.id);
1221
+ polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple));
868
1222
  });
869
1223
  const remainingDetected = detected
870
1224
  .map((room, index) => ({ room, index }))
871
- .filter(({ index }) => !matchedDetectedIdx.has(index))
872
- .sort((a, b) => b.room.area - a.room.area);
873
- const remainingAuto = existingAutoMeta.filter((entry) => !matchedSlabIds.has(entry.slab.id));
1225
+ .filter(({ index }) => !matchedDetectedIndices.has(index))
1226
+ .sort((left, right) => right.room.area - left.room.area);
1227
+ const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id));
874
1228
  for (const { room, index } of remainingDetected) {
875
1229
  let bestMatch = null;
876
1230
  for (const entry of remainingAuto) {
877
- if (matchedSlabIds.has(entry.slab.id))
1231
+ if (matchedSurfaceIds.has(entry.surface.id))
878
1232
  continue;
879
- const dx = room.centroid.x - entry.centroid.x;
880
- const dy = room.centroid.y - entry.centroid.y;
881
- const dist = Math.hypot(dx, dy);
1233
+ const distance = Math.hypot(room.centroid.x - entry.centroid.x, room.centroid.y - entry.centroid.y);
882
1234
  const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
883
1235
  const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
884
- const overlap = bboxOverlapArea(room.bbox, entry.bbox);
885
- if (overlap <= 0.0001 && dist > 1.5)
1236
+ if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5)
886
1237
  continue;
887
- const score = dist + areaPenalty * 0.35;
888
- if (!bestMatch || score < bestMatch.score) {
1238
+ const score = distance + areaPenalty * 0.35;
1239
+ if (!bestMatch || score < bestMatch.score)
889
1240
  bestMatch = { entry, score };
890
- }
891
1241
  }
892
1242
  if (!bestMatch)
893
1243
  continue;
894
- matchedDetectedIdx.add(index);
895
- matchedSlabIds.add(bestMatch.entry.slab.id);
896
- const polygon = room.poly.map(pointToTuple);
897
- updatesById.set(bestMatch.entry.slab.id, {
898
- polygon,
899
- elevation: context.elevationForRoom?.(polygon),
900
- });
1244
+ matchedDetectedIndices.add(index);
1245
+ matchedSurfaceIds.add(bestMatch.entry.surface.id);
1246
+ roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index);
1247
+ sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id);
1248
+ polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple));
901
1249
  }
1250
+ detected.forEach((room, index) => {
1251
+ if (sourceSurfaceIdByRoomIndex.has(index))
1252
+ return;
1253
+ let bestSource = null;
1254
+ for (const entry of metadata) {
1255
+ const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]);
1256
+ if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage))
1257
+ continue;
1258
+ bestSource = { id: entry.surface.id, coverage };
1259
+ }
1260
+ if (bestSource)
1261
+ sourceSurfaceIdByRoomIndex.set(index, bestSource.id);
1262
+ });
902
1263
  const detectedRoomPolygons = detectedAll.map((room) => room.poly);
903
- const slabsToDelete = [];
904
- const slabDemotions = [];
905
- for (const slab of existingAuto) {
906
- if (updatesById.has(slab.id))
1264
+ const deleted = [];
1265
+ const demote = [];
1266
+ for (const surface of existingAuto) {
1267
+ if (polygonBySurfaceId.has(surface.id))
1268
+ continue;
1269
+ if (conflictingSurfaceIds.has(surface.id)) {
1270
+ demote.push({ id: surface.id, data: { autoFromWalls: false } });
907
1271
  continue;
908
- const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons);
909
- if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
910
- slabsToDelete.push(slab.id);
911
1272
  }
912
- else {
913
- // Render offsets derive from level context at geometry build time, so
914
- // demotion leaves the stored polygon untouched (same as ceilings).
915
- slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } });
1273
+ const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons);
1274
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD)
1275
+ deleted.push(surface.id);
1276
+ else
1277
+ demote.push({ id: surface.id, data: { autoFromWalls: false } });
1278
+ }
1279
+ return {
1280
+ detectedAll,
1281
+ detected,
1282
+ existingAuto,
1283
+ compatibleMergesByRoomIndex,
1284
+ matchedDetectedIndices,
1285
+ roomIndexBySurfaceId,
1286
+ sourceSurfaceIdByRoomIndex,
1287
+ polygonBySurfaceId,
1288
+ delete: deleted,
1289
+ demote,
1290
+ };
1291
+ }
1292
+ export function planAutoZonesForLevel(spaces, existingZones) {
1293
+ const update = [];
1294
+ for (const zone of existingZones) {
1295
+ const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
1296
+ const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
1297
+ ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
1298
+ : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
1299
+ if (!matchingSpace)
1300
+ continue;
1301
+ const data = {};
1302
+ if (!zone.autoFromWalls)
1303
+ data.autoFromWalls = true;
1304
+ if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
1305
+ data.boundaryWallIds = matchingSpace.wallIds;
916
1306
  }
1307
+ if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
1308
+ data.polygon = matchingSpace.polygon;
1309
+ }
1310
+ if (Object.keys(data).length > 0)
1311
+ update.push({ id: zone.id, data });
1312
+ }
1313
+ return { update };
1314
+ }
1315
+ export function resolveAutoZonePolygon(zone, resolve) {
1316
+ if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
1317
+ return zone.polygon;
1318
+ const walls = zone.boundaryWallIds.flatMap((id) => {
1319
+ const node = resolve(id);
1320
+ return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
1321
+ ? [node]
1322
+ : [];
1323
+ });
1324
+ if (walls.length !== zone.boundaryWallIds.length)
1325
+ return zone.polygon;
1326
+ const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
1327
+ return room ? room.polygon.map(pointToTuple) : zone.polygon;
1328
+ }
1329
+ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {}, namingSlabs = existingSlabs) {
1330
+ const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature);
1331
+ const { detected, existingAuto, compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, matchedDetectedIndices: matchedDetectedIdx, roomIndexBySurfaceId: roomIndexBySlabId, sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, delete: slabsToDelete, demote: slabDemotions, } = match;
1332
+ const updatesById = new Map();
1333
+ for (const slab of existingAuto) {
1334
+ const polygon = match.polygonBySurfaceId.get(slab.id);
1335
+ if (!polygon)
1336
+ continue;
1337
+ updatesById.set(slab.id, {
1338
+ polygon,
1339
+ elevation: slabElevationForReconciledRoom(slab, polygon, context),
1340
+ });
1341
+ }
1342
+ const openingAssignmentsBySlabId = new Map();
1343
+ for (const slab of existingAuto) {
1344
+ const roomIndices = [...sourceSlabIdByRoomIndex.entries()]
1345
+ .filter(([, slabId]) => slabId === slab.id)
1346
+ .map(([roomIndex]) => roomIndex);
1347
+ if (roomIndices.length === 0)
1348
+ continue;
1349
+ openingAssignmentsBySlabId.set(slab.id, partitionSurfaceOpenings(slab, roomIndices, detected));
917
1350
  }
918
1351
  const slabsToUpdate = [
919
1352
  ...existingAuto
@@ -922,9 +1355,23 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
922
1355
  const update = updatesById.get(slab.id);
923
1356
  if (!update)
924
1357
  return [];
1358
+ const roomIndex = roomIndexBySlabId.get(slab.id);
1359
+ const openings = roomIndex == null
1360
+ ? { holes: slab.holes, holeMetadata: slab.holeMetadata }
1361
+ : compatibleMergeSlabsByRoomIndex.has(roomIndex)
1362
+ ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? [])
1363
+ : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? {
1364
+ holes: [],
1365
+ holeMetadata: [],
1366
+ });
925
1367
  const data = {};
926
1368
  if (!sameTuplePolygon(slab.polygon, update.polygon))
927
1369
  data.polygon = update.polygon;
1370
+ if (!sameTuplePolygons(slab.holes, openings.holes))
1371
+ data.holes = openings.holes;
1372
+ if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) {
1373
+ data.holeMetadata = openings.holeMetadata;
1374
+ }
928
1375
  if (update.elevation !== undefined &&
929
1376
  Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON) {
930
1377
  data.elevation = update.elevation;
@@ -933,7 +1380,7 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
933
1380
  }),
934
1381
  ...slabDemotions,
935
1382
  ];
936
- const plannedSlabsForNaming = [...existingSlabs];
1383
+ const plannedSlabsForNaming = [...namingSlabs];
937
1384
  const slabsToCreate = [];
938
1385
  for (let index = 0; index < detected.length; index += 1) {
939
1386
  if (matchedDetectedIdx.has(index))
@@ -944,14 +1391,28 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
944
1391
  const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab');
945
1392
  plannedSlabsForNaming.push({ name });
946
1393
  const polygon = room.poly.map(pointToTuple);
947
- const elevation = context.elevationForRoom?.(polygon);
1394
+ const sourceId = sourceSlabIdByRoomIndex.get(index);
1395
+ const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined;
1396
+ const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined;
1397
+ const elevation = source
1398
+ ? slabElevationForReconciledRoom(source, polygon, context)
1399
+ : context.elevationForRoom?.(polygon);
948
1400
  slabsToCreate.push(SlabNode.parse({
949
1401
  name,
950
1402
  polygon,
951
- holes: [],
1403
+ holes: openings?.holes ?? [],
1404
+ holeMetadata: openings?.holeMetadata ?? [],
952
1405
  elevation: elevation !== undefined && Number.isFinite(elevation)
953
1406
  ? elevation
954
1407
  : DEFAULT_AUTO_SLAB_ELEVATION,
1408
+ thickness: source?.thickness,
1409
+ recessed: source?.recessed,
1410
+ recessedRimElevation: source?.recessedRimElevation,
1411
+ fillToTerrain: source?.fillToTerrain,
1412
+ material: source?.material,
1413
+ materialPreset: source?.materialPreset,
1414
+ slots: source?.slots,
1415
+ visible: source?.visible,
955
1416
  autoFromWalls: true,
956
1417
  }));
957
1418
  }
@@ -961,8 +1422,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
961
1422
  delete: slabsToDelete,
962
1423
  };
963
1424
  }
964
- function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore, context = {}) {
965
- const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context);
1425
+ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore, context = {}, namingSlabs = existingSlabs) {
1426
+ const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs);
966
1427
  if (plan.delete.length > 0) {
967
1428
  sceneStore.getState().deleteNodes(plan.delete);
968
1429
  }
@@ -974,105 +1435,20 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore,
974
1435
  }
975
1436
  return plan;
976
1437
  }
977
- export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
1438
+ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}, namingCeilings = existingCeilings) {
978
1439
  const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
979
- const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
980
- const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
981
- const detectedAll = roomPolygons
982
- .map((poly) => ({
983
- poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
984
- sig: '',
985
- centroid: { x: 0, y: 0 },
986
- area: 0,
987
- bbox: bboxOf([]),
988
- }))
989
- .map((room) => ({
990
- ...room,
991
- sig: polygonSignature(room.poly),
992
- centroid: polygonCentroid(room.poly),
993
- area: Math.abs(polygonArea(room.poly)),
994
- bbox: bboxOf(room.poly),
995
- }));
996
- const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
997
- const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
998
- const existingAutoMeta = existingAuto.map((ceiling) => {
999
- const poly = ceiling.polygon.map(pointFromTuple);
1000
- return {
1001
- ceiling,
1002
- sig: polygonSignature(poly),
1003
- centroid: polygonCentroid(poly),
1004
- area: Math.abs(polygonArea(poly)),
1005
- bbox: bboxOf(poly),
1006
- };
1007
- });
1008
- const matchedCeilingIds = new Set();
1009
- const matchedDetectedIdx = new Set();
1440
+ const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature);
1441
+ const { detected, existingAuto, compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, matchedDetectedIndices: matchedDetectedIdx, roomIndexBySurfaceId: roomIndexByCeilingId, sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, delete: ceilingsToDelete, demote: ceilingDemotions, } = match;
1010
1442
  const updatesById = new Map();
1011
- const autoBySignature = new Map();
1012
- for (const entry of existingAutoMeta) {
1013
- const bucket = autoBySignature.get(entry.sig) ?? [];
1014
- bucket.push(entry);
1015
- autoBySignature.set(entry.sig, bucket);
1016
- }
1017
- detected.forEach((room, index) => {
1018
- const existing = autoBySignature.get(room.sig)?.shift();
1019
- if (!existing)
1020
- return;
1021
- matchedDetectedIdx.add(index);
1022
- matchedCeilingIds.add(existing.ceiling.id);
1023
- const polygon = room.poly.map(pointToTuple);
1024
- updatesById.set(existing.ceiling.id, {
1025
- polygon,
1026
- height: context.heightForRoom?.(polygon),
1027
- });
1028
- });
1029
- const remainingDetected = detected
1030
- .map((room, index) => ({ room, index }))
1031
- .filter(({ index }) => !matchedDetectedIdx.has(index))
1032
- .sort((a, b) => b.room.area - a.room.area);
1033
- const remainingAuto = existingAutoMeta.filter((entry) => !matchedCeilingIds.has(entry.ceiling.id));
1034
- for (const { room, index } of remainingDetected) {
1035
- let bestMatch = null;
1036
- for (const entry of remainingAuto) {
1037
- if (matchedCeilingIds.has(entry.ceiling.id))
1038
- continue;
1039
- const dx = room.centroid.x - entry.centroid.x;
1040
- const dy = room.centroid.y - entry.centroid.y;
1041
- const dist = Math.hypot(dx, dy);
1042
- const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
1043
- const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
1044
- const overlap = bboxOverlapArea(room.bbox, entry.bbox);
1045
- if (overlap <= 0.0001 && dist > 1.5)
1046
- continue;
1047
- const score = dist + areaPenalty * 0.35;
1048
- if (!bestMatch || score < bestMatch.score) {
1049
- bestMatch = { entry, score };
1050
- }
1051
- }
1052
- if (!bestMatch)
1443
+ for (const ceiling of existingAuto) {
1444
+ const polygon = match.polygonBySurfaceId.get(ceiling.id);
1445
+ if (!polygon)
1053
1446
  continue;
1054
- matchedDetectedIdx.add(index);
1055
- matchedCeilingIds.add(bestMatch.entry.ceiling.id);
1056
- const polygon = room.poly.map(pointToTuple);
1057
- updatesById.set(bestMatch.entry.ceiling.id, {
1447
+ updatesById.set(ceiling.id, {
1058
1448
  polygon,
1059
- height: context.heightForRoom?.(polygon),
1449
+ height: ceilingHeightForReconciledRoom(ceiling, polygon, context),
1060
1450
  });
1061
1451
  }
1062
- const detectedRoomPolygons = detectedAll.map((room) => room.poly);
1063
- const ceilingsToDelete = [];
1064
- const ceilingDemotions = [];
1065
- for (const ceiling of existingAuto) {
1066
- if (updatesById.has(ceiling.id))
1067
- continue;
1068
- const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons);
1069
- if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
1070
- ceilingsToDelete.push(ceiling.id);
1071
- }
1072
- else {
1073
- ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
1074
- }
1075
- }
1076
1452
  // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
1077
1453
  // created, moved, or thickened on the level above can leave an EXISTING
1078
1454
  // manual explicit-height ceiling poking into its solid. Clamp explicit
@@ -1091,6 +1467,18 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1091
1467
  ? [{ id: ceiling.id, data: { height: bound } }]
1092
1468
  : [];
1093
1469
  });
1470
+ const openingAssignmentsByCeilingId = new Map();
1471
+ const childAssignmentsByCeilingId = new Map();
1472
+ for (const ceiling of existingAuto) {
1473
+ const roomIndices = [...sourceCeilingIdByRoomIndex.entries()]
1474
+ .filter(([, ceilingId]) => ceilingId === ceiling.id)
1475
+ .map(([roomIndex]) => roomIndex);
1476
+ if (roomIndices.length === 0)
1477
+ continue;
1478
+ openingAssignmentsByCeilingId.set(ceiling.id, partitionSurfaceOpenings(ceiling, roomIndices, detected));
1479
+ childAssignmentsByCeilingId.set(ceiling.id, partitionCeilingChildren(ceiling, roomIndices, detected, roomIndexByCeilingId.get(ceiling.id), context.childPosition));
1480
+ }
1481
+ const childReparents = [];
1094
1482
  const ceilingsToUpdate = [
1095
1483
  ...existingAuto
1096
1484
  .filter((ceiling) => updatesById.has(ceiling.id))
@@ -1098,9 +1486,40 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1098
1486
  const update = updatesById.get(ceiling.id);
1099
1487
  if (!update)
1100
1488
  return [];
1489
+ const roomIndex = roomIndexByCeilingId.get(ceiling.id);
1490
+ const openings = roomIndex == null
1491
+ ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata }
1492
+ : compatibleMergeCeilingsByRoomIndex.has(roomIndex)
1493
+ ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? [])
1494
+ : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? {
1495
+ holes: [],
1496
+ holeMetadata: [],
1497
+ });
1101
1498
  const data = {};
1102
1499
  if (!sameTuplePolygon(ceiling.polygon, update.polygon))
1103
1500
  data.polygon = update.polygon;
1501
+ if (!sameTuplePolygons(ceiling.holes, openings.holes))
1502
+ data.holes = openings.holes;
1503
+ if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) {
1504
+ data.holeMetadata = openings.holeMetadata;
1505
+ }
1506
+ const mergeContributors = roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex);
1507
+ const children = mergeContributors
1508
+ ? [
1509
+ ...new Set(mergeContributors.flatMap((contributor) => contributor.children)),
1510
+ ]
1511
+ : roomIndex == null
1512
+ ? ceiling.children
1513
+ : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children);
1514
+ for (const contributor of mergeContributors ?? []) {
1515
+ if (contributor.id === ceiling.id)
1516
+ continue;
1517
+ for (const childId of contributor.children) {
1518
+ childReparents.push({ id: childId, parentId: ceiling.id });
1519
+ }
1520
+ }
1521
+ if (!sameStringSet(ceiling.children, children))
1522
+ data.children = children;
1104
1523
  if (update.height !== undefined &&
1105
1524
  (ceiling.height === undefined ||
1106
1525
  Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON)) {
@@ -1111,7 +1530,7 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1111
1530
  ...ceilingDemotions,
1112
1531
  ...manualClamps,
1113
1532
  ];
1114
- const plannedCeilingsForNaming = [...existingCeilings];
1533
+ const plannedCeilingsForNaming = [...namingCeilings];
1115
1534
  const ceilingsToCreate = [];
1116
1535
  for (let index = 0; index < detected.length; index += 1) {
1117
1536
  if (matchedDetectedIdx.has(index))
@@ -1122,32 +1541,54 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1122
1541
  const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling');
1123
1542
  plannedCeilingsForNaming.push({ name });
1124
1543
  const polygon = room.poly.map(pointToTuple);
1125
- const height = context.heightForRoom?.(polygon);
1126
- ceilingsToCreate.push(CeilingNode.parse({
1544
+ const sourceId = sourceCeilingIdByRoomIndex.get(index);
1545
+ const source = sourceId ? existingAuto.find((ceiling) => ceiling.id === sourceId) : undefined;
1546
+ const openings = sourceId ? openingAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined;
1547
+ const children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined;
1548
+ const height = source
1549
+ ? ceilingHeightForReconciledRoom(source, polygon, context)
1550
+ : context.heightForRoom?.(polygon);
1551
+ const created = CeilingNode.parse({
1127
1552
  name,
1128
1553
  polygon,
1129
- holes: [],
1554
+ children: children ?? [],
1555
+ holes: openings?.holes ?? [],
1556
+ holeMetadata: openings?.holeMetadata ?? [],
1557
+ material: source?.material,
1558
+ materialPreset: source?.materialPreset,
1559
+ slots: source?.slots,
1560
+ visible: source?.visible,
1130
1561
  ...(height !== undefined && Number.isFinite(height) ? { height } : {}),
1131
1562
  autoFromWalls: true,
1132
- }));
1563
+ });
1564
+ ceilingsToCreate.push(created);
1565
+ for (const childId of children ?? []) {
1566
+ childReparents.push({ id: childId, parentId: created.id });
1567
+ }
1133
1568
  }
1134
1569
  return {
1135
1570
  create: ceilingsToCreate,
1136
1571
  update: ceilingsToUpdate,
1137
1572
  delete: ceilingsToDelete,
1573
+ reparent: childReparents,
1138
1574
  };
1139
1575
  }
1140
- function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore, context = {}) {
1141
- const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context);
1142
- if (plan.delete.length > 0) {
1143
- sceneStore.getState().deleteNodes(plan.delete);
1144
- }
1576
+ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore, context = {}, namingCeilings = existingCeilings) {
1577
+ const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings);
1145
1578
  if (plan.update.length > 0) {
1146
1579
  sceneStore.getState().updateNodes(plan.update);
1147
1580
  }
1148
1581
  if (plan.create.length > 0) {
1149
1582
  sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })));
1150
1583
  }
1584
+ if (plan.reparent.length > 0) {
1585
+ sceneStore
1586
+ .getState()
1587
+ .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } })));
1588
+ }
1589
+ if (plan.delete.length > 0) {
1590
+ sceneStore.getState().deleteNodes(plan.delete);
1591
+ }
1151
1592
  }
1152
1593
  function detectSpacesFromWalls(levelId, walls) {
1153
1594
  const rooms = extractRooms(walls);
@@ -1157,6 +1598,7 @@ function detectSpacesFromWalls(levelId, walls) {
1157
1598
  ...resolveWallSurfaceSides(wall, roomPolygons),
1158
1599
  }));
1159
1600
  return {
1601
+ rooms,
1160
1602
  roomPolygons,
1161
1603
  spaces: rooms.map((room) => buildSpace(levelId, room)),
1162
1604
  wallUpdates,
@@ -1165,7 +1607,7 @@ function detectSpacesFromWalls(levelId, walls) {
1165
1607
  export function detectSpacesForLevel(levelId, walls) {
1166
1608
  return detectSpacesFromWalls(levelId, walls);
1167
1609
  }
1168
- function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1610
+ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes, previousNodes, previousRoomsByLevel) {
1169
1611
  const { updateNodes } = sceneStore.getState();
1170
1612
  const existingSpaces = editorStore.getState().spaces;
1171
1613
  const nextSpaces = {};
@@ -1175,11 +1617,12 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1175
1617
  }
1176
1618
  }
1177
1619
  for (const levelId of levelIds) {
1178
- const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
1179
- const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
1180
- const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
1181
- const zones = Object.values(nodes).filter((node) => node?.type === 'zone' && node.parentId === levelId);
1182
- const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
1620
+ const children = levelChildren(nodes, levelId);
1621
+ const walls = children.filter((node) => node?.type === 'wall' && node.parentId === levelId);
1622
+ const slabs = children.filter((node) => node?.type === 'slab');
1623
+ const ceilings = children.filter((node) => node?.type === 'ceiling');
1624
+ const zones = children.filter((node) => node?.type === 'zone');
1625
+ const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls);
1183
1626
  const changedWallUpdates = wallUpdates.filter((update) => {
1184
1627
  const wall = nodes[update.wallId];
1185
1628
  return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide);
@@ -1198,18 +1641,42 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1198
1641
  ? getStoredLevelHeight(levelNode)
1199
1642
  : DEFAULT_LEVEL_HEIGHT;
1200
1643
  const parsedSlabs = slabs.map((slab) => SlabNode.parse(slab));
1644
+ const parsedCeilings = ceilings.map((ceiling) => CeilingNode.parse(ceiling));
1645
+ const previousRooms = previousRoomsByLevel.get(levelId) ?? [];
1646
+ const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs);
1647
+ const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings);
1201
1648
  const verticalPlacements = autoRoomVerticalPlacements(spaces, walls,
1202
1649
  // A derived floor cannot be evidence for its own next elevation: that
1203
1650
  // would lift unpinned walls, then lift the floor again on every pass.
1204
1651
  parsedSlabs.filter((slab) => !slab.autoFromWalls), nodes, storeyHeight);
1652
+ const previousChildren = levelChildren(previousNodes, levelId);
1653
+ const previousWalls = previousChildren.filter((node) => node?.type === 'wall' && node.parentId === levelId);
1654
+ const previousSlabs = previousChildren
1655
+ .filter((node) => node?.type === 'slab')
1656
+ .map((slab) => SlabNode.parse(slab));
1657
+ const previousLevelNode = previousNodes[levelId];
1658
+ const previousStoreyHeight = previousLevelNode?.type === 'level'
1659
+ ? getStoredLevelHeight(previousLevelNode)
1660
+ : DEFAULT_LEVEL_HEIGHT;
1661
+ const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces;
1662
+ const previousVerticalPlacements = autoRoomVerticalPlacements(previousSpaces, previousWalls, previousSlabs.filter((slab) => !slab.autoFromWalls), previousNodes, previousStoreyHeight);
1205
1663
  const placementFor = (polygon) => verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1206
- syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, {
1664
+ const previousPlacementFor = (polygon) => previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1665
+ syncAutoSlabsForLevel(levelId, slabRooms.map((room) => room.polygon), parsedSlabs, sceneStore, {
1207
1666
  elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
1667
+ previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation,
1208
1668
  });
1209
- syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, {
1669
+ syncAutoCeilingsForLevel(levelId, ceilingRooms.map((room) => room.polygon), parsedCeilings, sceneStore, {
1210
1670
  storeyHeight,
1211
1671
  ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
1212
1672
  heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
1673
+ previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight,
1674
+ childPosition: (childId) => {
1675
+ const child = nodes[childId];
1676
+ return child && Array.isArray(child.position)
1677
+ ? [child.position[0], child.position[2]]
1678
+ : undefined;
1679
+ },
1213
1680
  });
1214
1681
  const zonePlan = planAutoZonesForLevel(spaces, zones.map((zone) => ZoneNode.parse(zone)));
1215
1682
  if (zonePlan.update.length > 0)
@@ -1217,9 +1684,97 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1217
1684
  for (const space of spaces) {
1218
1685
  nextSpaces[space.id] = space;
1219
1686
  }
1687
+ previousRoomsByLevel.set(levelId, rooms);
1220
1688
  }
1221
1689
  editorStore.getState().setSpaces(nextSpaces);
1222
1690
  }
1691
+ function runIndexedSpaceDetection(levelId, topologyDelta, sceneStore, editorStore, nodes, previousNodes) {
1692
+ const { updateNodes } = sceneStore.getState();
1693
+ const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon);
1694
+ const changedWallUpdates = topologyDelta.currentWalls
1695
+ .map((wall) => ({
1696
+ wallId: wall.id,
1697
+ ...resolveWallSurfaceSides(wall, allRoomPolygons),
1698
+ }))
1699
+ .filter((update) => {
1700
+ const wall = nodes[update.wallId];
1701
+ return (wall?.type === 'wall' &&
1702
+ (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide));
1703
+ });
1704
+ if (changedWallUpdates.length > 0) {
1705
+ updateNodes(changedWallUpdates.map((update) => ({
1706
+ id: update.wallId,
1707
+ data: { frontSide: update.frontSide, backSide: update.backSide },
1708
+ })));
1709
+ }
1710
+ const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms];
1711
+ if (scopedRooms.length > 0) {
1712
+ const unaffectedRooms = topologyDelta.allCurrentRooms.filter((room) => !topologyDelta.currentRooms.includes(room));
1713
+ const currentChildren = levelChildren(nodes, levelId);
1714
+ const allSlabs = currentChildren
1715
+ .filter((node) => node.type === 'slab')
1716
+ .map((slab) => SlabNode.parse(slab));
1717
+ const allCeilings = currentChildren
1718
+ .filter((node) => node.type === 'ceiling')
1719
+ .map((ceiling) => CeilingNode.parse(ceiling));
1720
+ const slabs = allSlabs.filter((slab) => surfaceTouchesRooms(slab, scopedRooms) &&
1721
+ (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)));
1722
+ const ceilings = allCeilings.filter((ceiling) => surfaceTouchesRooms(ceiling, scopedRooms) &&
1723
+ (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)));
1724
+ const slabRooms = roomsEligibleForAutoSurface(topologyDelta.beforeRooms, topologyDelta.currentRooms, slabs);
1725
+ const ceilingRooms = roomsEligibleForAutoSurface(topologyDelta.beforeRooms, topologyDelta.currentRooms, ceilings);
1726
+ const levelNode = nodes[levelId];
1727
+ const storeyHeight = levelNode?.type === 'level'
1728
+ ? getStoredLevelHeight(levelNode)
1729
+ : DEFAULT_LEVEL_HEIGHT;
1730
+ const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room));
1731
+ const verticalPlacements = autoRoomVerticalPlacements(currentSpaces, topologyDelta.currentWalls, allSlabs.filter((slab) => !slab.autoFromWalls), nodes, storeyHeight);
1732
+ const previousChildren = levelChildren(previousNodes, levelId);
1733
+ const previousSlabs = previousChildren
1734
+ .filter((node) => node.type === 'slab')
1735
+ .map((slab) => SlabNode.parse(slab));
1736
+ const previousLevelNode = previousNodes[levelId];
1737
+ const previousStoreyHeight = previousLevelNode?.type === 'level'
1738
+ ? getStoredLevelHeight(previousLevelNode)
1739
+ : DEFAULT_LEVEL_HEIGHT;
1740
+ const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room));
1741
+ const previousVerticalPlacements = autoRoomVerticalPlacements(previousSpaces, topologyDelta.previousWalls, previousSlabs.filter((slab) => !slab.autoFromWalls), previousNodes, previousStoreyHeight);
1742
+ const placementFor = (polygon) => verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1743
+ const previousPlacementFor = (polygon) => previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1744
+ syncAutoSlabsForLevel(levelId, slabRooms.map((room) => room.polygon), slabs, sceneStore, {
1745
+ elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
1746
+ previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation,
1747
+ }, allSlabs);
1748
+ syncAutoCeilingsForLevel(levelId, ceilingRooms.map((room) => room.polygon), ceilings, sceneStore, {
1749
+ storeyHeight,
1750
+ ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
1751
+ heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
1752
+ previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight,
1753
+ childPosition: (childId) => {
1754
+ const child = nodes[childId];
1755
+ return child && Array.isArray(child.position)
1756
+ ? [child.position[0], child.position[2]]
1757
+ : undefined;
1758
+ },
1759
+ }, allCeilings);
1760
+ }
1761
+ const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room));
1762
+ const zones = levelChildren(nodes, levelId)
1763
+ .filter((node) => node.type === 'zone')
1764
+ .map((zone) => ZoneNode.parse(zone));
1765
+ const zonePlan = planAutoZonesForLevel(spaces, zones);
1766
+ if (zonePlan.update.length > 0)
1767
+ updateNodes(zonePlan.update);
1768
+ const existingSpaces = editorStore.getState().spaces;
1769
+ const nextSpaces = {};
1770
+ for (const [spaceId, space] of Object.entries(existingSpaces)) {
1771
+ if (space.levelId !== levelId)
1772
+ nextSpaces[spaceId] = space;
1773
+ }
1774
+ for (const space of spaces)
1775
+ nextSpaces[space.id] = space;
1776
+ editorStore.getState().setSpaces(nextSpaces);
1777
+ }
1223
1778
  // Refcount of outstanding pause requests, matching the pauseSceneHistory
1224
1779
  // pattern. The community editor flips this off while the AI is actively
1225
1780
  // mutating the scene so the wall-driven auto slab/ceiling sync doesn't race
@@ -1240,66 +1795,142 @@ export function resumeSpaceDetection() {
1240
1795
  export function isSpaceDetectionPaused() {
1241
1796
  return spaceDetectionPauseDepth > 0;
1242
1797
  }
1243
- export function initSpaceDetectionSync(sceneStore, editorStore) {
1798
+ export function initSpaceDetectionSync(sceneStore, editorStore, options = {}) {
1244
1799
  // Baseline from whatever is already in the store. Detection reacts to wall
1245
1800
  // edits made IN-SESSION (create / move / delete); it must not re-litigate a
1246
1801
  // scene that merely loaded — rerunning on hydration resurrected auto slabs
1247
1802
  // the user had deleted in an earlier session.
1248
- const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
1803
+ const initialNodes = sceneStore.getState().nodes;
1804
+ const previousRoomsByLevel = new Map();
1805
+ const topologyIndex = new RoomTopologyIndex({
1806
+ detectRooms: extractRooms,
1807
+ sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple),
1808
+ junctionTolerance: WALL_JUNCTION_TOLERANCE,
1809
+ });
1810
+ let previousNodes = initialNodes;
1249
1811
  let isProcessing = false;
1812
+ const adoptSceneBaseline = (nodes) => {
1813
+ topologyIndex.rebuild(nodes);
1814
+ const roomsByLevel = detectedRoomsByLevel(nodes);
1815
+ previousRoomsByLevel.clear();
1816
+ const spaces = {};
1817
+ for (const [levelId, rooms] of roomsByLevel) {
1818
+ previousRoomsByLevel.set(levelId, rooms);
1819
+ for (const room of rooms) {
1820
+ const space = buildSpace(levelId, room);
1821
+ spaces[space.id] = space;
1822
+ }
1823
+ }
1824
+ editorStore.getState().setSpaces(spaces);
1825
+ previousNodes = nodes;
1826
+ };
1827
+ adoptSceneBaseline(initialNodes);
1828
+ const unsubscribeCommits = subscribeSceneCommits((commit) => {
1829
+ if (commit.origin === 'local')
1830
+ return;
1831
+ adoptSceneBaseline(commit.current.nodes);
1832
+ });
1250
1833
  const unsubscribe = sceneStore.subscribe((state) => {
1251
1834
  if (isProcessing)
1252
1835
  return;
1253
1836
  if (getSceneHistoryPauseDepth() > 0)
1254
1837
  return;
1255
1838
  const nodes = state.nodes;
1256
- const currentSnapshots = levelStructureSnapshots(nodes);
1839
+ const candidateIds = activeSceneCommitNodeIds();
1257
1840
  // Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
1258
1841
  // every paused change once detection resumes. Whatever the AI built while
1259
1842
  // paused becomes the new baseline; only future changes will reconcile.
1260
1843
  if (spaceDetectionPauseDepth > 0) {
1261
- previousSnapshots.clear();
1262
- for (const [levelId, snapshot] of currentSnapshots.entries()) {
1263
- previousSnapshots.set(levelId, snapshot);
1844
+ adoptSceneBaseline(nodes);
1845
+ return;
1846
+ }
1847
+ const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds);
1848
+ if (candidateIds && changedWalls.size > 0) {
1849
+ const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds);
1850
+ for (const levelId of changedWalls.keys())
1851
+ fallbackLevels.delete(levelId);
1852
+ isProcessing = true;
1853
+ pauseSceneHistory(sceneStore);
1854
+ try {
1855
+ for (const [levelId, wallIds] of changedWalls) {
1856
+ const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes);
1857
+ runIndexedSpaceDetection(levelId, topologyDelta, sceneStore, editorStore, nodes, previousNodes);
1858
+ previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms);
1859
+ options.onTopologyReconcile?.({
1860
+ levelId,
1861
+ strategy: topologyDelta.strategy,
1862
+ examinedWallIds: topologyDelta.examinedWallIds,
1863
+ affectedBeforeRoomCount: topologyDelta.beforeRooms.length,
1864
+ affectedCurrentRoomCount: topologyDelta.currentRooms.length,
1865
+ });
1866
+ }
1867
+ if (fallbackLevels.size > 0) {
1868
+ runSpaceDetection([...fallbackLevels], sceneStore, editorStore, sceneStore.getState().nodes, previousNodes, previousRoomsByLevel);
1869
+ const liveNodes = sceneStore.getState().nodes;
1870
+ for (const levelId of fallbackLevels)
1871
+ topologyIndex.rebuildLevel(levelId, liveNodes);
1872
+ }
1873
+ }
1874
+ finally {
1875
+ resumeSceneHistory(sceneStore);
1876
+ previousNodes = sceneStore.getState().nodes;
1877
+ isProcessing = false;
1264
1878
  }
1265
1879
  return;
1266
1880
  }
1267
1881
  const levelsToUpdate = new Set();
1268
- for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
1269
- // First sight of a level is a hydration baseline, not a wall edit —
1270
- // `setScene` delivers a loaded scene as one atomic update, and a level's
1271
- // first wall can't close a room anyway. Record it (below) and only
1272
- // react to subsequent changes.
1273
- const previous = previousSnapshots.get(levelId);
1274
- if (previous === undefined)
1275
- continue;
1276
- if (previous !== (currentSnapshots.get(levelId) ?? '')) {
1882
+ if (candidateIds) {
1883
+ for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) {
1277
1884
  levelsToUpdate.add(levelId);
1278
1885
  }
1279
1886
  }
1887
+ else {
1888
+ const previousSnapshots = levelStructureSnapshots(previousNodes);
1889
+ const currentSnapshots = levelStructureSnapshots(nodes);
1890
+ for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
1891
+ // First sight of a level is a hydration baseline, not a wall edit —
1892
+ // `setScene` delivers a loaded scene as one atomic update, and a level's
1893
+ // first wall can't close a room anyway. Record it (below) and only
1894
+ // react to subsequent changes.
1895
+ const previous = previousSnapshots.get(levelId);
1896
+ if (previous === undefined)
1897
+ continue;
1898
+ if (previous !== (currentSnapshots.get(levelId) ?? '')) {
1899
+ levelsToUpdate.add(levelId);
1900
+ }
1901
+ }
1902
+ }
1280
1903
  if (levelsToUpdate.size === 0) {
1281
- previousSnapshots.clear();
1282
- for (const [levelId, snapshot] of currentSnapshots.entries()) {
1283
- previousSnapshots.set(levelId, snapshot);
1904
+ if (candidateIds) {
1905
+ previousNodes = nodes;
1906
+ return;
1907
+ }
1908
+ const currentRoomsByLevel = detectedRoomsByLevel(nodes);
1909
+ previousRoomsByLevel.clear();
1910
+ for (const [levelId, rooms] of currentRoomsByLevel) {
1911
+ previousRoomsByLevel.set(levelId, rooms);
1284
1912
  }
1913
+ previousNodes = nodes;
1285
1914
  return;
1286
1915
  }
1287
1916
  isProcessing = true;
1288
1917
  pauseSceneHistory(sceneStore);
1289
1918
  try {
1290
- runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes);
1919
+ runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes, previousNodes, previousRoomsByLevel);
1291
1920
  }
1292
1921
  finally {
1293
1922
  resumeSceneHistory(sceneStore);
1294
- previousSnapshots.clear();
1295
- const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
1296
- for (const [levelId, snapshot] of postRunSnapshots.entries()) {
1297
- previousSnapshots.set(levelId, snapshot);
1298
- }
1923
+ const liveNodes = sceneStore.getState().nodes;
1924
+ for (const levelId of levelsToUpdate)
1925
+ topologyIndex.rebuildLevel(levelId, liveNodes);
1926
+ previousNodes = liveNodes;
1299
1927
  isProcessing = false;
1300
1928
  }
1301
1929
  });
1302
- return unsubscribe;
1930
+ return () => {
1931
+ unsubscribe();
1932
+ unsubscribeCommits();
1933
+ };
1303
1934
  }
1304
1935
  export function wallTouchesOthers(wall, otherWalls) {
1305
1936
  const threshold = 0.1;
@@ -1315,18 +1946,3 @@ export function wallTouchesOthers(wall, otherWalls) {
1315
1946
  }
1316
1947
  return false;
1317
1948
  }
1318
- function distanceToSegment(point, segStart, segEnd) {
1319
- const [px, py] = point;
1320
- const [x1, y1] = segStart;
1321
- const [x2, y2] = segEnd;
1322
- const dx = x2 - x1;
1323
- const dy = y2 - y1;
1324
- const lenSq = dx * dx + dy * dy;
1325
- if (lenSq < 0.0001) {
1326
- return Math.hypot(px - x1, py - y1);
1327
- }
1328
- const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
1329
- const projX = x1 + t * dx;
1330
- const projY = y1 + t * dy;
1331
- return Math.hypot(px - projX, py - projY);
1332
- }