@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,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { CeilingNode, SlabNode, WallNode } from '../schema';
|
|
3
|
-
import { planAutoCeilingsForLevel } from './space-detection';
|
|
2
|
+
import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema';
|
|
3
|
+
import { detectSpacesForLevel, planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, resolveAutoZonePolygon, wallClosesRoom, } from './space-detection';
|
|
4
4
|
const square = [
|
|
5
5
|
[0, 0],
|
|
6
6
|
[4, 0],
|
|
@@ -74,4 +74,303 @@ describe('planAutoCeilingsForLevel', () => {
|
|
|
74
74
|
expect(plan.create).toHaveLength(0);
|
|
75
75
|
expect(plan.update).toHaveLength(0);
|
|
76
76
|
});
|
|
77
|
+
test('demotes an orphaned auto ceiling to manual with its polygon untouched', () => {
|
|
78
|
+
const ceiling = CeilingNode.parse({
|
|
79
|
+
polygon: square,
|
|
80
|
+
height: 2.55,
|
|
81
|
+
autoFromWalls: true,
|
|
82
|
+
});
|
|
83
|
+
const plan = planAutoCeilingsForLevel([], [ceiling]);
|
|
84
|
+
expect(plan.create).toHaveLength(0);
|
|
85
|
+
expect(plan.delete).toHaveLength(0);
|
|
86
|
+
expect(plan.update).toHaveLength(1);
|
|
87
|
+
expect(plan.update[0]?.id).toBe(ceiling.id);
|
|
88
|
+
// Ceilings render the stored polygon in both modes, so no polygon bake.
|
|
89
|
+
expect(plan.update[0]?.data).toEqual({ autoFromWalls: false });
|
|
90
|
+
});
|
|
91
|
+
test('deletes an unmatched auto ceiling absorbed by a room merge', () => {
|
|
92
|
+
const leftCeiling = CeilingNode.parse({
|
|
93
|
+
polygon: [
|
|
94
|
+
[0, 0],
|
|
95
|
+
[4, 0],
|
|
96
|
+
[4, 3],
|
|
97
|
+
[0, 3],
|
|
98
|
+
],
|
|
99
|
+
autoFromWalls: true,
|
|
100
|
+
});
|
|
101
|
+
const rightCeiling = CeilingNode.parse({
|
|
102
|
+
polygon: [
|
|
103
|
+
[4, 0],
|
|
104
|
+
[8, 0],
|
|
105
|
+
[8, 3],
|
|
106
|
+
[4, 3],
|
|
107
|
+
],
|
|
108
|
+
autoFromWalls: true,
|
|
109
|
+
});
|
|
110
|
+
const mergedRoom = [
|
|
111
|
+
{ x: 0, y: 0 },
|
|
112
|
+
{ x: 8, y: 0 },
|
|
113
|
+
{ x: 8, y: 3 },
|
|
114
|
+
{ x: 0, y: 3 },
|
|
115
|
+
];
|
|
116
|
+
const plan = planAutoCeilingsForLevel([mergedRoom], [leftCeiling, rightCeiling]);
|
|
117
|
+
expect(plan.create).toHaveLength(0);
|
|
118
|
+
expect(plan.delete).toHaveLength(1);
|
|
119
|
+
const survivorId = plan.update[0]?.id;
|
|
120
|
+
expect([leftCeiling.id, rightCeiling.id]).toContain(plan.delete[0]);
|
|
121
|
+
expect(plan.delete[0]).not.toBe(survivorId);
|
|
122
|
+
});
|
|
123
|
+
test('a demoted ceiling suppresses re-creating an auto ceiling when the room re-forms', () => {
|
|
124
|
+
const ceiling = CeilingNode.parse({
|
|
125
|
+
polygon: square,
|
|
126
|
+
height: 2.55,
|
|
127
|
+
autoFromWalls: true,
|
|
128
|
+
});
|
|
129
|
+
const demotion = planAutoCeilingsForLevel([], [ceiling]).update[0];
|
|
130
|
+
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data });
|
|
131
|
+
expect(demoted.autoFromWalls).toBe(false);
|
|
132
|
+
const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], {
|
|
133
|
+
walls: squareWalls(),
|
|
134
|
+
slabs: [slab(0.05)],
|
|
135
|
+
});
|
|
136
|
+
expect(plan.create).toHaveLength(0);
|
|
137
|
+
expect(plan.update).toHaveLength(0);
|
|
138
|
+
expect(plan.delete).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('detectSpacesForLevel', () => {
|
|
142
|
+
const areaOf = (polygon) => {
|
|
143
|
+
let area = 0;
|
|
144
|
+
for (let i = 0; i < polygon.length; i += 1) {
|
|
145
|
+
const a = polygon[i];
|
|
146
|
+
const b = polygon[(i + 1) % polygon.length];
|
|
147
|
+
area += a.x * b.y - b.x * a.y;
|
|
148
|
+
}
|
|
149
|
+
return Math.abs(area / 2);
|
|
150
|
+
};
|
|
151
|
+
test('detects an isolated four-wall room', () => {
|
|
152
|
+
const walls = squareWalls();
|
|
153
|
+
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls);
|
|
154
|
+
expect(roomPolygons).toHaveLength(1);
|
|
155
|
+
expect(new Set(spaces[0]?.wallIds)).toEqual(new Set(walls.map((wall) => wall.id)));
|
|
156
|
+
expect(spaces[0]?.boundaryFaces).toHaveLength(4);
|
|
157
|
+
expect(spaces[0]?.boundaryFaces.map((boundary) => `${boundary.wallId}:${boundary.face}`).sort()).toEqual(walls.map((wall) => `${wall.id}:front`).sort());
|
|
158
|
+
});
|
|
159
|
+
test('excludes dangling wall branches from a room boundary', () => {
|
|
160
|
+
const roomWalls = squareWalls();
|
|
161
|
+
const branch = WallNode.parse({ start: [0, 0], end: [1, 1] });
|
|
162
|
+
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', [...roomWalls, branch]);
|
|
163
|
+
expect(roomPolygons).toHaveLength(1);
|
|
164
|
+
expect(roomPolygons[0]).toHaveLength(4);
|
|
165
|
+
expect(areaOf(roomPolygons[0])).toBeCloseTo(12);
|
|
166
|
+
expect(spaces[0]?.wallIds.sort()).toEqual(roomWalls.map((wall) => wall.id).sort());
|
|
167
|
+
expect(spaces[0]?.boundaryFaces).toHaveLength(4);
|
|
168
|
+
});
|
|
169
|
+
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
|
|
170
|
+
// Big 6×5 room; a smaller room hangs below, its two verticals landing on the
|
|
171
|
+
// interior of the big room's bottom wall (x=1 and x=3, not endpoints). Before
|
|
172
|
+
// planarization those touch points were dangling nodes and the small room
|
|
173
|
+
// was never detected.
|
|
174
|
+
const walls = [
|
|
175
|
+
WallNode.parse({ start: [0, 0], end: [6, 0] }),
|
|
176
|
+
WallNode.parse({ start: [6, 0], end: [6, 5] }),
|
|
177
|
+
WallNode.parse({ start: [6, 5], end: [0, 5] }),
|
|
178
|
+
WallNode.parse({ start: [0, 5], end: [0, 0] }),
|
|
179
|
+
WallNode.parse({ start: [1, 0], end: [1, -2] }),
|
|
180
|
+
WallNode.parse({ start: [1, -2], end: [3, -2] }),
|
|
181
|
+
WallNode.parse({ start: [3, -2], end: [3, 0] }),
|
|
182
|
+
];
|
|
183
|
+
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls);
|
|
184
|
+
const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b);
|
|
185
|
+
const smallRoom = spaces.find((space) => areaOf(space.polygon.map(([x, y]) => ({ x, y }))) < 5);
|
|
186
|
+
expect(roomPolygons).toHaveLength(2);
|
|
187
|
+
expect(areas[0]).toBeCloseTo(4, 1); // small room: 2×2
|
|
188
|
+
expect(areas[1]).toBeCloseTo(30, 1); // big room: 6×5
|
|
189
|
+
expect(new Set(smallRoom?.wallIds)).toEqual(new Set([walls[0].id, walls[4].id, walls[5].id, walls[6].id]));
|
|
190
|
+
const longWallId = walls[0].id;
|
|
191
|
+
const longWallBoundaries = spaces.flatMap((space) => space.boundaryFaces.filter((boundary) => boundary.wallId === longWallId));
|
|
192
|
+
expect(longWallBoundaries).toHaveLength(4);
|
|
193
|
+
expect(longWallBoundaries.filter((boundary) => boundary.face === 'back')).toHaveLength(1);
|
|
194
|
+
expect(longWallBoundaries.filter((boundary) => boundary.face === 'front')).toHaveLength(3);
|
|
195
|
+
expect(longWallBoundaries.map((boundary) => boundary.points)).toContainEqual([
|
|
196
|
+
[1, 0],
|
|
197
|
+
[3, 0],
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe('procedural zones', () => {
|
|
202
|
+
test('adopts an exact room footprint and records its enclosing walls', () => {
|
|
203
|
+
const walls = squareWalls();
|
|
204
|
+
const { spaces } = detectSpacesForLevel('level-1', walls);
|
|
205
|
+
const zone = ZoneNode.parse({ name: 'Kitchen', polygon: square });
|
|
206
|
+
const plan = planAutoZonesForLevel(spaces, [zone]);
|
|
207
|
+
expect(plan.update).toHaveLength(1);
|
|
208
|
+
expect(plan.update[0]?.data.autoFromWalls).toBe(true);
|
|
209
|
+
expect(new Set(plan.update[0]?.data.boundaryWallIds)).toEqual(new Set(walls.map((wall) => wall.id)));
|
|
210
|
+
});
|
|
211
|
+
test('derives the live polygon from effective wall endpoints', () => {
|
|
212
|
+
const walls = squareWalls();
|
|
213
|
+
const zone = ZoneNode.parse({
|
|
214
|
+
name: 'Kitchen',
|
|
215
|
+
polygon: square,
|
|
216
|
+
autoFromWalls: true,
|
|
217
|
+
boundaryWallIds: walls.map((wall) => wall.id),
|
|
218
|
+
});
|
|
219
|
+
const movedWalls = [
|
|
220
|
+
{ ...walls[0], end: [5, 0] },
|
|
221
|
+
{ ...walls[1], start: [5, 0], end: [5, 3] },
|
|
222
|
+
{ ...walls[2], start: [5, 3] },
|
|
223
|
+
walls[3],
|
|
224
|
+
];
|
|
225
|
+
const byId = new Map(movedWalls.map((wall) => [wall.id, wall]));
|
|
226
|
+
const polygon = resolveAutoZonePolygon(zone, (id) => byId.get(id));
|
|
227
|
+
const plan = planAutoZonesForLevel(detectSpacesForLevel('level-1', movedWalls).spaces, [zone]);
|
|
228
|
+
expect(polygon).toContainEqual([5, 0]);
|
|
229
|
+
expect(polygon).toContainEqual([5, 3]);
|
|
230
|
+
expect(polygon).not.toContainEqual([4, 0]);
|
|
231
|
+
expect(plan.update[0]?.data.polygon).toContainEqual([5, 0]);
|
|
232
|
+
});
|
|
233
|
+
test('leaves an unrelated site zone manual', () => {
|
|
234
|
+
const { spaces } = detectSpacesForLevel('level-1', squareWalls());
|
|
235
|
+
const zone = ZoneNode.parse({
|
|
236
|
+
name: 'Lawn',
|
|
237
|
+
polygon: [
|
|
238
|
+
[10, 10],
|
|
239
|
+
[12, 10],
|
|
240
|
+
[12, 12],
|
|
241
|
+
[10, 12],
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
expect(planAutoZonesForLevel(spaces, [zone]).update).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe('wallClosesRoom', () => {
|
|
248
|
+
test('is false while a chain is still open, true once it encloses a room', () => {
|
|
249
|
+
const open = [
|
|
250
|
+
WallNode.parse({ start: [0, 0], end: [4, 0] }),
|
|
251
|
+
WallNode.parse({ start: [4, 0], end: [4, 3] }),
|
|
252
|
+
WallNode.parse({ start: [4, 3], end: [0, 3] }),
|
|
253
|
+
];
|
|
254
|
+
const closing = WallNode.parse({ start: [0, 3], end: [0, 0] });
|
|
255
|
+
expect(wallClosesRoom(open, closing)).toBe(false);
|
|
256
|
+
expect(wallClosesRoom([...open, closing], closing)).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
test('fires when a bay is sealed against the middle of an existing wall', () => {
|
|
259
|
+
const bigRoom = [
|
|
260
|
+
WallNode.parse({ start: [0, 0], end: [6, 0] }),
|
|
261
|
+
WallNode.parse({ start: [6, 0], end: [6, 5] }),
|
|
262
|
+
WallNode.parse({ start: [6, 5], end: [0, 5] }),
|
|
263
|
+
WallNode.parse({ start: [0, 5], end: [0, 0] }),
|
|
264
|
+
];
|
|
265
|
+
const bayLeft = WallNode.parse({ start: [1, 0], end: [1, -2] });
|
|
266
|
+
const bayBottom = WallNode.parse({ start: [1, -2], end: [3, -2] });
|
|
267
|
+
const bayRight = WallNode.parse({ start: [3, -2], end: [3, 0] });
|
|
268
|
+
// Two sides down and across: not enclosed yet.
|
|
269
|
+
expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom], bayBottom)).toBe(false);
|
|
270
|
+
// The final side lands on the interior of the big room's bottom wall.
|
|
271
|
+
expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom, bayRight], bayRight)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
describe('planAutoSlabsForLevel', () => {
|
|
275
|
+
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
|
|
276
|
+
// Two rooms with identical polygon signatures previously collided in a
|
|
277
|
+
// signature-keyed Map, so one detected room never matched an existing slab
|
|
278
|
+
// and churned (delete + recreate) on every pass.
|
|
279
|
+
const slabA = slab(0.05);
|
|
280
|
+
const slabB = slab(0.05);
|
|
281
|
+
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB]);
|
|
282
|
+
expect(plan.create).toHaveLength(0);
|
|
283
|
+
expect(plan.delete).toHaveLength(0);
|
|
284
|
+
expect(plan.update).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
test('deletes an extra auto-slab when only one identical room is detected', () => {
|
|
287
|
+
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)]);
|
|
288
|
+
expect(plan.create).toHaveLength(0);
|
|
289
|
+
expect(plan.delete).toHaveLength(1);
|
|
290
|
+
});
|
|
291
|
+
test('demotes an orphaned auto slab to manual when its room disappears', () => {
|
|
292
|
+
const painted = SlabNode.parse({
|
|
293
|
+
polygon: square,
|
|
294
|
+
elevation: 0.4,
|
|
295
|
+
autoFromWalls: true,
|
|
296
|
+
});
|
|
297
|
+
const plan = planAutoSlabsForLevel([], [painted]);
|
|
298
|
+
expect(plan.create).toHaveLength(0);
|
|
299
|
+
expect(plan.delete).toHaveLength(0);
|
|
300
|
+
expect(plan.update).toHaveLength(1);
|
|
301
|
+
const update = plan.update[0];
|
|
302
|
+
expect(update?.id).toBe(painted.id);
|
|
303
|
+
// Demotion flips only the flag — the stored polygon stays untouched
|
|
304
|
+
// (render offsets derive from level context at geometry build time).
|
|
305
|
+
expect(update?.data).toEqual({ autoFromWalls: false });
|
|
306
|
+
});
|
|
307
|
+
test('deletes an unmatched auto slab whose area was absorbed by a room merge', () => {
|
|
308
|
+
const leftSlab = SlabNode.parse({
|
|
309
|
+
polygon: [
|
|
310
|
+
[0, 0],
|
|
311
|
+
[4, 0],
|
|
312
|
+
[4, 3],
|
|
313
|
+
[0, 3],
|
|
314
|
+
],
|
|
315
|
+
autoFromWalls: true,
|
|
316
|
+
});
|
|
317
|
+
const rightSlab = SlabNode.parse({
|
|
318
|
+
polygon: [
|
|
319
|
+
[4, 0],
|
|
320
|
+
[8, 0],
|
|
321
|
+
[8, 3],
|
|
322
|
+
[4, 3],
|
|
323
|
+
],
|
|
324
|
+
autoFromWalls: true,
|
|
325
|
+
});
|
|
326
|
+
const mergedRoom = [
|
|
327
|
+
{ x: 0, y: 0 },
|
|
328
|
+
{ x: 8, y: 0 },
|
|
329
|
+
{ x: 8, y: 3 },
|
|
330
|
+
{ x: 0, y: 3 },
|
|
331
|
+
];
|
|
332
|
+
const plan = planAutoSlabsForLevel([mergedRoom], [leftSlab, rightSlab]);
|
|
333
|
+
expect(plan.create).toHaveLength(0);
|
|
334
|
+
expect(plan.delete).toHaveLength(1);
|
|
335
|
+
expect(plan.update).toHaveLength(1);
|
|
336
|
+
const survivorId = plan.update[0]?.id;
|
|
337
|
+
expect([leftSlab.id, rightSlab.id]).toContain(plan.delete[0]);
|
|
338
|
+
expect(plan.delete[0]).not.toBe(survivorId);
|
|
339
|
+
// The survivor stays auto — updated to the merged polygon, not demoted.
|
|
340
|
+
expect(plan.update[0]?.data.autoFromWalls).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
test('a demoted slab suppresses re-creating an auto slab when the room re-forms', () => {
|
|
343
|
+
const auto = slab(0.05);
|
|
344
|
+
const demotion = planAutoSlabsForLevel([], [auto]).update[0];
|
|
345
|
+
const demoted = SlabNode.parse({ ...auto, ...demotion?.data });
|
|
346
|
+
expect(demoted.autoFromWalls).toBe(false);
|
|
347
|
+
const plan = planAutoSlabsForLevel([roomPolygon()], [demoted]);
|
|
348
|
+
expect(plan.create).toHaveLength(0);
|
|
349
|
+
expect(plan.update).toHaveLength(0);
|
|
350
|
+
expect(plan.delete).toHaveLength(0);
|
|
351
|
+
});
|
|
352
|
+
test('manual slabs that split one room suppress a replacement full-room slab', () => {
|
|
353
|
+
const left = SlabNode.parse({
|
|
354
|
+
polygon: [
|
|
355
|
+
[0, 0],
|
|
356
|
+
[2, 0],
|
|
357
|
+
[2, 3],
|
|
358
|
+
[0, 3],
|
|
359
|
+
],
|
|
360
|
+
autoFromWalls: false,
|
|
361
|
+
});
|
|
362
|
+
const right = SlabNode.parse({
|
|
363
|
+
polygon: [
|
|
364
|
+
[2, 0],
|
|
365
|
+
[4, 0],
|
|
366
|
+
[4, 3],
|
|
367
|
+
[2, 3],
|
|
368
|
+
],
|
|
369
|
+
autoFromWalls: false,
|
|
370
|
+
});
|
|
371
|
+
const plan = planAutoSlabsForLevel([roomPolygon()], [left, right]);
|
|
372
|
+
expect(plan.create).toHaveLength(0);
|
|
373
|
+
expect(plan.update).toHaveLength(0);
|
|
374
|
+
expect(plan.delete).toHaveLength(0);
|
|
375
|
+
});
|
|
77
376
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { WallNode } from '../schema/nodes/wall';
|
|
2
|
+
import type { AnyNode, AnyNodeId } from '../schema/types';
|
|
3
|
+
/**
|
|
4
|
+
* Pure plan-space wall-distance math shared by the 2D opening snap
|
|
5
|
+
* (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D
|
|
6
|
+
* Voronoi debug overlay. One source of truth means the overlay is a
|
|
7
|
+
* faithful picture of what the snap actually decides.
|
|
8
|
+
*
|
|
9
|
+
* A wall segment's Voronoi cell is exactly "the points whose nearest wall
|
|
10
|
+
* is this segment", so nearest-segment classification == the segment
|
|
11
|
+
* Voronoi diagram. Curved walls are excluded (the opening snap rejects
|
|
12
|
+
* them — mitering + arc + opening tears in 3D).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a
|
|
16
|
+
* wall. Tight, because plan walls are thin and often close together — a large
|
|
17
|
+
* radius would let a far wall's region reach across a nearer one. Shared so the
|
|
18
|
+
* snap and the Voronoi debug overlay clip to the exact same range.
|
|
19
|
+
*/
|
|
20
|
+
export declare const WALL_SNAP_DISTANCE_M = 0.4;
|
|
21
|
+
export type WallSegment = {
|
|
22
|
+
wall: WallNode;
|
|
23
|
+
/** [x, z] plan start. */
|
|
24
|
+
start: readonly [number, number];
|
|
25
|
+
/** [x, z] plan end. */
|
|
26
|
+
end: readonly [number, number];
|
|
27
|
+
/** Unit direction (start → end) in plan. */
|
|
28
|
+
dirX: number;
|
|
29
|
+
dirY: number;
|
|
30
|
+
/** Segment length in metres. */
|
|
31
|
+
length: number;
|
|
32
|
+
};
|
|
33
|
+
export type WallSegmentClosest = {
|
|
34
|
+
segment: WallSegment;
|
|
35
|
+
/** Distance from the query point to the closest point on the segment. */
|
|
36
|
+
distance: number;
|
|
37
|
+
/** Distance along the wall from `start`, clamped to [0, length]. */
|
|
38
|
+
along: number;
|
|
39
|
+
/** Signed perpendicular offset from the wall axis (+ on the front side). */
|
|
40
|
+
perp: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Collect the straight (non-curved) wall segments that are direct children
|
|
44
|
+
* of a level — the candidates an opening can snap onto.
|
|
45
|
+
*/
|
|
46
|
+
export declare function collectLevelWallSegments(nodes: Record<AnyNodeId, AnyNode>, levelId: AnyNodeId | null): WallSegment[];
|
|
47
|
+
/** Closest point + signed offset of one query point against one segment. */
|
|
48
|
+
export declare function closestOnSegment(segment: WallSegment, pointX: number, pointY: number): {
|
|
49
|
+
distance: number;
|
|
50
|
+
along: number;
|
|
51
|
+
perp: number;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The single nearest wall segment to a plan point — its Voronoi cell. Returns
|
|
55
|
+
* null when `segments` is empty or (when `maxDistance` is given) nothing is
|
|
56
|
+
* within range. Ties resolve to the first segment scanned; callers pass an
|
|
57
|
+
* already-curved-filtered list from `collectLevelWallSegments`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function nearestWallSegment(segments: readonly WallSegment[], pointX: number, pointY: number, maxDistance?: number, excludeWallId?: AnyNodeId): WallSegmentClosest | null;
|
|
60
|
+
//# sourceMappingURL=wall-distance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wall-distance.d.ts","sourceRoot":"","sources":["../../src/lib/wall-distance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAGzD;;;;;;;;;;GAUG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,MAAM,CAAA;AAEvC,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,QAAQ,CAAA;IACd,yBAAyB;IACzB,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,uBAAuB;IACvB,GAAG,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,EAAE,WAAW,CAAA;IACpB,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EACjC,OAAO,EAAE,SAAS,GAAG,IAAI,GACxB,WAAW,EAAE,CA0Bf;AAED,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EACpB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CASnD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,WAAW,EAAE,EAChC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,WAAW,SAA2B,EACtC,aAAa,CAAC,EAAE,SAAS,GACxB,kBAAkB,GAAG,IAAI,CAU3B"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isCurvedWall } from '../systems/wall/wall-curve';
|
|
2
|
+
/**
|
|
3
|
+
* Pure plan-space wall-distance math shared by the 2D opening snap
|
|
4
|
+
* (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D
|
|
5
|
+
* Voronoi debug overlay. One source of truth means the overlay is a
|
|
6
|
+
* faithful picture of what the snap actually decides.
|
|
7
|
+
*
|
|
8
|
+
* A wall segment's Voronoi cell is exactly "the points whose nearest wall
|
|
9
|
+
* is this segment", so nearest-segment classification == the segment
|
|
10
|
+
* Voronoi diagram. Curved walls are excluded (the opening snap rejects
|
|
11
|
+
* them — mitering + arc + opening tears in 3D).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a
|
|
15
|
+
* wall. Tight, because plan walls are thin and often close together — a large
|
|
16
|
+
* radius would let a far wall's region reach across a nearer one. Shared so the
|
|
17
|
+
* snap and the Voronoi debug overlay clip to the exact same range.
|
|
18
|
+
*/
|
|
19
|
+
export const WALL_SNAP_DISTANCE_M = 0.4;
|
|
20
|
+
/**
|
|
21
|
+
* Collect the straight (non-curved) wall segments that are direct children
|
|
22
|
+
* of a level — the candidates an opening can snap onto.
|
|
23
|
+
*/
|
|
24
|
+
export function collectLevelWallSegments(nodes, levelId) {
|
|
25
|
+
if (!levelId)
|
|
26
|
+
return [];
|
|
27
|
+
const level = nodes[levelId];
|
|
28
|
+
const childIds = level?.children;
|
|
29
|
+
if (!Array.isArray(childIds))
|
|
30
|
+
return [];
|
|
31
|
+
const segments = [];
|
|
32
|
+
for (const childId of childIds) {
|
|
33
|
+
const node = nodes[childId];
|
|
34
|
+
if (node?.type !== 'wall')
|
|
35
|
+
continue;
|
|
36
|
+
const wall = node;
|
|
37
|
+
if (isCurvedWall(wall))
|
|
38
|
+
continue;
|
|
39
|
+
const dx = wall.end[0] - wall.start[0];
|
|
40
|
+
const dy = wall.end[1] - wall.start[1];
|
|
41
|
+
const length = Math.hypot(dx, dy);
|
|
42
|
+
if (length < 1e-6)
|
|
43
|
+
continue;
|
|
44
|
+
segments.push({
|
|
45
|
+
wall,
|
|
46
|
+
start: wall.start,
|
|
47
|
+
end: wall.end,
|
|
48
|
+
dirX: dx / length,
|
|
49
|
+
dirY: dy / length,
|
|
50
|
+
length,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return segments;
|
|
54
|
+
}
|
|
55
|
+
/** Closest point + signed offset of one query point against one segment. */
|
|
56
|
+
export function closestOnSegment(segment, pointX, pointY) {
|
|
57
|
+
const px = pointX - segment.start[0];
|
|
58
|
+
const py = pointY - segment.start[1];
|
|
59
|
+
const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY));
|
|
60
|
+
const perp = px * -segment.dirY + py * segment.dirX;
|
|
61
|
+
const closestX = segment.start[0] + segment.dirX * along;
|
|
62
|
+
const closestY = segment.start[1] + segment.dirY * along;
|
|
63
|
+
const distance = Math.hypot(pointX - closestX, pointY - closestY);
|
|
64
|
+
return { distance, along, perp };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The single nearest wall segment to a plan point — its Voronoi cell. Returns
|
|
68
|
+
* null when `segments` is empty or (when `maxDistance` is given) nothing is
|
|
69
|
+
* within range. Ties resolve to the first segment scanned; callers pass an
|
|
70
|
+
* already-curved-filtered list from `collectLevelWallSegments`.
|
|
71
|
+
*/
|
|
72
|
+
export function nearestWallSegment(segments, pointX, pointY, maxDistance = Number.POSITIVE_INFINITY, excludeWallId) {
|
|
73
|
+
let best = null;
|
|
74
|
+
for (const segment of segments) {
|
|
75
|
+
if (excludeWallId && segment.wall.id === excludeWallId)
|
|
76
|
+
continue;
|
|
77
|
+
const { distance, along, perp } = closestOnSegment(segment, pointX, pointY);
|
|
78
|
+
if (distance > maxDistance)
|
|
79
|
+
continue;
|
|
80
|
+
if (best && distance >= best.distance)
|
|
81
|
+
continue;
|
|
82
|
+
best = { segment, distance, along, perp };
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AnyNode, ZoneNode } from '../schema';
|
|
2
|
+
export type ZoneQuantityValue = {
|
|
3
|
+
status: 'available';
|
|
4
|
+
value: number;
|
|
5
|
+
note?: string;
|
|
6
|
+
} | {
|
|
7
|
+
status: 'unavailable';
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export type ZoneQuantityReport = {
|
|
11
|
+
classification: 'footprint' | 'enclosed-room';
|
|
12
|
+
footprintArea: number;
|
|
13
|
+
perimeter: number;
|
|
14
|
+
edgeLengths: number[];
|
|
15
|
+
boundaryWallIds: string[];
|
|
16
|
+
wallSurface: ZoneQuantityValue;
|
|
17
|
+
floorSurface: ZoneQuantityValue;
|
|
18
|
+
volume: ZoneQuantityValue;
|
|
19
|
+
};
|
|
20
|
+
export declare function deriveZoneQuantityReport(zone: ZoneNode, sceneNodes: Record<string, AnyNode>): ZoneQuantityReport;
|
|
21
|
+
//# sourceMappingURL=zone-quantities.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zone-quantities.d.ts","sourceRoot":"","sources":["../../src/lib/zone-quantities.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAmC,QAAQ,EAAE,MAAM,WAAW,CAAA;AAOnF,MAAM,MAAM,iBAAiB,GACzB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAE7C,MAAM,MAAM,kBAAkB,GAAG;IAC/B,cAAc,EAAE,WAAW,GAAG,eAAe,CAAA;IAC7C,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,WAAW,EAAE,iBAAiB,CAAA;IAC9B,YAAY,EAAE,iBAAiB,CAAA;IAC/B,MAAM,EAAE,iBAAiB,CAAA;CAC1B,CAAA;AA2HD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,QAAQ,EACd,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAClC,kBAAkB,CA2GpB"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { sampleWallCenterline } from '../systems/wall/wall-curve';
|
|
2
|
+
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint';
|
|
3
|
+
import { detectSpacesForLevel } from './space-detection';
|
|
4
|
+
const BOUNDARY_TOLERANCE = 0.08;
|
|
5
|
+
function pointDistance(a, b) {
|
|
6
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
7
|
+
}
|
|
8
|
+
function pointToSegmentDistance(point, start, end) {
|
|
9
|
+
const dx = end[0] - start[0];
|
|
10
|
+
const dy = end[1] - start[1];
|
|
11
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
12
|
+
if (lengthSquared <= 1e-12)
|
|
13
|
+
return pointDistance(point, start);
|
|
14
|
+
const t = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared));
|
|
15
|
+
return pointDistance(point, [start[0] + t * dx, start[1] + t * dy]);
|
|
16
|
+
}
|
|
17
|
+
function pointToPolygonBoundaryDistance(point, polygon) {
|
|
18
|
+
let best = Number.POSITIVE_INFINITY;
|
|
19
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
20
|
+
const start = polygon[index];
|
|
21
|
+
const end = polygon[(index + 1) % polygon.length];
|
|
22
|
+
if (!(start && end))
|
|
23
|
+
continue;
|
|
24
|
+
best = Math.min(best, pointToSegmentDistance(point, start, end));
|
|
25
|
+
}
|
|
26
|
+
return best;
|
|
27
|
+
}
|
|
28
|
+
function signedPolygonArea(polygon) {
|
|
29
|
+
let area = 0;
|
|
30
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
31
|
+
const start = polygon[index];
|
|
32
|
+
const end = polygon[(index + 1) % polygon.length];
|
|
33
|
+
if (!(start && end))
|
|
34
|
+
continue;
|
|
35
|
+
area += start[0] * end[1] - end[0] * start[1];
|
|
36
|
+
}
|
|
37
|
+
return area / 2;
|
|
38
|
+
}
|
|
39
|
+
function polygonArea(polygon) {
|
|
40
|
+
return Math.abs(signedPolygonArea(polygon));
|
|
41
|
+
}
|
|
42
|
+
function polygonPerimeter(polygon) {
|
|
43
|
+
let perimeter = 0;
|
|
44
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
45
|
+
const start = polygon[index];
|
|
46
|
+
const end = polygon[(index + 1) % polygon.length];
|
|
47
|
+
if (!(start && end))
|
|
48
|
+
continue;
|
|
49
|
+
perimeter += pointDistance(start, end);
|
|
50
|
+
}
|
|
51
|
+
return perimeter;
|
|
52
|
+
}
|
|
53
|
+
function polygonsDescribeSameRegion(a, b) {
|
|
54
|
+
if (a.length < 3 || b.length < 3)
|
|
55
|
+
return false;
|
|
56
|
+
const aArea = polygonArea(a);
|
|
57
|
+
const bArea = polygonArea(b);
|
|
58
|
+
const areaTolerance = Math.max(0.02, Math.max(aArea, bArea) * 0.01);
|
|
59
|
+
if (Math.abs(aArea - bArea) > areaTolerance)
|
|
60
|
+
return false;
|
|
61
|
+
return (a.every((point) => pointToPolygonBoundaryDistance(point, b) <= BOUNDARY_TOLERANCE) &&
|
|
62
|
+
b.every((point) => pointToPolygonBoundaryDistance(point, a) <= BOUNDARY_TOLERANCE));
|
|
63
|
+
}
|
|
64
|
+
function polygonSurfaceArea(polygon, holes = []) {
|
|
65
|
+
return Math.max(0, polygonArea(polygon) - holes.reduce((sum, hole) => sum + polygonArea(hole), 0));
|
|
66
|
+
}
|
|
67
|
+
function pointToPolylineDistance(point, polyline) {
|
|
68
|
+
let best = Number.POSITIVE_INFINITY;
|
|
69
|
+
for (let index = 0; index < polyline.length - 1; index += 1) {
|
|
70
|
+
const start = polyline[index];
|
|
71
|
+
const end = polyline[index + 1];
|
|
72
|
+
if (!(start && end))
|
|
73
|
+
continue;
|
|
74
|
+
best = Math.min(best, pointToSegmentDistance(point, start, end));
|
|
75
|
+
}
|
|
76
|
+
return best;
|
|
77
|
+
}
|
|
78
|
+
function wallForBoundarySegment(start, end, walls) {
|
|
79
|
+
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
|
|
80
|
+
let best = null;
|
|
81
|
+
for (const wall of walls) {
|
|
82
|
+
const centerline = sampleWallCenterline(wall, 32).map((point) => [point.x, point.y]);
|
|
83
|
+
const distances = [start, midpoint, end].map((point) => pointToPolylineDistance(point, centerline));
|
|
84
|
+
if (distances.some((distance) => distance > BOUNDARY_TOLERANCE))
|
|
85
|
+
continue;
|
|
86
|
+
const distance = distances.reduce((sum, value) => sum + value, 0);
|
|
87
|
+
if (!best || distance < best.distance)
|
|
88
|
+
best = { wall, distance };
|
|
89
|
+
}
|
|
90
|
+
return best?.wall ?? null;
|
|
91
|
+
}
|
|
92
|
+
function matchingSurfaceNodes(zone, nodes) {
|
|
93
|
+
return nodes.filter((node) => polygonsDescribeSameRegion(zone.polygon, node.polygon));
|
|
94
|
+
}
|
|
95
|
+
function unavailable(reason) {
|
|
96
|
+
return { status: 'unavailable', reason };
|
|
97
|
+
}
|
|
98
|
+
export function deriveZoneQuantityReport(zone, sceneNodes) {
|
|
99
|
+
const levelId = zone.parentId;
|
|
100
|
+
const levelNodes = levelId
|
|
101
|
+
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
|
|
102
|
+
: [];
|
|
103
|
+
const walls = levelNodes.filter((node) => node.type === 'wall');
|
|
104
|
+
const slabs = matchingSurfaceNodes(zone, levelNodes.filter((node) => node.type === 'slab'));
|
|
105
|
+
const ceilings = matchingSurfaceNodes(zone, levelNodes.filter((node) => node.type === 'ceiling'));
|
|
106
|
+
const edgeLengths = zone.polygon.map((start, index) => {
|
|
107
|
+
const end = zone.polygon[(index + 1) % zone.polygon.length];
|
|
108
|
+
return end ? pointDistance(start, end) : 0;
|
|
109
|
+
});
|
|
110
|
+
const footprintArea = polygonArea(zone.polygon);
|
|
111
|
+
const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0);
|
|
112
|
+
const matchingRoom = levelId
|
|
113
|
+
? detectSpacesForLevel(levelId, walls).roomPolygons.find((polygon) => polygonsDescribeSameRegion(zone.polygon, polygon.map((point) => [point.x, point.y])))
|
|
114
|
+
: undefined;
|
|
115
|
+
const wallMatches = matchingRoom?.map((start, index) => {
|
|
116
|
+
const end = matchingRoom[(index + 1) % matchingRoom.length];
|
|
117
|
+
if (!end)
|
|
118
|
+
return null;
|
|
119
|
+
const tupleStart = [start.x, start.y];
|
|
120
|
+
const tupleEnd = [end.x, end.y];
|
|
121
|
+
const wall = wallForBoundarySegment(tupleStart, tupleEnd, walls);
|
|
122
|
+
if (!wall)
|
|
123
|
+
return null;
|
|
124
|
+
return { wall, length: pointDistance(tupleStart, tupleEnd) };
|
|
125
|
+
});
|
|
126
|
+
const allWallsProven = !!wallMatches && wallMatches.length > 0 && wallMatches.every(Boolean);
|
|
127
|
+
const boundaryWallIds = allWallsProven
|
|
128
|
+
? [...new Set(wallMatches.map((match) => match.wall.id))]
|
|
129
|
+
: [];
|
|
130
|
+
const wallSurface = allWallsProven
|
|
131
|
+
? {
|
|
132
|
+
status: 'available',
|
|
133
|
+
value: wallMatches.reduce((sum, match) => sum + match.length * (match.wall.height ?? DEFAULT_WALL_HEIGHT), 0),
|
|
134
|
+
note: 'Gross interior wall face before openings.',
|
|
135
|
+
}
|
|
136
|
+
: unavailable(matchingRoom
|
|
137
|
+
? 'The closed boundary could not be assigned to every wall segment.'
|
|
138
|
+
: 'No matching closed wall loop was detected.');
|
|
139
|
+
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined;
|
|
140
|
+
const floorSurface = matchingSlab
|
|
141
|
+
? {
|
|
142
|
+
status: 'available',
|
|
143
|
+
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes),
|
|
144
|
+
note: 'Matching slab surface after openings.',
|
|
145
|
+
}
|
|
146
|
+
: unavailable(slabs.length > 1
|
|
147
|
+
? 'More than one slab matches this boundary.'
|
|
148
|
+
: 'No slab matches this zone boundary.');
|
|
149
|
+
const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined;
|
|
150
|
+
let volume;
|
|
151
|
+
if (!matchingRoom) {
|
|
152
|
+
volume = unavailable('No matching closed wall loop was detected.');
|
|
153
|
+
}
|
|
154
|
+
else if (!matchingSlab) {
|
|
155
|
+
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.');
|
|
156
|
+
}
|
|
157
|
+
else if (!matchingCeiling) {
|
|
158
|
+
volume = unavailable(ceilings.length > 1
|
|
159
|
+
? 'More than one ceiling matches this boundary.'
|
|
160
|
+
: 'No ceiling matches this zone boundary.');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const clearHeight = matchingCeiling.height - matchingSlab.elevation;
|
|
164
|
+
volume =
|
|
165
|
+
Number.isFinite(clearHeight) && clearHeight > 0
|
|
166
|
+
? {
|
|
167
|
+
status: 'available',
|
|
168
|
+
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes) * clearHeight,
|
|
169
|
+
note: 'Matching slab area multiplied by clear ceiling height.',
|
|
170
|
+
}
|
|
171
|
+
: unavailable('The matching ceiling is not above the slab surface.');
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
classification: matchingRoom ? 'enclosed-room' : 'footprint',
|
|
175
|
+
footprintArea,
|
|
176
|
+
perimeter,
|
|
177
|
+
edgeLengths,
|
|
178
|
+
boundaryWallIds,
|
|
179
|
+
wallSurface,
|
|
180
|
+
floorSurface,
|
|
181
|
+
volume,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zone-quantities.test.d.ts","sourceRoot":"","sources":["../../src/lib/zone-quantities.test.ts"],"names":[],"mappings":""}
|