@pascal-app/core 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events/bus.d.ts +30 -2
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
- package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
- package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +27 -1
- package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
- package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
- package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -5
- package/dist/lib/level-name.d.ts +4 -0
- package/dist/lib/level-name.d.ts.map +1 -0
- package/dist/lib/level-name.js +10 -0
- package/dist/lib/measurement-geometry.d.ts +19 -0
- package/dist/lib/measurement-geometry.d.ts.map +1 -0
- package/dist/lib/measurement-geometry.js +232 -0
- package/dist/lib/measurement-geometry.test.d.ts +2 -0
- package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
- package/dist/lib/measurement-geometry.test.js +102 -0
- package/dist/lib/polygon-geometry.d.ts +0 -1
- package/dist/lib/polygon-geometry.d.ts.map +1 -1
- package/dist/lib/polygon-geometry.js +0 -18
- package/dist/lib/selection-proxy.d.ts +4 -0
- package/dist/lib/selection-proxy.d.ts.map +1 -0
- package/dist/lib/selection-proxy.js +12 -0
- package/dist/lib/slab-polygon.d.ts +31 -2
- package/dist/lib/slab-polygon.d.ts.map +1 -1
- package/dist/lib/slab-polygon.js +525 -31
- package/dist/lib/slab-polygon.test.d.ts +2 -0
- package/dist/lib/slab-polygon.test.d.ts.map +1 -0
- package/dist/lib/slab-polygon.test.js +635 -0
- package/dist/lib/slots.d.ts +14 -0
- package/dist/lib/slots.d.ts.map +1 -0
- package/dist/lib/slots.js +26 -0
- package/dist/lib/space-detection.d.ts +25 -2
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +414 -118
- package/dist/lib/space-detection.test.js +301 -2
- package/dist/lib/wall-distance.d.ts +60 -0
- package/dist/lib/wall-distance.d.ts.map +1 -0
- package/dist/lib/wall-distance.js +85 -0
- package/dist/lib/zone-quantities.d.ts +21 -0
- package/dist/lib/zone-quantities.d.ts.map +1 -0
- package/dist/lib/zone-quantities.js +183 -0
- package/dist/lib/zone-quantities.test.d.ts +2 -0
- package/dist/lib/zone-quantities.test.d.ts.map +1 -0
- package/dist/lib/zone-quantities.test.js +82 -0
- package/dist/material-library.d.ts +20 -1
- package/dist/material-library.d.ts.map +1 -1
- package/dist/material-library.js +2181 -441
- package/dist/registry/handles.d.ts +75 -2
- package/dist/registry/handles.d.ts.map +1 -1
- package/dist/registry/index.d.ts +3 -3
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/registry.d.ts +46 -3
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +82 -1
- package/dist/registry/registry.test.js +14 -1
- package/dist/registry/subtree.d.ts.map +1 -1
- package/dist/registry/subtree.js +4 -0
- package/dist/registry/subtree.test.js +30 -0
- package/dist/registry/types.d.ts +717 -38
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/schema/index.d.ts +21 -5
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +17 -3
- package/dist/schema/material.d.ts +3 -2
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +7 -5
- package/dist/schema/material.test.d.ts +2 -0
- package/dist/schema/material.test.d.ts.map +1 -0
- package/dist/schema/material.test.js +43 -0
- package/dist/schema/nodes/box-vent.d.ts +2 -2
- package/dist/schema/nodes/cabinet.d.ts +534 -0
- package/dist/schema/nodes/cabinet.d.ts.map +1 -0
- package/dist/schema/nodes/cabinet.js +137 -0
- package/dist/schema/nodes/ceiling.d.ts +3 -2
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +4 -0
- package/dist/schema/nodes/chimney.d.ts +4 -4
- package/dist/schema/nodes/column.d.ts +3 -2
- package/dist/schema/nodes/column.d.ts.map +1 -1
- package/dist/schema/nodes/column.js +3 -0
- package/dist/schema/nodes/cupola.d.ts +2 -2
- package/dist/schema/nodes/door.d.ts +13 -5
- package/dist/schema/nodes/door.d.ts.map +1 -1
- package/dist/schema/nodes/door.js +12 -0
- package/dist/schema/nodes/dormer.d.ts +8 -8
- package/dist/schema/nodes/dormer.js +1 -1
- package/dist/schema/nodes/downspout.d.ts +2 -2
- package/dist/schema/nodes/duct-fitting.d.ts +91 -0
- package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
- package/dist/schema/nodes/duct-fitting.js +92 -0
- package/dist/schema/nodes/duct-segment.d.ts +61 -0
- package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
- package/dist/schema/nodes/duct-segment.js +73 -0
- package/dist/schema/nodes/duct-terminal.d.ts +57 -0
- package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
- package/dist/schema/nodes/duct-terminal.js +51 -0
- package/dist/schema/nodes/elevator.d.ts +3 -2
- package/dist/schema/nodes/elevator.d.ts.map +1 -1
- package/dist/schema/nodes/elevator.js +3 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
- package/dist/schema/nodes/fence.d.ts +18 -2
- package/dist/schema/nodes/fence.d.ts.map +1 -1
- package/dist/schema/nodes/fence.js +25 -2
- package/dist/schema/nodes/gutter.d.ts +2 -2
- package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
- package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
- package/dist/schema/nodes/hvac-equipment.js +55 -0
- package/dist/schema/nodes/item.d.ts +8 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +11 -0
- package/dist/schema/nodes/level.d.ts +1 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +2 -0
- package/dist/schema/nodes/lineset.d.ts +42 -0
- package/dist/schema/nodes/lineset.d.ts.map +1 -0
- package/dist/schema/nodes/lineset.js +38 -0
- package/dist/schema/nodes/liquid-line.d.ts +34 -0
- package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
- package/dist/schema/nodes/liquid-line.js +24 -0
- package/dist/schema/nodes/measurement.d.ts +306 -0
- package/dist/schema/nodes/measurement.d.ts.map +1 -0
- package/dist/schema/nodes/measurement.js +87 -0
- package/dist/schema/nodes/measurement.test.d.ts +2 -0
- package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
- package/dist/schema/nodes/measurement.test.js +139 -0
- package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
- package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-fitting.js +45 -0
- package/dist/schema/nodes/pipe-segment.d.ts +48 -0
- package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-segment.js +36 -0
- package/dist/schema/nodes/pipe-trap.d.ts +43 -0
- package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-trap.js +36 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
- package/dist/schema/nodes/ridge-vent.d.ts +18 -3
- package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
- package/dist/schema/nodes/ridge-vent.js +221 -3
- package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
- package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-shape.js +318 -0
- package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
- package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
- package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
- package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
- package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-walls.js +330 -0
- package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
- package/dist/schema/nodes/roof-segment.d.ts +84 -8
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +290 -5
- package/dist/schema/nodes/roof.d.ts +8 -8
- package/dist/schema/nodes/shelf.d.ts +3 -2
- package/dist/schema/nodes/shelf.d.ts.map +1 -1
- package/dist/schema/nodes/shelf.js +6 -0
- package/dist/schema/nodes/skylight.d.ts +10 -10
- package/dist/schema/nodes/slab.d.ts +3 -2
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +4 -0
- package/dist/schema/nodes/solar-panel.d.ts +4 -4
- package/dist/schema/nodes/stair-segment.d.ts +4 -4
- package/dist/schema/nodes/stair.d.ts +13 -12
- package/dist/schema/nodes/stair.d.ts.map +1 -1
- package/dist/schema/nodes/stair.js +3 -0
- package/dist/schema/nodes/turbine-vent.d.ts +2 -2
- package/dist/schema/nodes/wall.d.ts +245 -6
- package/dist/schema/nodes/wall.d.ts.map +1 -1
- package/dist/schema/nodes/wall.js +257 -0
- package/dist/schema/nodes/wall.test.d.ts +2 -0
- package/dist/schema/nodes/wall.test.d.ts.map +1 -0
- package/dist/schema/nodes/wall.test.js +208 -0
- package/dist/schema/nodes/window.d.ts +11 -3
- package/dist/schema/nodes/window.d.ts.map +1 -1
- package/dist/schema/nodes/window.js +12 -0
- package/dist/schema/nodes/zone.d.ts +2 -0
- package/dist/schema/nodes/zone.d.ts.map +1 -1
- package/dist/schema/nodes/zone.js +6 -0
- package/dist/schema/scene-material.d.ts +41 -0
- package/dist/schema/scene-material.d.ts.map +1 -0
- package/dist/schema/scene-material.js +9 -0
- package/dist/schema/types.d.ts +1089 -94
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +23 -0
- package/dist/services/alignment-anchors.d.ts.map +1 -1
- package/dist/services/alignment-anchors.js +30 -1
- package/dist/services/alignment.d.ts +5 -0
- package/dist/services/alignment.d.ts.map +1 -1
- package/dist/services/alignment.js +2 -0
- package/dist/services/hosting.d.ts +8 -0
- package/dist/services/hosting.d.ts.map +1 -1
- package/dist/services/hosting.js +13 -4
- package/dist/services/hosting.test.js +32 -1
- package/dist/services/index.d.ts +8 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +8 -2
- package/dist/services/level-height.d.ts +28 -0
- package/dist/services/level-height.d.ts.map +1 -0
- package/dist/services/level-height.js +62 -0
- package/dist/services/opening-guides.d.ts +141 -0
- package/dist/services/opening-guides.d.ts.map +1 -0
- package/dist/services/opening-guides.js +267 -0
- package/dist/services/opening-guides.test.d.ts +2 -0
- package/dist/services/opening-guides.test.d.ts.map +1 -0
- package/dist/services/opening-guides.test.js +208 -0
- package/dist/services/port-connectivity.d.ts +115 -0
- package/dist/services/port-connectivity.d.ts.map +1 -0
- package/dist/services/port-connectivity.js +300 -0
- package/dist/services/port-connectivity.test.d.ts +2 -0
- package/dist/services/port-connectivity.test.d.ts.map +1 -0
- package/dist/services/port-connectivity.test.js +297 -0
- package/dist/services/riser-diagram.d.ts +40 -0
- package/dist/services/riser-diagram.d.ts.map +1 -0
- package/dist/services/riser-diagram.js +110 -0
- package/dist/services/riser-diagram.test.d.ts +2 -0
- package/dist/services/riser-diagram.test.d.ts.map +1 -0
- package/dist/services/riser-diagram.test.js +66 -0
- package/dist/services/snap.d.ts +9 -1
- package/dist/services/snap.d.ts.map +1 -1
- package/dist/services/snap.js +21 -1
- package/dist/services/snap.test.js +43 -1
- package/dist/services/system-graph.d.ts +30 -0
- package/dist/services/system-graph.d.ts.map +1 -0
- package/dist/services/system-graph.js +164 -0
- package/dist/services/system-graph.test.d.ts +2 -0
- package/dist/services/system-graph.test.d.ts.map +1 -0
- package/dist/services/system-graph.test.js +138 -0
- package/dist/services/validate-dwv.d.ts +32 -0
- package/dist/services/validate-dwv.d.ts.map +1 -0
- package/dist/services/validate-dwv.js +118 -0
- package/dist/services/validate-dwv.test.d.ts +2 -0
- package/dist/services/validate-dwv.test.d.ts.map +1 -0
- package/dist/services/validate-dwv.test.js +109 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +94 -1
- package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
- package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
- package/dist/store/actions/ridge-vent-update.test.js +264 -0
- package/dist/store/history-control.d.ts +11 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +33 -0
- package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
- package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
- package/dist/store/use-scene-dirty-tracking.test.js +68 -0
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
- package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
- package/dist/store/use-scene-plugins.test.d.ts +2 -0
- package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
- package/dist/store/use-scene-plugins.test.js +51 -0
- package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
- package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
- package/dist/store/use-scene-window-migration.test.d.ts +2 -0
- package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-window-migration.test.js +85 -0
- package/dist/store/use-scene.d.ts +17 -2
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +358 -51
- package/dist/systems/elevator/elevator-service.js +1 -1
- package/dist/systems/fence/fence-centerline.d.ts +12 -0
- package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
- package/dist/systems/fence/fence-centerline.js +37 -0
- package/dist/systems/fence/fence-spline.d.ts +39 -0
- package/dist/systems/fence/fence-spline.d.ts.map +1 -0
- package/dist/systems/fence/fence-spline.js +196 -0
- package/dist/systems/fence/fence-spline.test.d.ts +2 -0
- package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
- package/dist/systems/fence/fence-spline.test.js +136 -0
- package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
- package/dist/systems/stair/stair-footprint.js +21 -11
- package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.js +1 -18
- package/dist/systems/stair/stair-opening-sync.test.js +39 -0
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +38 -2
- package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
- package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-mitering.test.js +63 -0
- package/dist/utils/clone-scene-graph.d.ts +1 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +23 -3
- package/dist/utils/clone-scene-graph.test.js +3 -0
- package/dist/utils/heal-scene-graph.d.ts +18 -0
- package/dist/utils/heal-scene-graph.d.ts.map +1 -0
- package/dist/utils/heal-scene-graph.js +90 -0
- package/dist/utils/heal-scene-graph.test.d.ts +2 -0
- package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
- package/dist/utils/heal-scene-graph.test.js +88 -0
- package/dist/validation/validate-build-json.d.ts +1 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -1
- package/dist/validation/validate-build-json.js +31 -2
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CeilingNode, SlabNode, } from '../schema';
|
|
1
|
+
import { CeilingNode, SlabNode, ZoneNode, } from '../schema';
|
|
2
2
|
import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
|
|
3
3
|
import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
|
|
4
4
|
import { simplifyClosedPolygon } from './polygon-geometry';
|
|
@@ -9,6 +9,16 @@ const ROOM_CURVE_TOLERANCE = 0.04;
|
|
|
9
9
|
const MAX_CURVE_SUBDIVISION_DEPTH = 6;
|
|
10
10
|
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08;
|
|
11
11
|
const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08;
|
|
12
|
+
// A wall endpoint within this distance of another wall's interior is treated as a
|
|
13
|
+
// T-junction and splits that wall (see `splitStraightWallAtVertices`).
|
|
14
|
+
const WALL_JUNCTION_TOLERANCE = 0.08;
|
|
15
|
+
// An unmatched auto slab/ceiling whose polygon is still substantially covered
|
|
16
|
+
// by a detected room was absorbed by a room merge — the surviving auto surface
|
|
17
|
+
// owns that area, so keeping it would z-fight and it is deleted. Below this
|
|
18
|
+
// coverage the room genuinely ceased to exist (e.g. an enclosing wall was
|
|
19
|
+
// deleted) and the node is demoted to manual so user data survives.
|
|
20
|
+
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6;
|
|
21
|
+
const COVERAGE_SAMPLE_STEPS = 12;
|
|
12
22
|
function pointFromTuple(point) {
|
|
13
23
|
return { x: point[0], y: point[1] };
|
|
14
24
|
}
|
|
@@ -116,6 +126,46 @@ function bboxOverlapArea(a, b) {
|
|
|
116
126
|
const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY));
|
|
117
127
|
return ix * iy;
|
|
118
128
|
}
|
|
129
|
+
// Fraction of `subject`'s area lying inside any of `covers`, estimated by
|
|
130
|
+
// sampling a grid of cell centers over the subject's bbox. Cheap and robust
|
|
131
|
+
// enough for the merge-vs-demote decision; exact polygon clipping would be a
|
|
132
|
+
// heavy dependency for a 60% threshold.
|
|
133
|
+
function polygonCoverageRatio(subject, covers) {
|
|
134
|
+
if (subject.length < 3 || covers.length === 0)
|
|
135
|
+
return 0;
|
|
136
|
+
const bbox = bboxOf(subject);
|
|
137
|
+
const width = bbox.maxX - bbox.minX;
|
|
138
|
+
const height = bbox.maxY - bbox.minY;
|
|
139
|
+
let inside = 0;
|
|
140
|
+
let covered = 0;
|
|
141
|
+
for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) {
|
|
142
|
+
for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) {
|
|
143
|
+
const point = {
|
|
144
|
+
x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width,
|
|
145
|
+
y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height,
|
|
146
|
+
};
|
|
147
|
+
if (!pointInPolygon(point, subject))
|
|
148
|
+
continue;
|
|
149
|
+
inside += 1;
|
|
150
|
+
if (pointInAnyPolygon(point, covers))
|
|
151
|
+
covered += 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (inside === 0) {
|
|
155
|
+
return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
return covered / inside;
|
|
158
|
+
}
|
|
159
|
+
// Demoted auto surfaces keep their polygon untouched, so a re-closed room
|
|
160
|
+
// usually hits the exact-signature manual check. Coverage handles the rest:
|
|
161
|
+
// a room split across multiple manual surfaces AND a single manual surface
|
|
162
|
+
// spanning multiple rooms both suppress a replacement auto surface — what
|
|
163
|
+
// matters is that the ROOM is already substantially covered, not that any
|
|
164
|
+
// one manual surface belongs to it (a per-surface "mostly inside the room"
|
|
165
|
+
// filter dropped multi-room slabs and resurrected deleted auto slabs).
|
|
166
|
+
function matchesManualFootprint(roomPolygon, manualPolygons) {
|
|
167
|
+
return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD;
|
|
168
|
+
}
|
|
119
169
|
function pointDistanceToPolygonBoundary(point, polygon) {
|
|
120
170
|
let minDistance = Number.POSITIVE_INFINITY;
|
|
121
171
|
for (let index = 0; index < polygon.length; index += 1) {
|
|
@@ -240,11 +290,52 @@ function sampleWallPointsForRoomDetection(wall, tolerance = ROOM_CURVE_TOLERANCE
|
|
|
240
290
|
};
|
|
241
291
|
return subdivide(0, start, 1, end, 0);
|
|
242
292
|
}
|
|
243
|
-
function
|
|
244
|
-
const
|
|
245
|
-
|
|
293
|
+
function segmentProjection(point, start, end) {
|
|
294
|
+
const dx = end.x - start.x;
|
|
295
|
+
const dy = end.y - start.y;
|
|
296
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
297
|
+
if (lengthSquared < 1e-12) {
|
|
298
|
+
return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) };
|
|
299
|
+
}
|
|
300
|
+
const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared;
|
|
301
|
+
const clampedT = Math.max(0, Math.min(1, t));
|
|
302
|
+
const projX = start.x + clampedT * dx;
|
|
303
|
+
const projY = start.y + clampedT * dy;
|
|
304
|
+
return { t, distance: Math.hypot(point.x - projX, point.y - projY) };
|
|
246
305
|
}
|
|
247
|
-
|
|
306
|
+
// Break a straight wall at any junction vertex (another wall's endpoint) that
|
|
307
|
+
// lands on its interior, returning the ordered polyline [start, …splits, end].
|
|
308
|
+
// Splitting at the *vertex* position (not the projection) keeps the split node's
|
|
309
|
+
// key identical to the touching wall's endpoint so the two share a graph node.
|
|
310
|
+
function splitStraightWallAtVertices(start, end, vertices) {
|
|
311
|
+
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
|
312
|
+
if (length < 1e-9)
|
|
313
|
+
return [start, end];
|
|
314
|
+
const interior = [];
|
|
315
|
+
for (const vertex of vertices) {
|
|
316
|
+
const { t, distance } = segmentProjection(vertex, start, end);
|
|
317
|
+
if (distance > WALL_JUNCTION_TOLERANCE)
|
|
318
|
+
continue;
|
|
319
|
+
const along = t * length;
|
|
320
|
+
if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE)
|
|
321
|
+
continue;
|
|
322
|
+
interior.push({ point: vertex, t });
|
|
323
|
+
}
|
|
324
|
+
interior.sort((a, b) => a.t - b.t);
|
|
325
|
+
const ordered = [start];
|
|
326
|
+
let lastKey = pointKey(start);
|
|
327
|
+
for (const { point } of interior) {
|
|
328
|
+
const key = pointKey(point);
|
|
329
|
+
if (key === lastKey)
|
|
330
|
+
continue;
|
|
331
|
+
ordered.push(point);
|
|
332
|
+
lastKey = key;
|
|
333
|
+
}
|
|
334
|
+
if (lastKey !== pointKey(end))
|
|
335
|
+
ordered.push(end);
|
|
336
|
+
return ordered;
|
|
337
|
+
}
|
|
338
|
+
function extractRooms(walls) {
|
|
248
339
|
if (walls.length < 3)
|
|
249
340
|
return [];
|
|
250
341
|
const graph = new Map();
|
|
@@ -256,35 +347,71 @@ function extractRoomPolygons(walls) {
|
|
|
256
347
|
}
|
|
257
348
|
return key;
|
|
258
349
|
};
|
|
350
|
+
// Planarize first: collect every wall endpoint as a candidate graph vertex so
|
|
351
|
+
// straight walls can be split at T-junctions where another wall ends mid-span.
|
|
352
|
+
// Without this the touching wall's endpoint is a dangling degree-1 node and the
|
|
353
|
+
// enclosed area (e.g. a room added against the middle of an existing wall)
|
|
354
|
+
// never forms a cycle.
|
|
355
|
+
const vertexByKey = new Map();
|
|
356
|
+
for (const wall of walls) {
|
|
357
|
+
for (const tuple of [wall.start, wall.end]) {
|
|
358
|
+
const point = pointFromTuple(tuple);
|
|
359
|
+
const key = pointKey(point);
|
|
360
|
+
if (!vertexByKey.has(key))
|
|
361
|
+
vertexByKey.set(key, point);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const vertices = [...vertexByKey.values()];
|
|
259
365
|
for (const wall of walls) {
|
|
260
366
|
const start = pointFromTuple(wall.start);
|
|
261
367
|
const end = pointFromTuple(wall.end);
|
|
262
|
-
|
|
263
|
-
const endKey = upsertNode(end);
|
|
264
|
-
if (startKey === endKey)
|
|
368
|
+
if (samePointWithinTolerance(start, end))
|
|
265
369
|
continue;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
370
|
+
// Curved walls keep their sampled polyline as one edge; straight walls split
|
|
371
|
+
// into consecutive sub-edges at their interior junction vertices.
|
|
372
|
+
const subPolylines = isCurvedWall(wall)
|
|
373
|
+
? [sampleWallPointsForRoomDetection(wall)]
|
|
374
|
+
: (() => {
|
|
375
|
+
const ordered = splitStraightWallAtVertices(start, end, vertices);
|
|
376
|
+
const parts = [];
|
|
377
|
+
for (let index = 0; index < ordered.length - 1; index += 1) {
|
|
378
|
+
parts.push([ordered[index], ordered[index + 1]]);
|
|
379
|
+
}
|
|
380
|
+
return parts;
|
|
381
|
+
})();
|
|
382
|
+
subPolylines.forEach((points, subIndex) => {
|
|
383
|
+
const from = points[0];
|
|
384
|
+
const to = points[points.length - 1];
|
|
385
|
+
const fromKey = upsertNode(from);
|
|
386
|
+
const toKey = upsertNode(to);
|
|
387
|
+
if (fromKey === toKey)
|
|
388
|
+
return;
|
|
389
|
+
const reversePoints = [...points].reverse();
|
|
390
|
+
const forwardId = `${wall.id}#${subIndex}:f`;
|
|
391
|
+
const reverseId = `${wall.id}#${subIndex}:r`;
|
|
392
|
+
halfEdges.set(forwardId, {
|
|
393
|
+
id: forwardId,
|
|
394
|
+
reverseId,
|
|
395
|
+
fromKey,
|
|
396
|
+
toKey,
|
|
397
|
+
angle: Math.atan2(points[1].y - from.y, points[1].x - from.x),
|
|
398
|
+
points,
|
|
399
|
+
wallId: wall.id,
|
|
400
|
+
face: 'front',
|
|
401
|
+
});
|
|
402
|
+
halfEdges.set(reverseId, {
|
|
403
|
+
id: reverseId,
|
|
404
|
+
reverseId: forwardId,
|
|
405
|
+
fromKey: toKey,
|
|
406
|
+
toKey: fromKey,
|
|
407
|
+
angle: Math.atan2(reversePoints[1].y - to.y, reversePoints[1].x - to.x),
|
|
408
|
+
points: reversePoints,
|
|
409
|
+
wallId: wall.id,
|
|
410
|
+
face: 'back',
|
|
411
|
+
});
|
|
412
|
+
graph.get(fromKey)?.outgoing.push(forwardId);
|
|
413
|
+
graph.get(toKey)?.outgoing.push(reverseId);
|
|
277
414
|
});
|
|
278
|
-
halfEdges.set(reverseId, {
|
|
279
|
-
id: reverseId,
|
|
280
|
-
reverseId: forwardId,
|
|
281
|
-
fromKey: endKey,
|
|
282
|
-
toKey: startKey,
|
|
283
|
-
angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
|
|
284
|
-
points: getDirectedWallBoundaryPoints(wall, false),
|
|
285
|
-
});
|
|
286
|
-
graph.get(startKey)?.outgoing.push(forwardId);
|
|
287
|
-
graph.get(endKey)?.outgoing.push(reverseId);
|
|
288
415
|
}
|
|
289
416
|
const sortedOutgoing = new Map();
|
|
290
417
|
for (const [key, node] of graph.entries()) {
|
|
@@ -305,15 +432,49 @@ function extractRoomPolygons(walls) {
|
|
|
305
432
|
const nextIdx = (idx - 1 + outgoing.length) % outgoing.length;
|
|
306
433
|
return outgoing[nextIdx] ?? null;
|
|
307
434
|
};
|
|
435
|
+
const splitIntoSimpleCycles = (walkEdgeIds) => {
|
|
436
|
+
const cycles = [];
|
|
437
|
+
const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '');
|
|
438
|
+
if (!firstEdge)
|
|
439
|
+
return cycles;
|
|
440
|
+
const pathEdges = [];
|
|
441
|
+
const pathVertices = [firstEdge.fromKey];
|
|
442
|
+
const vertexIndex = new Map([[firstEdge.fromKey, 0]]);
|
|
443
|
+
for (const edgeId of walkEdgeIds) {
|
|
444
|
+
const edge = halfEdges.get(edgeId);
|
|
445
|
+
if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1])
|
|
446
|
+
return [];
|
|
447
|
+
pathEdges.push(edgeId);
|
|
448
|
+
const repeatedIndex = vertexIndex.get(edge.toKey);
|
|
449
|
+
if (repeatedIndex === undefined) {
|
|
450
|
+
pathVertices.push(edge.toKey);
|
|
451
|
+
vertexIndex.set(edge.toKey, pathVertices.length - 1);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const cycle = pathEdges.slice(repeatedIndex);
|
|
455
|
+
if (cycle.length >= 3)
|
|
456
|
+
cycles.push(cycle);
|
|
457
|
+
for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) {
|
|
458
|
+
vertexIndex.delete(pathVertices[index]);
|
|
459
|
+
}
|
|
460
|
+
pathVertices.length = repeatedIndex + 1;
|
|
461
|
+
pathEdges.length = repeatedIndex;
|
|
462
|
+
}
|
|
463
|
+
return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [];
|
|
464
|
+
};
|
|
308
465
|
const visitedDirected = new Set();
|
|
309
|
-
const
|
|
310
|
-
|
|
466
|
+
const rooms = [];
|
|
467
|
+
// A face walk cannot revisit a half-edge, so the half-edge count bounds its
|
|
468
|
+
// length. It can revisit a vertex when dangling walls or other graph bridges
|
|
469
|
+
// are traced out and back; those excursions are removed below.
|
|
470
|
+
const maxSteps = Math.min(2000, halfEdges.size + 10);
|
|
311
471
|
for (const edgeId of halfEdges.keys()) {
|
|
312
472
|
if (visitedDirected.has(edgeId))
|
|
313
473
|
continue;
|
|
314
474
|
const cycleEdgeIds = [];
|
|
315
475
|
let currentEdgeId = edgeId;
|
|
316
476
|
let valid = true;
|
|
477
|
+
let closed = false;
|
|
317
478
|
for (let step = 0; step < maxSteps; step += 1) {
|
|
318
479
|
const currentEdge = halfEdges.get(currentEdgeId);
|
|
319
480
|
if (!currentEdge) {
|
|
@@ -328,29 +489,64 @@ function extractRoomPolygons(walls) {
|
|
|
328
489
|
break;
|
|
329
490
|
}
|
|
330
491
|
currentEdgeId = next;
|
|
331
|
-
if (currentEdgeId === edgeId)
|
|
492
|
+
if (currentEdgeId === edgeId) {
|
|
493
|
+
closed = true;
|
|
332
494
|
break;
|
|
495
|
+
}
|
|
333
496
|
}
|
|
334
|
-
if (!valid || cycleEdgeIds.length < 3)
|
|
497
|
+
if (!(valid && closed) || cycleEdgeIds.length < 3)
|
|
335
498
|
continue;
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
499
|
+
for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) {
|
|
500
|
+
const polygon = dedupeSequentialPoints(simpleCycleEdgeIds.flatMap((id, index) => {
|
|
501
|
+
const points = halfEdges.get(id)?.points ?? [];
|
|
502
|
+
return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1);
|
|
503
|
+
}));
|
|
504
|
+
if (polygon.length < 3)
|
|
505
|
+
continue;
|
|
506
|
+
const signedArea = polygonArea(polygon);
|
|
507
|
+
if (signedArea <= 0)
|
|
508
|
+
continue;
|
|
509
|
+
if (signedArea < 0.5 || signedArea > 10_000)
|
|
510
|
+
continue;
|
|
511
|
+
const signature = polygonSignature(polygon);
|
|
512
|
+
if (rooms.some((room) => polygonSignature(room.polygon) === signature))
|
|
513
|
+
continue;
|
|
514
|
+
rooms.push({
|
|
515
|
+
polygon,
|
|
516
|
+
boundaryFaces: simpleCycleEdgeIds.flatMap((id) => {
|
|
517
|
+
const edge = halfEdges.get(id);
|
|
518
|
+
if (!edge)
|
|
519
|
+
return [];
|
|
520
|
+
return [
|
|
521
|
+
{
|
|
522
|
+
wallId: edge.wallId,
|
|
523
|
+
face: edge.face,
|
|
524
|
+
points: edge.points.map(pointToTuple),
|
|
525
|
+
},
|
|
526
|
+
];
|
|
527
|
+
}),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
351
530
|
}
|
|
352
|
-
|
|
353
|
-
return
|
|
531
|
+
rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)));
|
|
532
|
+
return rooms;
|
|
533
|
+
}
|
|
534
|
+
function extractRoomPolygons(walls) {
|
|
535
|
+
return extractRooms(walls).map((room) => room.polygon);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* True when `wall` lies on the boundary of a room enclosed by `walls`, using the
|
|
539
|
+
* same planar room graph the auto slab/ceiling sync uses. The wall builder's
|
|
540
|
+
* "Room (auto-close)" mode calls this so drafting stops the moment a segment
|
|
541
|
+
* closes a room — whether the chain loops back to its own start or seals a bay
|
|
542
|
+
* against the middle of an existing wall (a T-junction). Sharing one graph means
|
|
543
|
+
* auto-close and auto-slab detection can never disagree about what is "closed".
|
|
544
|
+
*/
|
|
545
|
+
export function wallClosesRoom(walls, wall) {
|
|
546
|
+
const roomPolygons = extractRoomPolygons(walls);
|
|
547
|
+
if (roomPolygons.length === 0)
|
|
548
|
+
return false;
|
|
549
|
+
return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon));
|
|
354
550
|
}
|
|
355
551
|
export function resolveWallSurfaceSides(wall, roomPolygons) {
|
|
356
552
|
if (roomPolygons.length === 0) {
|
|
@@ -423,55 +619,104 @@ function wallGeometrySignature(wall) {
|
|
|
423
619
|
function levelWallSnapshot(walls) {
|
|
424
620
|
return walls.map(wallGeometrySignature).sort().join('||');
|
|
425
621
|
}
|
|
426
|
-
function
|
|
427
|
-
|
|
428
|
-
.
|
|
429
|
-
.
|
|
430
|
-
|
|
431
|
-
.map((
|
|
432
|
-
|
|
433
|
-
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join('|');
|
|
434
|
-
}
|
|
435
|
-
function levelSlabSnapshot(slabs) {
|
|
436
|
-
return slabs.map(slabGeometrySignature).sort().join('||');
|
|
622
|
+
function zoneGeometrySignature(zone) {
|
|
623
|
+
return [
|
|
624
|
+
zone.id,
|
|
625
|
+
zone.autoFromWalls ? 'auto' : 'manual',
|
|
626
|
+
zone.boundaryWallIds.slice().sort().join(','),
|
|
627
|
+
zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'),
|
|
628
|
+
].join('|');
|
|
437
629
|
}
|
|
630
|
+
// Slabs and ceilings stay out of the trigger signature: including generated
|
|
631
|
+
// surfaces caused delete/recreate feedback. Zones are included only so a newly
|
|
632
|
+
// traced room footprint can adopt its enclosing walls without waiting for the
|
|
633
|
+
// next remodel.
|
|
438
634
|
function levelStructureSnapshots(nodes) {
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] };
|
|
442
|
-
byLevel.set(levelId, entry);
|
|
443
|
-
return entry;
|
|
444
|
-
};
|
|
635
|
+
const wallsByLevel = new Map();
|
|
636
|
+
const zonesByLevel = new Map();
|
|
445
637
|
for (const node of Object.values(nodes)) {
|
|
446
638
|
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId))
|
|
447
639
|
continue;
|
|
640
|
+
const levelId = node.parentId;
|
|
448
641
|
if (node.type === 'wall') {
|
|
449
|
-
|
|
642
|
+
const walls = wallsByLevel.get(levelId) ?? [];
|
|
643
|
+
walls.push(node);
|
|
644
|
+
wallsByLevel.set(levelId, walls);
|
|
450
645
|
}
|
|
451
|
-
else if (node.type === '
|
|
452
|
-
|
|
646
|
+
else if (node.type === 'zone') {
|
|
647
|
+
const zones = zonesByLevel.get(levelId) ?? [];
|
|
648
|
+
zones.push(ZoneNode.parse(node));
|
|
649
|
+
zonesByLevel.set(levelId, zones);
|
|
453
650
|
}
|
|
454
651
|
}
|
|
455
652
|
const snapshots = new Map();
|
|
456
|
-
|
|
457
|
-
|
|
653
|
+
const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]);
|
|
654
|
+
for (const levelId of levelIds) {
|
|
655
|
+
const walls = wallsByLevel.get(levelId) ?? [];
|
|
656
|
+
const zones = zonesByLevel.get(levelId) ?? [];
|
|
657
|
+
snapshots.set(levelId, `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`);
|
|
458
658
|
}
|
|
459
659
|
return snapshots;
|
|
460
660
|
}
|
|
461
|
-
function buildSpace(levelId,
|
|
462
|
-
const signature = polygonSignature(polygon);
|
|
661
|
+
function buildSpace(levelId, room) {
|
|
662
|
+
const signature = polygonSignature(room.polygon);
|
|
463
663
|
return {
|
|
464
664
|
id: `space-${levelId}-${signature.slice(0, 12)}`,
|
|
465
665
|
levelId,
|
|
466
|
-
polygon: polygon.map(pointToTuple),
|
|
467
|
-
wallIds: [],
|
|
666
|
+
polygon: room.polygon.map(pointToTuple),
|
|
667
|
+
wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
|
|
668
|
+
boundaryFaces: room.boundaryFaces,
|
|
468
669
|
isExterior: false,
|
|
469
670
|
};
|
|
470
671
|
}
|
|
672
|
+
function sameStringSet(a, b) {
|
|
673
|
+
if (a.length !== b.length)
|
|
674
|
+
return false;
|
|
675
|
+
const right = new Set(b);
|
|
676
|
+
return a.every((value) => right.has(value));
|
|
677
|
+
}
|
|
678
|
+
export function planAutoZonesForLevel(spaces, existingZones) {
|
|
679
|
+
const update = [];
|
|
680
|
+
for (const zone of existingZones) {
|
|
681
|
+
const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
|
|
682
|
+
const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
|
|
683
|
+
? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
|
|
684
|
+
: spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
|
|
685
|
+
if (!matchingSpace)
|
|
686
|
+
continue;
|
|
687
|
+
const data = {};
|
|
688
|
+
if (!zone.autoFromWalls)
|
|
689
|
+
data.autoFromWalls = true;
|
|
690
|
+
if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
|
|
691
|
+
data.boundaryWallIds = matchingSpace.wallIds;
|
|
692
|
+
}
|
|
693
|
+
if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
|
|
694
|
+
data.polygon = matchingSpace.polygon;
|
|
695
|
+
}
|
|
696
|
+
if (Object.keys(data).length > 0)
|
|
697
|
+
update.push({ id: zone.id, data });
|
|
698
|
+
}
|
|
699
|
+
return { update };
|
|
700
|
+
}
|
|
701
|
+
export function resolveAutoZonePolygon(zone, resolve) {
|
|
702
|
+
if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
|
|
703
|
+
return zone.polygon;
|
|
704
|
+
const walls = zone.boundaryWallIds.flatMap((id) => {
|
|
705
|
+
const node = resolve(id);
|
|
706
|
+
return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
|
|
707
|
+
? [node]
|
|
708
|
+
: [];
|
|
709
|
+
});
|
|
710
|
+
if (walls.length !== zone.boundaryWallIds.length)
|
|
711
|
+
return zone.polygon;
|
|
712
|
+
const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
|
|
713
|
+
return room ? room.polygon.map(pointToTuple) : zone.polygon;
|
|
714
|
+
}
|
|
471
715
|
export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
|
|
472
716
|
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
|
|
473
717
|
const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
|
|
474
|
-
const
|
|
718
|
+
const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
|
|
719
|
+
const detectedAll = roomPolygons
|
|
475
720
|
.map((poly) => ({
|
|
476
721
|
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
|
|
477
722
|
sig: '',
|
|
@@ -485,8 +730,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
|
|
|
485
730
|
centroid: polygonCentroid(room.poly),
|
|
486
731
|
area: Math.abs(polygonArea(room.poly)),
|
|
487
732
|
bbox: bboxOf(room.poly),
|
|
488
|
-
}))
|
|
489
|
-
|
|
733
|
+
}));
|
|
734
|
+
const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
|
|
490
735
|
const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
|
|
491
736
|
const existingAutoMeta = existingAuto.map((slab) => {
|
|
492
737
|
const poly = slab.polygon.map(pointFromTuple);
|
|
@@ -503,10 +748,12 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
|
|
|
503
748
|
const updatesById = new Map();
|
|
504
749
|
const autoBySignature = new Map();
|
|
505
750
|
for (const entry of existingAutoMeta) {
|
|
506
|
-
autoBySignature.
|
|
751
|
+
const bucket = autoBySignature.get(entry.sig) ?? [];
|
|
752
|
+
bucket.push(entry);
|
|
753
|
+
autoBySignature.set(entry.sig, bucket);
|
|
507
754
|
}
|
|
508
755
|
detected.forEach((room, index) => {
|
|
509
|
-
const existing = autoBySignature.get(room.sig);
|
|
756
|
+
const existing = autoBySignature.get(room.sig)?.shift();
|
|
510
757
|
if (!existing)
|
|
511
758
|
return;
|
|
512
759
|
matchedDetectedIdx.add(index);
|
|
@@ -542,17 +789,33 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
|
|
|
542
789
|
matchedSlabIds.add(bestMatch.entry.slab.id);
|
|
543
790
|
updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple));
|
|
544
791
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
792
|
+
const detectedRoomPolygons = detectedAll.map((room) => room.poly);
|
|
793
|
+
const slabsToDelete = [];
|
|
794
|
+
const slabDemotions = [];
|
|
795
|
+
for (const slab of existingAuto) {
|
|
796
|
+
if (updatesById.has(slab.id))
|
|
797
|
+
continue;
|
|
798
|
+
const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons);
|
|
799
|
+
if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
|
|
800
|
+
slabsToDelete.push(slab.id);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
// Render offsets derive from level context at geometry build time, so
|
|
804
|
+
// demotion leaves the stored polygon untouched (same as ceilings).
|
|
805
|
+
slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const slabsToUpdate = [
|
|
809
|
+
...existingAuto
|
|
810
|
+
.filter((slab) => updatesById.has(slab.id))
|
|
811
|
+
.flatMap((slab) => {
|
|
812
|
+
const polygon = updatesById.get(slab.id);
|
|
813
|
+
if (!polygon)
|
|
814
|
+
return [];
|
|
815
|
+
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }];
|
|
816
|
+
}),
|
|
817
|
+
...slabDemotions,
|
|
818
|
+
];
|
|
556
819
|
const plannedSlabsForNaming = [...existingSlabs];
|
|
557
820
|
const slabsToCreate = [];
|
|
558
821
|
for (let index = 0; index < detected.length; index += 1) {
|
|
@@ -609,7 +872,8 @@ export function projectAutoSlabsForPlan(existingSlabs, plan) {
|
|
|
609
872
|
export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
|
|
610
873
|
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
|
|
611
874
|
const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
|
|
612
|
-
const
|
|
875
|
+
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
|
|
876
|
+
const detectedAll = roomPolygons
|
|
613
877
|
.map((poly) => ({
|
|
614
878
|
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
|
|
615
879
|
sig: '',
|
|
@@ -624,8 +888,8 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
|
|
|
624
888
|
area: Math.abs(polygonArea(room.poly)),
|
|
625
889
|
bbox: bboxOf(room.poly),
|
|
626
890
|
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
|
|
627
|
-
}))
|
|
628
|
-
|
|
891
|
+
}));
|
|
892
|
+
const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
|
|
629
893
|
const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
|
|
630
894
|
const existingAutoMeta = existingAuto.map((ceiling) => {
|
|
631
895
|
const poly = ceiling.polygon.map(pointFromTuple);
|
|
@@ -642,10 +906,12 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
|
|
|
642
906
|
const updatesById = new Map();
|
|
643
907
|
const autoBySignature = new Map();
|
|
644
908
|
for (const entry of existingAutoMeta) {
|
|
645
|
-
autoBySignature.
|
|
909
|
+
const bucket = autoBySignature.get(entry.sig) ?? [];
|
|
910
|
+
bucket.push(entry);
|
|
911
|
+
autoBySignature.set(entry.sig, bucket);
|
|
646
912
|
}
|
|
647
913
|
detected.forEach((room, index) => {
|
|
648
|
-
const existing = autoBySignature.get(room.sig);
|
|
914
|
+
const existing = autoBySignature.get(room.sig)?.shift();
|
|
649
915
|
if (!existing)
|
|
650
916
|
return;
|
|
651
917
|
matchedDetectedIdx.add(index);
|
|
@@ -687,25 +953,39 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
|
|
|
687
953
|
height: room.ceilingHeight,
|
|
688
954
|
});
|
|
689
955
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
const data = {};
|
|
700
|
-
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
|
|
701
|
-
data.polygon = update.polygon;
|
|
956
|
+
const detectedRoomPolygons = detectedAll.map((room) => room.poly);
|
|
957
|
+
const ceilingsToDelete = [];
|
|
958
|
+
const ceilingDemotions = [];
|
|
959
|
+
for (const ceiling of existingAuto) {
|
|
960
|
+
if (updatesById.has(ceiling.id))
|
|
961
|
+
continue;
|
|
962
|
+
const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons);
|
|
963
|
+
if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
|
|
964
|
+
ceilingsToDelete.push(ceiling.id);
|
|
702
965
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
data.height = update.height;
|
|
966
|
+
else {
|
|
967
|
+
ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
|
|
706
968
|
}
|
|
707
|
-
|
|
708
|
-
|
|
969
|
+
}
|
|
970
|
+
const ceilingsToUpdate = [
|
|
971
|
+
...existingAuto
|
|
972
|
+
.filter((ceiling) => updatesById.has(ceiling.id))
|
|
973
|
+
.flatMap((ceiling) => {
|
|
974
|
+
const update = updatesById.get(ceiling.id);
|
|
975
|
+
if (!update)
|
|
976
|
+
return [];
|
|
977
|
+
const data = {};
|
|
978
|
+
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
|
|
979
|
+
data.polygon = update.polygon;
|
|
980
|
+
}
|
|
981
|
+
if (Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
|
|
982
|
+
CEILING_HEIGHT_EPSILON) {
|
|
983
|
+
data.height = update.height;
|
|
984
|
+
}
|
|
985
|
+
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }];
|
|
986
|
+
}),
|
|
987
|
+
...ceilingDemotions,
|
|
988
|
+
];
|
|
709
989
|
const plannedCeilingsForNaming = [...existingCeilings];
|
|
710
990
|
const ceilingsToCreate = [];
|
|
711
991
|
for (let index = 0; index < detected.length; index += 1) {
|
|
@@ -743,14 +1023,15 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
|
|
|
743
1023
|
}
|
|
744
1024
|
}
|
|
745
1025
|
function detectSpacesFromWalls(levelId, walls) {
|
|
746
|
-
const
|
|
1026
|
+
const rooms = extractRooms(walls);
|
|
1027
|
+
const roomPolygons = rooms.map((room) => room.polygon);
|
|
747
1028
|
const wallUpdates = walls.map((wall) => ({
|
|
748
1029
|
wallId: wall.id,
|
|
749
1030
|
...resolveWallSurfaceSides(wall, roomPolygons),
|
|
750
1031
|
}));
|
|
751
1032
|
return {
|
|
752
1033
|
roomPolygons,
|
|
753
|
-
spaces:
|
|
1034
|
+
spaces: rooms.map((room) => buildSpace(levelId, room)),
|
|
754
1035
|
wallUpdates,
|
|
755
1036
|
};
|
|
756
1037
|
}
|
|
@@ -770,6 +1051,7 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
|
|
|
770
1051
|
const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
|
|
771
1052
|
const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
|
|
772
1053
|
const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
|
|
1054
|
+
const zones = Object.values(nodes).filter((node) => node?.type === 'zone' && node.parentId === levelId);
|
|
773
1055
|
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
|
|
774
1056
|
const changedWallUpdates = wallUpdates.filter((update) => {
|
|
775
1057
|
const wall = nodes[update.wallId];
|
|
@@ -788,6 +1070,9 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
|
|
|
788
1070
|
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore);
|
|
789
1071
|
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan);
|
|
790
1072
|
syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, { walls, slabs: projectedSlabs });
|
|
1073
|
+
const zonePlan = planAutoZonesForLevel(spaces, zones.map((zone) => ZoneNode.parse(zone)));
|
|
1074
|
+
if (zonePlan.update.length > 0)
|
|
1075
|
+
updateNodes(zonePlan.update);
|
|
791
1076
|
for (const space of spaces) {
|
|
792
1077
|
nextSpaces[space.id] = space;
|
|
793
1078
|
}
|
|
@@ -815,7 +1100,11 @@ export function isSpaceDetectionPaused() {
|
|
|
815
1100
|
return spaceDetectionPauseDepth > 0;
|
|
816
1101
|
}
|
|
817
1102
|
export function initSpaceDetectionSync(sceneStore, editorStore) {
|
|
818
|
-
|
|
1103
|
+
// Baseline from whatever is already in the store. Detection reacts to wall
|
|
1104
|
+
// edits made IN-SESSION (create / move / delete); it must not re-litigate a
|
|
1105
|
+
// scene that merely loaded — rerunning on hydration resurrected auto slabs
|
|
1106
|
+
// the user had deleted in an earlier session.
|
|
1107
|
+
const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
|
|
819
1108
|
let isProcessing = false;
|
|
820
1109
|
const unsubscribe = sceneStore.subscribe((state) => {
|
|
821
1110
|
if (isProcessing)
|
|
@@ -836,7 +1125,14 @@ export function initSpaceDetectionSync(sceneStore, editorStore) {
|
|
|
836
1125
|
}
|
|
837
1126
|
const levelsToUpdate = new Set();
|
|
838
1127
|
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
|
|
839
|
-
|
|
1128
|
+
// First sight of a level is a hydration baseline, not a wall edit —
|
|
1129
|
+
// `setScene` delivers a loaded scene as one atomic update, and a level's
|
|
1130
|
+
// first wall can't close a room anyway. Record it (below) and only
|
|
1131
|
+
// react to subsequent changes.
|
|
1132
|
+
const previous = previousSnapshots.get(levelId);
|
|
1133
|
+
if (previous === undefined)
|
|
1134
|
+
continue;
|
|
1135
|
+
if (previous !== (currentSnapshots.get(levelId) ?? '')) {
|
|
840
1136
|
levelsToUpdate.add(levelId);
|
|
841
1137
|
}
|
|
842
1138
|
}
|