@pascal-app/core 0.7.0 → 0.9.1
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 +47 -18
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +1 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts +6 -22
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +33 -26
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +14 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.js +51 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +2 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +273 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +13 -2
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +95 -9
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +28 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +91 -18
- package/dist/index.d.ts +22 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/lib/polygon-geometry.d.ts.map +1 -1
- package/dist/lib/polygon-relations.d.ts +10 -0
- package/dist/lib/polygon-relations.d.ts.map +1 -0
- package/dist/lib/polygon-relations.js +73 -0
- package/dist/lib/space-detection-pause.test.d.ts +2 -0
- package/dist/lib/space-detection-pause.test.d.ts.map +1 -0
- package/dist/lib/space-detection-pause.test.js +42 -0
- package/dist/lib/space-detection.d.ts +30 -1
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +222 -41
- package/dist/lib/space-detection.test.d.ts +2 -0
- package/dist/lib/space-detection.test.d.ts.map +1 -0
- package/dist/lib/space-detection.test.js +77 -0
- package/dist/material-library.d.ts +1 -1
- package/dist/material-library.d.ts.map +1 -1
- package/dist/material-library.js +1957 -141
- package/dist/registry/__bench__/relations-resolver.bench.d.ts +15 -0
- package/dist/registry/__bench__/relations-resolver.bench.d.ts.map +1 -0
- package/dist/registry/__bench__/relations-resolver.bench.js +170 -0
- package/dist/registry/handles.d.ts +321 -0
- package/dist/registry/handles.d.ts.map +1 -0
- package/dist/registry/handles.js +24 -0
- package/dist/registry/index.d.ts +7 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +4 -0
- package/dist/registry/registry.d.ts +93 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/registry.js +201 -0
- package/dist/registry/registry.test.d.ts +2 -0
- package/dist/registry/registry.test.d.ts.map +1 -0
- package/dist/registry/registry.test.js +183 -0
- package/dist/registry/relations-resolver.d.ts +42 -0
- package/dist/registry/relations-resolver.d.ts.map +1 -0
- package/dist/registry/relations-resolver.js +77 -0
- package/dist/registry/relations-resolver.test.d.ts +2 -0
- package/dist/registry/relations-resolver.test.d.ts.map +1 -0
- package/dist/registry/relations-resolver.test.js +183 -0
- package/dist/registry/scene-api.d.ts +44 -0
- package/dist/registry/scene-api.d.ts.map +1 -0
- package/dist/registry/scene-api.js +111 -0
- package/dist/registry/scene-api.test.d.ts +2 -0
- package/dist/registry/scene-api.test.d.ts.map +1 -0
- package/dist/registry/scene-api.test.js +183 -0
- package/dist/registry/subtree.d.ts +67 -0
- package/dist/registry/subtree.d.ts.map +1 -0
- package/dist/registry/subtree.js +119 -0
- package/dist/registry/subtree.test.d.ts +2 -0
- package/dist/registry/subtree.test.d.ts.map +1 -0
- package/dist/registry/subtree.test.js +133 -0
- package/dist/registry/types.d.ts +1420 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +1 -0
- package/dist/schema/asset-url.test.js +0 -4
- package/dist/schema/index.d.ts +17 -2
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +17 -3
- package/dist/schema/material.d.ts +12 -2
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +10 -0
- package/dist/schema/nodes/box-vent.d.ts +74 -0
- package/dist/schema/nodes/box-vent.d.ts.map +1 -0
- package/dist/schema/nodes/box-vent.js +63 -0
- package/dist/schema/nodes/building.d.ts +1 -1
- package/dist/schema/nodes/building.d.ts.map +1 -1
- package/dist/schema/nodes/building.js +3 -2
- package/dist/schema/nodes/ceiling.d.ts +3 -1
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +1 -1
- package/dist/schema/nodes/chimney.d.ts +158 -0
- package/dist/schema/nodes/chimney.d.ts.map +1 -0
- package/dist/schema/nodes/chimney.js +67 -0
- package/dist/schema/nodes/column.d.ts +598 -6
- package/dist/schema/nodes/column.d.ts.map +1 -1
- package/dist/schema/nodes/column.js +586 -6
- package/dist/schema/nodes/cupola.d.ts +66 -0
- package/dist/schema/nodes/cupola.d.ts.map +1 -0
- package/dist/schema/nodes/cupola.js +28 -0
- package/dist/schema/nodes/door.d.ts +1 -1
- package/dist/schema/nodes/dormer.d.ts +228 -0
- package/dist/schema/nodes/dormer.d.ts.map +1 -0
- package/dist/schema/nodes/dormer.js +119 -0
- package/dist/schema/nodes/downspout.d.ts +75 -0
- package/dist/schema/nodes/downspout.d.ts.map +1 -0
- package/dist/schema/nodes/downspout.js +63 -0
- package/dist/schema/nodes/elevator.d.ts +105 -0
- package/dist/schema/nodes/elevator.d.ts.map +1 -0
- package/dist/schema/nodes/elevator.js +54 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts +68 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -0
- package/dist/schema/nodes/eyebrow-vent.js +40 -0
- package/dist/schema/nodes/fence.d.ts +3 -2
- package/dist/schema/nodes/fence.d.ts.map +1 -1
- package/dist/schema/nodes/fence.js +4 -2
- package/dist/schema/nodes/gutter.d.ts +84 -0
- package/dist/schema/nodes/gutter.d.ts.map +1 -0
- package/dist/schema/nodes/gutter.js +76 -0
- package/dist/schema/nodes/item.d.ts +16 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +21 -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/ridge-vent.d.ts +67 -0
- package/dist/schema/nodes/ridge-vent.d.ts.map +1 -0
- package/dist/schema/nodes/ridge-vent.js +30 -0
- package/dist/schema/nodes/roof-segment.d.ts +198 -4
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +254 -3
- package/dist/schema/nodes/roof.d.ts +6 -6
- package/dist/schema/nodes/roof.d.ts.map +1 -1
- package/dist/schema/nodes/roof.js +9 -21
- package/dist/schema/nodes/shelf.d.ts +105 -0
- package/dist/schema/nodes/shelf.d.ts.map +1 -0
- package/dist/schema/nodes/shelf.js +84 -0
- package/dist/schema/nodes/site.d.ts +1 -116
- package/dist/schema/nodes/site.d.ts.map +1 -1
- package/dist/schema/nodes/site.js +2 -6
- package/dist/schema/nodes/skylight.d.ts +279 -0
- package/dist/schema/nodes/skylight.d.ts.map +1 -0
- package/dist/schema/nodes/skylight.js +152 -0
- package/dist/schema/nodes/slab.d.ts +3 -1
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +1 -1
- package/dist/schema/nodes/solar-panel.d.ts +117 -0
- package/dist/schema/nodes/solar-panel.d.ts.map +1 -0
- package/dist/schema/nodes/solar-panel.js +47 -0
- package/dist/schema/nodes/stair-segment.d.ts +1 -1
- package/dist/schema/nodes/stair.d.ts +6 -6
- package/dist/schema/nodes/stair.d.ts.map +1 -1
- package/dist/schema/nodes/stair.js +9 -7
- package/dist/schema/nodes/surface-hole-metadata.d.ts +2 -0
- package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -1
- package/dist/schema/nodes/surface-hole-metadata.js +3 -1
- package/dist/schema/nodes/turbine-vent.d.ts +68 -0
- package/dist/schema/nodes/turbine-vent.d.ts.map +1 -0
- package/dist/schema/nodes/turbine-vent.js +41 -0
- package/dist/schema/nodes/wall.d.ts +3 -3
- package/dist/schema/nodes/window.d.ts +1 -1
- package/dist/schema/types.d.ts +1448 -236
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +26 -0
- package/dist/services/alignment-anchors.d.ts +100 -0
- package/dist/services/alignment-anchors.d.ts.map +1 -0
- package/dist/services/alignment-anchors.js +283 -0
- package/dist/services/alignment-anchors.test.d.ts +2 -0
- package/dist/services/alignment-anchors.test.d.ts.map +1 -0
- package/dist/services/alignment-anchors.test.js +383 -0
- package/dist/services/alignment.d.ts +126 -0
- package/dist/services/alignment.d.ts.map +1 -0
- package/dist/services/alignment.js +169 -0
- package/dist/services/alignment.test.d.ts +2 -0
- package/dist/services/alignment.test.d.ts.map +1 -0
- package/dist/services/alignment.test.js +92 -0
- package/dist/services/drag-session.d.ts +47 -0
- package/dist/services/drag-session.d.ts.map +1 -0
- package/dist/services/drag-session.js +100 -0
- package/dist/services/drag-session.test.d.ts +2 -0
- package/dist/services/drag-session.test.d.ts.map +1 -0
- package/dist/services/drag-session.test.js +219 -0
- package/dist/services/hosting.d.ts +80 -0
- package/dist/services/hosting.d.ts.map +1 -0
- package/dist/services/hosting.js +122 -0
- package/dist/services/hosting.test.d.ts +2 -0
- package/dist/services/hosting.test.d.ts.map +1 -0
- package/dist/services/hosting.test.js +199 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +6 -0
- package/dist/services/movement.d.ts +48 -0
- package/dist/services/movement.d.ts.map +1 -0
- package/dist/services/movement.js +65 -0
- package/dist/services/movement.test.d.ts +2 -0
- package/dist/services/movement.test.d.ts.map +1 -0
- package/dist/services/movement.test.js +133 -0
- package/dist/services/single-undo-dance.test.d.ts +2 -0
- package/dist/services/single-undo-dance.test.d.ts.map +1 -0
- package/dist/services/single-undo-dance.test.js +210 -0
- package/dist/services/snap.d.ts +72 -0
- package/dist/services/snap.d.ts.map +1 -0
- package/dist/services/snap.js +120 -0
- package/dist/services/snap.test.d.ts +2 -0
- package/dist/services/snap.test.d.ts.map +1 -0
- package/dist/services/snap.test.js +95 -0
- package/dist/solar-panel-presets.d.ts +17 -0
- package/dist/solar-panel-presets.d.ts.map +1 -0
- package/dist/solar-panel-presets.js +43 -0
- package/dist/store/actions/node-actions.d.ts +14 -2
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +499 -20
- package/dist/store/actions/node-mutation-sanitize.test.d.ts +2 -0
- package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +1 -0
- package/dist/store/actions/node-mutation-sanitize.test.js +147 -0
- package/dist/store/actions/reparent.test.d.ts +2 -0
- package/dist/store/actions/reparent.test.d.ts.map +1 -0
- package/dist/store/actions/reparent.test.js +212 -0
- package/dist/store/use-interactive.d.ts +39 -0
- package/dist/store/use-interactive.d.ts.map +1 -1
- package/dist/store/use-interactive.js +75 -0
- package/dist/store/use-live-node-overrides.d.ts +22 -0
- package/dist/store/use-live-node-overrides.d.ts.map +1 -0
- package/dist/store/use-live-node-overrides.js +59 -0
- package/dist/store/use-scene-elevator-migration.test.d.ts +2 -0
- package/dist/store/use-scene-elevator-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-elevator-migration.test.js +109 -0
- package/dist/store/use-scene.d.ts +11 -0
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +207 -11
- package/dist/systems/elevator/elevator-dispatch.d.ts +12 -0
- package/dist/systems/elevator/elevator-dispatch.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-dispatch.js +57 -0
- package/dist/systems/elevator/elevator-geometry.d.ts +15 -0
- package/dist/systems/elevator/elevator-geometry.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-geometry.js +50 -0
- package/dist/systems/elevator/elevator-opening-sync.d.ts +6 -0
- package/dist/systems/elevator/elevator-opening-sync.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-sync.js +218 -0
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts +2 -0
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-sync.test.js +146 -0
- package/dist/systems/elevator/elevator-opening-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-system.js +48 -0
- package/dist/systems/elevator/elevator-runtime-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-runtime-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime-system.js +8 -0
- package/dist/systems/elevator/elevator-runtime.d.ts +18 -0
- package/dist/systems/elevator/elevator-runtime.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime.js +225 -0
- package/dist/systems/elevator/elevator-runtime.test.d.ts +2 -0
- package/dist/systems/elevator/elevator-runtime.test.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime.test.js +64 -0
- package/dist/systems/elevator/elevator-service.d.ts +19 -0
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-service.js +111 -0
- package/dist/systems/stair/stair-footprint.d.ts +44 -0
- package/dist/systems/stair/stair-footprint.d.ts.map +1 -0
- package/dist/systems/stair/stair-footprint.js +168 -0
- package/dist/systems/stair/stair-opening-preview.d.ts +1777 -0
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-preview.js +93 -0
- package/dist/systems/stair/stair-opening-preview.test.d.ts +2 -0
- package/dist/systems/stair/stair-opening-preview.test.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-preview.test.js +80 -0
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.js +183 -185
- package/dist/systems/stair/stair-opening-sync.test.js +385 -4
- package/dist/systems/stair/stair-opening-system.d.ts +2 -0
- package/dist/systems/stair/stair-opening-system.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-system.js +104 -0
- package/dist/systems/wall/wall-curve.d.ts +1 -1
- package/dist/systems/wall/wall-curve.d.ts.map +1 -1
- package/dist/systems/wall/wall-curve.js +1 -1
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +2 -6
- package/dist/systems/wall/wall-move.d.ts +48 -0
- package/dist/systems/wall/wall-move.d.ts.map +1 -0
- package/dist/systems/wall/wall-move.js +193 -0
- package/dist/utils/clone-scene-graph.d.ts +6 -4
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +6 -4
- package/dist/utils/clone-scene-graph.test.d.ts +2 -0
- package/dist/utils/clone-scene-graph.test.d.ts.map +1 -0
- package/dist/utils/clone-scene-graph.test.js +59 -0
- package/dist/validation/validate-build-json.d.ts +43 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -0
- package/dist/validation/validate-build-json.js +227 -0
- package/package.json +11 -3
- package/dist/materials.d.ts +0 -10
- package/dist/materials.d.ts.map +0 -1
- package/dist/materials.js +0 -22
- package/dist/systems/ceiling/ceiling-system.d.ts +0 -8
- package/dist/systems/ceiling/ceiling-system.d.ts.map +0 -1
- package/dist/systems/ceiling/ceiling-system.js +0 -92
- package/dist/systems/door/door-system.d.ts +0 -2
- package/dist/systems/door/door-system.d.ts.map +0 -1
- package/dist/systems/door/door-system.js +0 -195
- package/dist/systems/fence/fence-system.d.ts +0 -2
- package/dist/systems/fence/fence-system.d.ts.map +0 -1
- package/dist/systems/fence/fence-system.js +0 -187
- package/dist/systems/item/item-system.d.ts +0 -2
- package/dist/systems/item/item-system.d.ts.map +0 -1
- package/dist/systems/item/item-system.js +0 -48
- package/dist/systems/roof/roof-system.d.ts +0 -16
- package/dist/systems/roof/roof-system.d.ts.map +0 -1
- package/dist/systems/roof/roof-system.js +0 -797
- package/dist/systems/slab/slab-system.d.ts +0 -8
- package/dist/systems/slab/slab-system.d.ts.map +0 -1
- package/dist/systems/slab/slab-system.js +0 -214
- package/dist/systems/stair/stair-system.d.ts +0 -2
- package/dist/systems/stair/stair-system.d.ts.map +0 -1
- package/dist/systems/stair/stair-system.js +0 -776
- package/dist/systems/wall/wall-system.d.ts +0 -12
- package/dist/systems/wall/wall-system.d.ts.map +0 -1
- package/dist/systems/wall/wall-system.js +0 -455
- package/dist/systems/window/window-system.d.ts +0 -2
- package/dist/systems/window/window-system.d.ts.map +0 -1
- package/dist/systems/window/window-system.js +0 -131
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { nodeRegistry, registerNode } from '../registry';
|
|
4
|
+
import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, } from '../systems/elevator/elevator-geometry';
|
|
5
|
+
import { stairFootprintAABB } from '../systems/stair/stair-footprint';
|
|
6
|
+
import { collectAlignmentAnchors, footprintAABB, footprintAABBFrom, movingAlignmentAnchors, movingFootprintAnchors, polygonAnchors, wallSegmentAnchors, } from './alignment-anchors';
|
|
7
|
+
// Minimal floor-placed def whose footprint reads `dimensions` / `rotation`
|
|
8
|
+
// straight off the node, so tests can drive the AABB math directly.
|
|
9
|
+
function floorPlacedDef(kind, applies) {
|
|
10
|
+
return {
|
|
11
|
+
kind,
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
14
|
+
category: 'utility',
|
|
15
|
+
defaults: () => ({}),
|
|
16
|
+
capabilities: {
|
|
17
|
+
floorPlaced: {
|
|
18
|
+
footprint: (n) => ({
|
|
19
|
+
dimensions: n.dimensions ?? [1, 1, 1],
|
|
20
|
+
rotation: n.rotation ?? [0, 0, 0],
|
|
21
|
+
}),
|
|
22
|
+
...(applies ? { applies } : {}),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Mirrors the real elevator/stair definitions, which expose their plan
|
|
29
|
+
// footprint via the `alignmentFootprint` capability rather than a hardcoded
|
|
30
|
+
// branch in the anchor bridge. The glue (shaft-outset box / stair AABB) is
|
|
31
|
+
// reproduced here from the same core helpers production uses.
|
|
32
|
+
function elevatorDef() {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'elevator',
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
schema: z.object({ type: z.literal('elevator') }),
|
|
37
|
+
category: 'structure',
|
|
38
|
+
defaults: () => ({}),
|
|
39
|
+
capabilities: {
|
|
40
|
+
alignmentFootprint: (n) => {
|
|
41
|
+
const e = n;
|
|
42
|
+
const wall = getElevatorShaftWallThickness(e);
|
|
43
|
+
return {
|
|
44
|
+
shape: 'box',
|
|
45
|
+
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2],
|
|
46
|
+
rotation: [0, e.rotation ?? 0, 0],
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function stairDef() {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'stair',
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
schema: z.object({ type: z.literal('stair') }),
|
|
58
|
+
category: 'structure',
|
|
59
|
+
defaults: () => ({}),
|
|
60
|
+
capabilities: {
|
|
61
|
+
alignmentFootprint: (n, nodes) => {
|
|
62
|
+
const aabb = stairFootprintAABB(n, nodes);
|
|
63
|
+
return aabb ? { shape: 'aabb', ...aabb } : null;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function plainDef(kind) {
|
|
70
|
+
return {
|
|
71
|
+
kind,
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
74
|
+
category: 'utility',
|
|
75
|
+
defaults: () => ({}),
|
|
76
|
+
capabilities: {},
|
|
77
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const node = (over) => over;
|
|
81
|
+
describe('footprintAABBFrom', () => {
|
|
82
|
+
test('unrotated box is centred at position', () => {
|
|
83
|
+
const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0);
|
|
84
|
+
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 });
|
|
85
|
+
});
|
|
86
|
+
test('90° rotation swaps width and depth extents', () => {
|
|
87
|
+
const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2);
|
|
88
|
+
expect(aabb.minX).toBeCloseTo(-2, 10);
|
|
89
|
+
expect(aabb.maxX).toBeCloseTo(2, 10);
|
|
90
|
+
expect(aabb.minZ).toBeCloseTo(-1, 10);
|
|
91
|
+
expect(aabb.maxZ).toBeCloseTo(1, 10);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('footprintAABB', () => {
|
|
95
|
+
beforeEach(() => nodeRegistry._reset());
|
|
96
|
+
test('reads dimensions + rotation from a floor-placed kind', () => {
|
|
97
|
+
registerNode(floorPlacedDef('box'));
|
|
98
|
+
const aabb = footprintAABB(node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }));
|
|
99
|
+
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 });
|
|
100
|
+
});
|
|
101
|
+
test('returns null for a kind without a footprint', () => {
|
|
102
|
+
registerNode(plainDef('wall'));
|
|
103
|
+
expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
test('derives an elevator footprint from its OUTER SHAFT, not the cab', () => {
|
|
106
|
+
// Aligns to the visible shaft outline: cab 2×4 + 0.09 m wall each side →
|
|
107
|
+
// 2.18 × 4.18, centred at (10, 20). The cab corners alone would sit ~9 cm
|
|
108
|
+
// inside the drawn edge (past the 8 cm snap), so a guide never appeared.
|
|
109
|
+
// The footprint comes from the elevator's `alignmentFootprint` (box) cap.
|
|
110
|
+
registerNode(elevatorDef());
|
|
111
|
+
const aabb = footprintAABB(node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }));
|
|
112
|
+
expect(aabb).toEqual({ minX: 8.91, minZ: 17.91, maxX: 11.09, maxZ: 22.09 });
|
|
113
|
+
});
|
|
114
|
+
test('returns null when the kind predicate excludes the node', () => {
|
|
115
|
+
registerNode(floorPlacedDef('lamp', (n) => !n.attached));
|
|
116
|
+
expect(footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true }))).toBeNull();
|
|
117
|
+
expect(footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false }))).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('movingFootprintAnchors', () => {
|
|
121
|
+
beforeEach(() => nodeRegistry._reset());
|
|
122
|
+
test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => {
|
|
123
|
+
registerNode(floorPlacedDef('box'));
|
|
124
|
+
const anchors = movingFootprintAnchors(node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }), 10, 20);
|
|
125
|
+
// 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}.
|
|
126
|
+
expect(anchors).toHaveLength(4);
|
|
127
|
+
expect(anchors.every((a) => a.kind === 'corner')).toBe(true);
|
|
128
|
+
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11]));
|
|
129
|
+
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22]));
|
|
130
|
+
});
|
|
131
|
+
test('rotationY override drives the AABB regardless of node rotation', () => {
|
|
132
|
+
registerNode(floorPlacedDef('box'));
|
|
133
|
+
const anchors = movingFootprintAnchors(node({
|
|
134
|
+
id: 'm',
|
|
135
|
+
type: 'box',
|
|
136
|
+
position: [0, 0, 0],
|
|
137
|
+
dimensions: [2, 1, 4],
|
|
138
|
+
rotation: [0, 0, 0],
|
|
139
|
+
}), 0, 0, Math.PI / 2);
|
|
140
|
+
const xs = anchors.map((a) => a.x);
|
|
141
|
+
// Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1.
|
|
142
|
+
expect(Math.max(...xs)).toBeCloseTo(2, 10);
|
|
143
|
+
expect(Math.min(...xs)).toBeCloseTo(-2, 10);
|
|
144
|
+
});
|
|
145
|
+
test('returns empty for a footprintless kind', () => {
|
|
146
|
+
registerNode(plainDef('wall'));
|
|
147
|
+
expect(movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1)).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('movingAlignmentAnchors', () => {
|
|
151
|
+
beforeEach(() => nodeRegistry._reset());
|
|
152
|
+
test('relocates a straight stair by its segment-chain footprint', () => {
|
|
153
|
+
registerNode(stairDef());
|
|
154
|
+
const nodes = {
|
|
155
|
+
st: node({
|
|
156
|
+
id: 'st',
|
|
157
|
+
type: 'stair',
|
|
158
|
+
position: [0, 0, 0],
|
|
159
|
+
rotation: 0,
|
|
160
|
+
stairType: 'straight',
|
|
161
|
+
width: 1,
|
|
162
|
+
children: ['seg'],
|
|
163
|
+
}),
|
|
164
|
+
seg: node({
|
|
165
|
+
id: 'seg',
|
|
166
|
+
type: 'stair-segment',
|
|
167
|
+
parentId: 'st',
|
|
168
|
+
width: 1,
|
|
169
|
+
length: 3,
|
|
170
|
+
height: 2.5,
|
|
171
|
+
attachmentSide: 'front',
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0);
|
|
175
|
+
expect(anchors).toHaveLength(4);
|
|
176
|
+
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5]));
|
|
177
|
+
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23]));
|
|
178
|
+
});
|
|
179
|
+
test('rotation override drives a moving straight stair footprint', () => {
|
|
180
|
+
registerNode(stairDef());
|
|
181
|
+
const nodes = {
|
|
182
|
+
st: node({
|
|
183
|
+
id: 'st',
|
|
184
|
+
type: 'stair',
|
|
185
|
+
position: [0, 0, 0],
|
|
186
|
+
rotation: 0,
|
|
187
|
+
stairType: 'straight',
|
|
188
|
+
width: 1,
|
|
189
|
+
children: ['seg'],
|
|
190
|
+
}),
|
|
191
|
+
seg: node({
|
|
192
|
+
id: 'seg',
|
|
193
|
+
type: 'stair-segment',
|
|
194
|
+
parentId: 'st',
|
|
195
|
+
width: 1,
|
|
196
|
+
length: 3,
|
|
197
|
+
height: 2.5,
|
|
198
|
+
attachmentSide: 'front',
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2);
|
|
202
|
+
const xs = anchors.map((a) => a.x);
|
|
203
|
+
const zs = anchors.map((a) => a.z);
|
|
204
|
+
expect(Math.min(...xs)).toBeCloseTo(10, 10);
|
|
205
|
+
expect(Math.max(...xs)).toBeCloseTo(13, 10);
|
|
206
|
+
expect(Math.min(...zs)).toBeCloseTo(19.5, 10);
|
|
207
|
+
expect(Math.max(...zs)).toBeCloseTo(20.5, 10);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('wallSegmentAnchors', () => {
|
|
211
|
+
test('returns both endpoints as corners and the chord midpoint as center', () => {
|
|
212
|
+
const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]);
|
|
213
|
+
expect(anchors).toEqual([
|
|
214
|
+
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
|
|
215
|
+
{ nodeId: 'w', kind: 'corner', x: 4, z: 2 },
|
|
216
|
+
{ nodeId: 'w', kind: 'center', x: 2, z: 1 },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => {
|
|
220
|
+
// Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1.
|
|
221
|
+
const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2);
|
|
222
|
+
expect(anchors).toEqual([
|
|
223
|
+
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
|
|
224
|
+
{ nodeId: 'w', kind: 'corner', x: 4, z: 0 },
|
|
225
|
+
{ nodeId: 'w', kind: 'center', x: 2, z: 0 },
|
|
226
|
+
{ nodeId: 'w', kind: 'corner', x: 0, z: 0.1 },
|
|
227
|
+
{ nodeId: 'w', kind: 'corner', x: 0, z: -0.1 },
|
|
228
|
+
{ nodeId: 'w', kind: 'corner', x: 4, z: 0.1 },
|
|
229
|
+
{ nodeId: 'w', kind: 'corner', x: 4, z: -0.1 },
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
test('skips face corners for zero/degenerate input', () => {
|
|
233
|
+
expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3);
|
|
234
|
+
expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe('polygonAnchors', () => {
|
|
238
|
+
test('returns each vertex as a corner anchor', () => {
|
|
239
|
+
expect(polygonAnchors('s', [
|
|
240
|
+
[0, 0],
|
|
241
|
+
[2, 0],
|
|
242
|
+
[2, 3],
|
|
243
|
+
])).toEqual([
|
|
244
|
+
{ nodeId: 's', kind: 'corner', x: 0, z: 0 },
|
|
245
|
+
{ nodeId: 's', kind: 'corner', x: 2, z: 0 },
|
|
246
|
+
{ nodeId: 's', kind: 'corner', x: 2, z: 3 },
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
describe('collectAlignmentAnchors', () => {
|
|
251
|
+
beforeEach(() => nodeRegistry._reset());
|
|
252
|
+
test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => {
|
|
253
|
+
registerNode(floorPlacedDef('box'));
|
|
254
|
+
const nodes = {
|
|
255
|
+
moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }),
|
|
256
|
+
box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }),
|
|
257
|
+
wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }),
|
|
258
|
+
slab: node({
|
|
259
|
+
id: 'slab',
|
|
260
|
+
type: 'slab',
|
|
261
|
+
polygon: [
|
|
262
|
+
[0, 0],
|
|
263
|
+
[2, 0],
|
|
264
|
+
[2, 2],
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
const anchors = collectAlignmentAnchors(nodes, 'moving');
|
|
269
|
+
const ids = anchors.map((a) => a.nodeId);
|
|
270
|
+
expect(ids).not.toContain('moving');
|
|
271
|
+
expect(ids.filter((id) => id === 'box')).toHaveLength(4); // corner anchors
|
|
272
|
+
expect(ids.filter((id) => id === 'wall')).toHaveLength(7); // endpoints + midpoint + 4 face corners
|
|
273
|
+
expect(ids.filter((id) => id === 'slab')).toHaveLength(3); // polygon vertices
|
|
274
|
+
});
|
|
275
|
+
test('levelId filter keeps only nodes resolving to that level (incl. nested)', () => {
|
|
276
|
+
registerNode(floorPlacedDef('box'));
|
|
277
|
+
registerNode(elevatorDef());
|
|
278
|
+
const nodes = {
|
|
279
|
+
b: node({ id: 'b', type: 'building' }),
|
|
280
|
+
L1: node({ id: 'L1', type: 'level', parentId: 'b' }),
|
|
281
|
+
L2: node({ id: 'L2', type: 'level', parentId: 'b' }),
|
|
282
|
+
moving: node({ id: 'moving', type: 'box', parentId: 'L1', position: [0, 0, 0] }),
|
|
283
|
+
sameFloor: node({ id: 'sameFloor', type: 'box', parentId: 'L1', position: [5, 0, 5] }),
|
|
284
|
+
// Item resting on `sameFloor` — resolves to L1 through the parent chain.
|
|
285
|
+
nested: node({ id: 'nested', type: 'box', parentId: 'sameFloor', position: [5, 0, 5] }),
|
|
286
|
+
otherFloor: node({ id: 'otherFloor', type: 'box', parentId: 'L2', position: [5, 0, 5] }),
|
|
287
|
+
// Building-scoped (parented to the building, no level ancestor) — spans
|
|
288
|
+
// every floor, so it stays in the pool regardless of the active level.
|
|
289
|
+
elevator: node({
|
|
290
|
+
id: 'elevator',
|
|
291
|
+
type: 'elevator',
|
|
292
|
+
parentId: 'b',
|
|
293
|
+
position: [9, 0, 9],
|
|
294
|
+
width: 1.6,
|
|
295
|
+
depth: 1.6,
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
298
|
+
const ids = collectAlignmentAnchors(nodes, 'moving', 'L1').map((a) => a.nodeId);
|
|
299
|
+
expect(ids.filter((id) => id === 'sameFloor')).toHaveLength(4);
|
|
300
|
+
expect(ids.filter((id) => id === 'nested')).toHaveLength(4);
|
|
301
|
+
expect(ids.filter((id) => id === 'elevator')).toHaveLength(4);
|
|
302
|
+
expect(ids).not.toContain('otherFloor');
|
|
303
|
+
});
|
|
304
|
+
test('straight stair contributes its segment-chain footprint corners', () => {
|
|
305
|
+
registerNode(stairDef());
|
|
306
|
+
const nodes = {
|
|
307
|
+
st: node({
|
|
308
|
+
id: 'st',
|
|
309
|
+
type: 'stair',
|
|
310
|
+
position: [0, 0, 0],
|
|
311
|
+
rotation: 0,
|
|
312
|
+
stairType: 'straight',
|
|
313
|
+
width: 1,
|
|
314
|
+
children: ['seg'],
|
|
315
|
+
}),
|
|
316
|
+
// Single 1×3 flight; origin at the run start, extending +Z by length.
|
|
317
|
+
seg: node({
|
|
318
|
+
id: 'seg',
|
|
319
|
+
type: 'stair-segment',
|
|
320
|
+
parentId: 'st',
|
|
321
|
+
width: 1,
|
|
322
|
+
length: 3,
|
|
323
|
+
height: 2.5,
|
|
324
|
+
attachmentSide: 'front',
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'st');
|
|
328
|
+
expect(anchors).toHaveLength(4);
|
|
329
|
+
expect(anchors.every((a) => a.kind === 'corner')).toBe(true);
|
|
330
|
+
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([-0.5, 0.5]));
|
|
331
|
+
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([0, 3]));
|
|
332
|
+
});
|
|
333
|
+
test('curved stair contributes its sector bounding-box corners', () => {
|
|
334
|
+
registerNode(stairDef());
|
|
335
|
+
const nodes = {
|
|
336
|
+
cs: node({
|
|
337
|
+
id: 'cs',
|
|
338
|
+
type: 'stair',
|
|
339
|
+
position: [0, 0, 0],
|
|
340
|
+
rotation: 0,
|
|
341
|
+
stairType: 'curved',
|
|
342
|
+
width: 1,
|
|
343
|
+
innerRadius: 1,
|
|
344
|
+
sweepAngle: Math.PI / 2,
|
|
345
|
+
}),
|
|
346
|
+
};
|
|
347
|
+
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'cs');
|
|
348
|
+
expect(anchors).toHaveLength(4);
|
|
349
|
+
// outerRadius = inner(1) + width(1) = 2, sweep π/2 centred on +X. Outer rim
|
|
350
|
+
// reaches X=2 at the bisector; min X is the inner rim's ±π/4 ends (cos45·1);
|
|
351
|
+
// Z spans ±(outer·sin45).
|
|
352
|
+
const xs = anchors.map((a) => a.x);
|
|
353
|
+
const zs = anchors.map((a) => a.z);
|
|
354
|
+
expect(Math.max(...xs)).toBeCloseTo(2, 5);
|
|
355
|
+
expect(Math.min(...xs)).toBeCloseTo(Math.SQRT1_2, 5);
|
|
356
|
+
expect(Math.max(...zs)).toBeCloseTo(Math.SQRT2, 5);
|
|
357
|
+
expect(Math.min(...zs)).toBeCloseTo(-Math.SQRT2, 5);
|
|
358
|
+
});
|
|
359
|
+
test('spiral stair contributes a full-circle bounding box', () => {
|
|
360
|
+
registerNode(stairDef());
|
|
361
|
+
const nodes = {
|
|
362
|
+
sp: node({
|
|
363
|
+
id: 'sp',
|
|
364
|
+
type: 'stair',
|
|
365
|
+
position: [5, 0, 5],
|
|
366
|
+
rotation: 0,
|
|
367
|
+
stairType: 'spiral',
|
|
368
|
+
width: 1,
|
|
369
|
+
innerRadius: 0.5,
|
|
370
|
+
sweepAngle: Math.PI * 2,
|
|
371
|
+
}),
|
|
372
|
+
};
|
|
373
|
+
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'sp');
|
|
374
|
+
expect(anchors).toHaveLength(4);
|
|
375
|
+
// outerRadius = inner(0.5) + width(1) = 1.5, a full revolution about (5, 5).
|
|
376
|
+
const xs = anchors.map((a) => a.x);
|
|
377
|
+
const zs = anchors.map((a) => a.z);
|
|
378
|
+
expect(Math.max(...xs)).toBeCloseTo(6.5, 2);
|
|
379
|
+
expect(Math.min(...xs)).toBeCloseTo(3.5, 2);
|
|
380
|
+
expect(Math.max(...zs)).toBeCloseTo(6.5, 2);
|
|
381
|
+
expect(Math.min(...zs)).toBeCloseTo(3.5, 2);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure alignment-guide resolver — no React, no DOM, no scene access.
|
|
3
|
+
*
|
|
4
|
+
* Given a moving object's anchor points at its proposed position and a
|
|
5
|
+
* pool of candidate anchors from nearby static objects, returns:
|
|
6
|
+
* - the best per-axis matches as `Guide` rendering primitives, and
|
|
7
|
+
* - an optional `{ dx, dz }` snap delta the caller can apply.
|
|
8
|
+
*
|
|
9
|
+
* Anchors are 2D points on the floor plane (XZ, in world meters). The
|
|
10
|
+
* resolver picks at most one match per axis: the smallest |Δx| match
|
|
11
|
+
* snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's
|
|
12
|
+
* behaviour — guides appear along the matched axes, regardless of how
|
|
13
|
+
* many neighbours could have matched.
|
|
14
|
+
*
|
|
15
|
+
* Two guides max per call keeps the visual signal sharp at the cost of
|
|
16
|
+
* not surfacing every possible alignment at once. Multi-guide ("this
|
|
17
|
+
* lines up with three things") is intentionally out of scope for v1.
|
|
18
|
+
*/
|
|
19
|
+
export type AnchorKind = 'corner' | 'edge-mid' | 'center';
|
|
20
|
+
export type AlignmentAnchor = {
|
|
21
|
+
/** Owning node id — informational; resolver does not use it. */
|
|
22
|
+
nodeId: string;
|
|
23
|
+
kind: AnchorKind;
|
|
24
|
+
x: number;
|
|
25
|
+
z: number;
|
|
26
|
+
};
|
|
27
|
+
export type AlignmentGuideAxis = 'x' | 'z';
|
|
28
|
+
/**
|
|
29
|
+
* Rendering primitive — a guide line on the floor plane.
|
|
30
|
+
*
|
|
31
|
+
* `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X.
|
|
32
|
+
* `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z.
|
|
33
|
+
*
|
|
34
|
+
* The line spans from the matched candidate anchor to the moving anchor
|
|
35
|
+
* after snap. Renderers extend visually beyond the endpoints if they want
|
|
36
|
+
* Figma-style "infinite line" feel.
|
|
37
|
+
*/
|
|
38
|
+
export type AlignmentGuide = {
|
|
39
|
+
axis: AlignmentGuideAxis;
|
|
40
|
+
coord: number;
|
|
41
|
+
from: {
|
|
42
|
+
x: number;
|
|
43
|
+
z: number;
|
|
44
|
+
};
|
|
45
|
+
to: {
|
|
46
|
+
x: number;
|
|
47
|
+
z: number;
|
|
48
|
+
};
|
|
49
|
+
movingAnchorKind: AnchorKind;
|
|
50
|
+
candidateAnchorKind: AnchorKind;
|
|
51
|
+
candidateNodeId: string;
|
|
52
|
+
/** Perpendicular distance between the two anchors (used by the distance pill). */
|
|
53
|
+
distance: number;
|
|
54
|
+
};
|
|
55
|
+
export type ResolveAlignmentInput = {
|
|
56
|
+
/** Anchors of the moving node, positioned at the proposed (pre-snap) location. */
|
|
57
|
+
moving: readonly AlignmentAnchor[];
|
|
58
|
+
/** Anchors from every other candidate node the caller has already filtered. */
|
|
59
|
+
candidates: readonly AlignmentAnchor[];
|
|
60
|
+
/**
|
|
61
|
+
* Max |Δ| (meters) for an anchor pair to count as a match. Typically
|
|
62
|
+
* derived from a screen-pixel budget × current units-per-pixel so the
|
|
63
|
+
* snap feel is zoom-invariant.
|
|
64
|
+
*/
|
|
65
|
+
threshold: number;
|
|
66
|
+
};
|
|
67
|
+
export type ResolveAlignmentResult = {
|
|
68
|
+
guides: AlignmentGuide[];
|
|
69
|
+
/**
|
|
70
|
+
* Delta the caller should add to the moving node's planar position so
|
|
71
|
+
* its anchors land on the matched axes. `null` when no axis matched.
|
|
72
|
+
*/
|
|
73
|
+
snap: {
|
|
74
|
+
dx: number;
|
|
75
|
+
dz: number;
|
|
76
|
+
} | null;
|
|
77
|
+
};
|
|
78
|
+
export type BuildingPose = {
|
|
79
|
+
position: readonly [number, number, number];
|
|
80
|
+
rotationY: number;
|
|
81
|
+
};
|
|
82
|
+
export type ResolveAlignmentInBuildingResult = {
|
|
83
|
+
/** Guides in WORLD coordinates. Renderers must be in a world-space group. */
|
|
84
|
+
guides: AlignmentGuide[];
|
|
85
|
+
/** Snap delta in the BUILDING-LOCAL frame, ready to add to a local position. */
|
|
86
|
+
snap: {
|
|
87
|
+
dx: number;
|
|
88
|
+
dz: number;
|
|
89
|
+
} | null;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Resolve alignment in WORLD space while accepting BUILDING-LOCAL anchors.
|
|
93
|
+
*
|
|
94
|
+
* Why this exists: the floor-plan grid lives in world XZ (rendered outside
|
|
95
|
+
* the rotated scene group), so alignment must follow the same axes —
|
|
96
|
+
* otherwise rotating a building drags the alignment guides off the visible
|
|
97
|
+
* grid and onto the rotated wall's local axes (the bug the user hit). The
|
|
98
|
+
* resolver itself is frame-agnostic; this wrapper just transforms anchors
|
|
99
|
+
* to world, resolves, then rotates the snap delta back into building-local
|
|
100
|
+
* so callers can add it to a local position without further math.
|
|
101
|
+
*
|
|
102
|
+
* `pose === null` → resolve in the caller's frame as-is (no transform).
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveAlignmentInBuildingWorld(input: {
|
|
105
|
+
moving: readonly AlignmentAnchor[];
|
|
106
|
+
candidates: readonly AlignmentAnchor[];
|
|
107
|
+
threshold: number;
|
|
108
|
+
pose: BuildingPose | null;
|
|
109
|
+
}): ResolveAlignmentInBuildingResult;
|
|
110
|
+
export declare function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult;
|
|
111
|
+
/**
|
|
112
|
+
* Produces the 9 standard anchors for an axis-aligned bounding box on the
|
|
113
|
+
* floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any
|
|
114
|
+
* floor-plan entity whose footprint can be expressed as a bbox.
|
|
115
|
+
*
|
|
116
|
+
* Caller is responsible for computing the bbox — the resolver doesn't
|
|
117
|
+
* care how (per-kind dimensions, SVG getBBox(), etc.).
|
|
118
|
+
*/
|
|
119
|
+
export declare function bboxAnchors(nodeId: string, minX: number, minZ: number, maxX: number, maxZ: number): AlignmentAnchor[];
|
|
120
|
+
/**
|
|
121
|
+
* The 4 corner anchors of a bbox — edges only, no edge-midpoints or center.
|
|
122
|
+
* Used where alignment should lock to an object's edges (left/right/front/
|
|
123
|
+
* back), never its centreline.
|
|
124
|
+
*/
|
|
125
|
+
export declare function bboxCornerAnchors(nodeId: string, minX: number, minZ: number, maxX: number, maxZ: number): AlignmentAnchor[];
|
|
126
|
+
//# sourceMappingURL=alignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alignment.d.ts","sourceRoot":"","sources":["../../src/services/alignment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAA;AAEzD,MAAM,MAAM,eAAe,GAAG;IAC5B,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,UAAU,CAAA;IAChB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,GAAG,GAAG,GAAG,CAAA;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,kBAAkB,CAAA;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9B,EAAE,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5B,gBAAgB,EAAE,UAAU,CAAA;IAC5B,mBAAmB,EAAE,UAAU,CAAA;IAC/B,eAAe,EAAE,MAAM,CAAA;IACvB,kFAAkF;IAClF,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,kFAAkF;IAClF,MAAM,EAAE,SAAS,eAAe,EAAE,CAAA;IAClC,+EAA+E;IAC/E,UAAU,EAAE,SAAS,eAAe,EAAE,CAAA;IACtC;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB;;;OAGG;IACH,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CACxC,CAAA;AAgCD,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC3C,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,gCAAgC,GAAG;IAC7C,6EAA6E;IAC7E,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,gFAAgF;IAChF,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CACxC,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE;IACrD,MAAM,EAAE,SAAS,eAAe,EAAE,CAAA;IAClC,UAAU,EAAE,SAAS,eAAe,EAAE,CAAA;IACtC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,YAAY,GAAG,IAAI,CAAA;CAC1B,GAAG,gCAAgC,CAwBnC;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,sBAAsB,CA0FrF;AAID;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,eAAe,EAAE,CAcnB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,eAAe,EAAE,CAOnB"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure alignment-guide resolver — no React, no DOM, no scene access.
|
|
3
|
+
*
|
|
4
|
+
* Given a moving object's anchor points at its proposed position and a
|
|
5
|
+
* pool of candidate anchors from nearby static objects, returns:
|
|
6
|
+
* - the best per-axis matches as `Guide` rendering primitives, and
|
|
7
|
+
* - an optional `{ dx, dz }` snap delta the caller can apply.
|
|
8
|
+
*
|
|
9
|
+
* Anchors are 2D points on the floor plane (XZ, in world meters). The
|
|
10
|
+
* resolver picks at most one match per axis: the smallest |Δx| match
|
|
11
|
+
* snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's
|
|
12
|
+
* behaviour — guides appear along the matched axes, regardless of how
|
|
13
|
+
* many neighbours could have matched.
|
|
14
|
+
*
|
|
15
|
+
* Two guides max per call keeps the visual signal sharp at the cost of
|
|
16
|
+
* not surfacing every possible alignment at once. Multi-guide ("this
|
|
17
|
+
* lines up with three things") is intentionally out of scope for v1.
|
|
18
|
+
*/
|
|
19
|
+
const EMPTY = { guides: [], snap: null };
|
|
20
|
+
/** Forward rotation: local XZ → world XZ for a node whose parent has
|
|
21
|
+
* position `bx,_,bz` and rotation-Y `rotY` (radians). Matches the
|
|
22
|
+
* transform used throughout the editor's tools / floor-plan. */
|
|
23
|
+
function localToWorld(x, z, bx, bz, cos, sin) {
|
|
24
|
+
return {
|
|
25
|
+
x: bx + x * cos + z * sin,
|
|
26
|
+
z: bz - x * sin + z * cos,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function transformAnchorToWorld(anchor, bx, bz, cos, sin) {
|
|
30
|
+
const w = localToWorld(anchor.x, anchor.z, bx, bz, cos, sin);
|
|
31
|
+
return { nodeId: anchor.nodeId, kind: anchor.kind, x: w.x, z: w.z };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve alignment in WORLD space while accepting BUILDING-LOCAL anchors.
|
|
35
|
+
*
|
|
36
|
+
* Why this exists: the floor-plan grid lives in world XZ (rendered outside
|
|
37
|
+
* the rotated scene group), so alignment must follow the same axes —
|
|
38
|
+
* otherwise rotating a building drags the alignment guides off the visible
|
|
39
|
+
* grid and onto the rotated wall's local axes (the bug the user hit). The
|
|
40
|
+
* resolver itself is frame-agnostic; this wrapper just transforms anchors
|
|
41
|
+
* to world, resolves, then rotates the snap delta back into building-local
|
|
42
|
+
* so callers can add it to a local position without further math.
|
|
43
|
+
*
|
|
44
|
+
* `pose === null` → resolve in the caller's frame as-is (no transform).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveAlignmentInBuildingWorld(input) {
|
|
47
|
+
const { moving, candidates, threshold, pose } = input;
|
|
48
|
+
if (!pose) {
|
|
49
|
+
return resolveAlignment({ moving, candidates, threshold });
|
|
50
|
+
}
|
|
51
|
+
const cos = Math.cos(pose.rotationY);
|
|
52
|
+
const sin = Math.sin(pose.rotationY);
|
|
53
|
+
const bx = pose.position[0];
|
|
54
|
+
const bz = pose.position[2];
|
|
55
|
+
const movingWorld = moving.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin));
|
|
56
|
+
const candidatesWorld = candidates.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin));
|
|
57
|
+
const result = resolveAlignment({
|
|
58
|
+
moving: movingWorld,
|
|
59
|
+
candidates: candidatesWorld,
|
|
60
|
+
threshold,
|
|
61
|
+
});
|
|
62
|
+
if (!result.snap)
|
|
63
|
+
return { guides: result.guides, snap: null };
|
|
64
|
+
// World → local rotation (orthogonal matrix → transpose). The inverse of
|
|
65
|
+
// `localToWorld` above maps (dx_world, dz_world) → (dx_local, dz_local).
|
|
66
|
+
const dxW = result.snap.dx;
|
|
67
|
+
const dzW = result.snap.dz;
|
|
68
|
+
const dxL = dxW * cos - dzW * sin;
|
|
69
|
+
const dzL = dxW * sin + dzW * cos;
|
|
70
|
+
return { guides: result.guides, snap: { dx: dxL, dz: dzL } };
|
|
71
|
+
}
|
|
72
|
+
export function resolveAlignment(input) {
|
|
73
|
+
const { moving, candidates, threshold } = input;
|
|
74
|
+
if (threshold <= 0 || moving.length === 0 || candidates.length === 0)
|
|
75
|
+
return EMPTY;
|
|
76
|
+
let bestX = null;
|
|
77
|
+
let bestZ = null;
|
|
78
|
+
for (const m of moving) {
|
|
79
|
+
for (const c of candidates) {
|
|
80
|
+
const dx = c.x - m.x;
|
|
81
|
+
const dz = c.z - m.z;
|
|
82
|
+
const adx = Math.abs(dx);
|
|
83
|
+
const adz = Math.abs(dz);
|
|
84
|
+
if (adx <= threshold &&
|
|
85
|
+
(bestX === null || adz < bestX.perp || (adz === bestX.perp && adx < bestX.primary))) {
|
|
86
|
+
bestX = { delta: dx, primary: adx, perp: adz, m, c };
|
|
87
|
+
}
|
|
88
|
+
if (adz <= threshold &&
|
|
89
|
+
(bestZ === null || adx < bestZ.perp || (adx === bestZ.perp && adz < bestZ.primary))) {
|
|
90
|
+
bestZ = { delta: dz, primary: adz, perp: adx, m, c };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!bestX && !bestZ)
|
|
95
|
+
return EMPTY;
|
|
96
|
+
const dxSnap = bestX?.delta ?? 0;
|
|
97
|
+
const dzSnap = bestZ?.delta ?? 0;
|
|
98
|
+
const guides = [];
|
|
99
|
+
if (bestX) {
|
|
100
|
+
// X-axis match: vertical guide at x = bestX.c.x. The moving anchor
|
|
101
|
+
// ends up at (c.x, m.z + dzSnap). Span the line between them.
|
|
102
|
+
const snappedMz = bestX.m.z + dzSnap;
|
|
103
|
+
const z1 = Math.min(bestX.c.z, snappedMz);
|
|
104
|
+
const z2 = Math.max(bestX.c.z, snappedMz);
|
|
105
|
+
guides.push({
|
|
106
|
+
axis: 'x',
|
|
107
|
+
coord: bestX.c.x,
|
|
108
|
+
from: { x: bestX.c.x, z: z1 },
|
|
109
|
+
to: { x: bestX.c.x, z: z2 },
|
|
110
|
+
movingAnchorKind: bestX.m.kind,
|
|
111
|
+
candidateAnchorKind: bestX.c.kind,
|
|
112
|
+
candidateNodeId: bestX.c.nodeId,
|
|
113
|
+
distance: Math.abs(snappedMz - bestX.c.z),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (bestZ) {
|
|
117
|
+
const snappedMx = bestZ.m.x + dxSnap;
|
|
118
|
+
const x1 = Math.min(bestZ.c.x, snappedMx);
|
|
119
|
+
const x2 = Math.max(bestZ.c.x, snappedMx);
|
|
120
|
+
guides.push({
|
|
121
|
+
axis: 'z',
|
|
122
|
+
coord: bestZ.c.z,
|
|
123
|
+
from: { x: x1, z: bestZ.c.z },
|
|
124
|
+
to: { x: x2, z: bestZ.c.z },
|
|
125
|
+
movingAnchorKind: bestZ.m.kind,
|
|
126
|
+
candidateAnchorKind: bestZ.c.kind,
|
|
127
|
+
candidateNodeId: bestZ.c.nodeId,
|
|
128
|
+
distance: Math.abs(snappedMx - bestZ.c.x),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { guides, snap: { dx: dxSnap, dz: dzSnap } };
|
|
132
|
+
}
|
|
133
|
+
// ─── Anchor extractors (pure) ─────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Produces the 9 standard anchors for an axis-aligned bounding box on the
|
|
136
|
+
* floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any
|
|
137
|
+
* floor-plan entity whose footprint can be expressed as a bbox.
|
|
138
|
+
*
|
|
139
|
+
* Caller is responsible for computing the bbox — the resolver doesn't
|
|
140
|
+
* care how (per-kind dimensions, SVG getBBox(), etc.).
|
|
141
|
+
*/
|
|
142
|
+
export function bboxAnchors(nodeId, minX, minZ, maxX, maxZ) {
|
|
143
|
+
const cx = (minX + maxX) / 2;
|
|
144
|
+
const cz = (minZ + maxZ) / 2;
|
|
145
|
+
return [
|
|
146
|
+
{ nodeId, kind: 'corner', x: minX, z: minZ },
|
|
147
|
+
{ nodeId, kind: 'corner', x: maxX, z: minZ },
|
|
148
|
+
{ nodeId, kind: 'corner', x: maxX, z: maxZ },
|
|
149
|
+
{ nodeId, kind: 'corner', x: minX, z: maxZ },
|
|
150
|
+
{ nodeId, kind: 'edge-mid', x: cx, z: minZ },
|
|
151
|
+
{ nodeId, kind: 'edge-mid', x: maxX, z: cz },
|
|
152
|
+
{ nodeId, kind: 'edge-mid', x: cx, z: maxZ },
|
|
153
|
+
{ nodeId, kind: 'edge-mid', x: minX, z: cz },
|
|
154
|
+
{ nodeId, kind: 'center', x: cx, z: cz },
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The 4 corner anchors of a bbox — edges only, no edge-midpoints or center.
|
|
159
|
+
* Used where alignment should lock to an object's edges (left/right/front/
|
|
160
|
+
* back), never its centreline.
|
|
161
|
+
*/
|
|
162
|
+
export function bboxCornerAnchors(nodeId, minX, minZ, maxX, maxZ) {
|
|
163
|
+
return [
|
|
164
|
+
{ nodeId, kind: 'corner', x: minX, z: minZ },
|
|
165
|
+
{ nodeId, kind: 'corner', x: maxX, z: minZ },
|
|
166
|
+
{ nodeId, kind: 'corner', x: maxX, z: maxZ },
|
|
167
|
+
{ nodeId, kind: 'corner', x: minX, z: maxZ },
|
|
168
|
+
];
|
|
169
|
+
}
|