@pascal-app/core 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/room-topology-index.d.ts +50 -0
- package/dist/lib/room-topology-index.d.ts.map +1 -0
- package/dist/lib/room-topology-index.js +237 -0
- package/dist/lib/space-detection.d.ts +29 -3
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +858 -242
- package/dist/registry/index.d.ts +3 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +2 -1
- package/dist/registry/registry.d.ts +17 -1
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +74 -0
- package/dist/registry/types.d.ts +35 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry/use-registry-version.d.ts +11 -0
- package/dist/registry/use-registry-version.d.ts.map +1 -0
- package/dist/registry/use-registry-version.js +15 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -0
- package/dist/schema/nodes/level.d.ts +3 -0
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +9 -0
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/storey.d.ts +3 -10
- package/dist/services/storey.d.ts.map +1 -1
- package/dist/services/storey.js +45 -15
- package/dist/store/actions/node-actions.d.ts +8 -4
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +31 -4
- package/dist/store/history-control.d.ts +5 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +68 -6
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +86 -172
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-service.js +16 -11
- package/dist/systems/slab/slab-support.d.ts +0 -31
- package/dist/systems/slab/slab-support.d.ts.map +1 -1
- package/dist/systems/slab/slab-support.js +26 -4
- package/dist/systems/stair/stair-opening-preview.d.ts +1 -0
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-rise.d.ts.map +1 -1
- package/dist/systems/stair/stair-rise.js +4 -2
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +88 -9
- package/dist/systems/wall/wall-topology.d.ts +53 -0
- package/dist/systems/wall/wall-topology.d.ts.map +1 -0
- package/dist/systems/wall/wall-topology.js +414 -0
- package/dist/utils/clone-scene-graph.d.ts +2 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +13 -2
- package/dist/utils/heal-scene-graph.d.ts +5 -0
- package/dist/utils/heal-scene-graph.d.ts.map +1 -1
- package/dist/utils/heal-scene-graph.js +46 -1
- package/dist/utils/scene-migrations.d.ts +4 -0
- package/dist/utils/scene-migrations.d.ts.map +1 -0
- package/dist/utils/scene-migrations.js +7 -0
- package/dist/utils/vertical-scene-migration.d.ts +13 -0
- package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
- package/dist/utils/vertical-scene-migration.js +179 -0
- package/package.json +4 -4
|
@@ -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
|
|
775
|
-
|
|
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
|
-
|
|
778
|
-
|
|
1017
|
+
return (polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 ||
|
|
1018
|
+
polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0);
|
|
779
1019
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
|
|
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 =
|
|
838
|
-
const
|
|
839
|
-
const poly =
|
|
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
|
-
|
|
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
|
|
849
|
-
const
|
|
850
|
-
const
|
|
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
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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 }) => !
|
|
872
|
-
.sort((
|
|
873
|
-
const remainingAuto =
|
|
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 (
|
|
1231
|
+
if (matchedSurfaceIds.has(entry.surface.id))
|
|
878
1232
|
continue;
|
|
879
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
904
|
-
const
|
|
905
|
-
for (const
|
|
906
|
-
if (
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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 = [...
|
|
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
|
|
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
|
|
980
|
-
const
|
|
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
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
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:
|
|
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 = [...
|
|
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
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
|
1179
|
-
const
|
|
1180
|
-
const
|
|
1181
|
-
const
|
|
1182
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
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
|
-
}
|