@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,122 @@
|
|
|
1
|
+
import { nodeRegistry } from '../registry/registry';
|
|
2
|
+
/**
|
|
3
|
+
* Maximum depth a node tree can host. Guards items-on-items-on-items chains
|
|
4
|
+
* from growing pathological — pre-Phase-1 the editor had no cap. Set high
|
|
5
|
+
* enough to allow legitimate stacking (chair on platform on truck on floor)
|
|
6
|
+
* while preventing AI/plugin-generated runaway.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_HOST_DEPTH = 6;
|
|
9
|
+
/**
|
|
10
|
+
* Validates that attaching `child` to `host` is safe and either returns an
|
|
11
|
+
* actionable error or signals OK. Does NOT mutate the scene — callers apply
|
|
12
|
+
* the patch after a successful check.
|
|
13
|
+
*
|
|
14
|
+
* Rules:
|
|
15
|
+
* - A node cannot host itself.
|
|
16
|
+
* - The hosting chain (child → host → host.parent → ...) must not contain
|
|
17
|
+
* `child` (cycle prevention).
|
|
18
|
+
* - The resulting chain must not exceed {@link MAX_HOST_DEPTH}.
|
|
19
|
+
* - If the child's NodeDefinition declares `capabilities.hostable.parents`,
|
|
20
|
+
* `host.type` must appear in that list.
|
|
21
|
+
*/
|
|
22
|
+
export function canAttach(childId, hostId, scene) {
|
|
23
|
+
if (childId === hostId) {
|
|
24
|
+
return { ok: false, error: { kind: 'self-host', nodeId: childId } };
|
|
25
|
+
}
|
|
26
|
+
const host = scene.get(hostId);
|
|
27
|
+
if (!host) {
|
|
28
|
+
return { ok: false, error: { kind: 'host-missing', hostId } };
|
|
29
|
+
}
|
|
30
|
+
const child = scene.get(childId);
|
|
31
|
+
if (!child) {
|
|
32
|
+
// No child node yet — likely a placement preview. Allow attach to proceed;
|
|
33
|
+
// the caller is responsible for ensuring child exists before commit.
|
|
34
|
+
return checkDepth(hostId, scene);
|
|
35
|
+
}
|
|
36
|
+
const childDef = nodeRegistry.get(child.type);
|
|
37
|
+
const allowed = childDef?.capabilities.hostable?.parents;
|
|
38
|
+
if (allowed && allowed.length > 0 && !allowed.includes(host.type)) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: { kind: 'kind-not-allowed', hostKind: host.type, allowed },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Cycle: walk host's ancestors and reject if we hit the child.
|
|
45
|
+
let cursor = host;
|
|
46
|
+
while (cursor) {
|
|
47
|
+
if (cursor.id === childId) {
|
|
48
|
+
return { ok: false, error: { kind: 'cycle', nodeId: childId, hostId } };
|
|
49
|
+
}
|
|
50
|
+
cursor = cursor.parentId ? scene.get(cursor.parentId) : undefined;
|
|
51
|
+
}
|
|
52
|
+
return checkDepth(hostId, scene);
|
|
53
|
+
}
|
|
54
|
+
function checkDepth(hostId, scene) {
|
|
55
|
+
// Count host's own depth (root = 0); attaching adds 1 to the child's depth.
|
|
56
|
+
let depth = 0;
|
|
57
|
+
let cursor = scene.get(hostId);
|
|
58
|
+
while (cursor?.parentId) {
|
|
59
|
+
cursor = scene.get(cursor.parentId);
|
|
60
|
+
depth += 1;
|
|
61
|
+
if (depth > MAX_HOST_DEPTH) {
|
|
62
|
+
return { ok: false, error: { kind: 'depth-exceeded', depth, max: MAX_HOST_DEPTH } };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Child sits one below host.
|
|
66
|
+
if (depth + 1 > MAX_HOST_DEPTH) {
|
|
67
|
+
return { ok: false, error: { kind: 'depth-exceeded', depth: depth + 1, max: MAX_HOST_DEPTH } };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the surfaces declared by a host's NodeDefinition. Surfaces describe
|
|
73
|
+
* where other nodes can stack/mount — the `top` of a slab, the `sides` of a
|
|
74
|
+
* wall, or a custom callback. Returns null when the host's def declares no
|
|
75
|
+
* surfaces (or no def is registered).
|
|
76
|
+
*/
|
|
77
|
+
export function getSurface(host) {
|
|
78
|
+
const def = nodeRegistry.get(host.type);
|
|
79
|
+
return def?.capabilities.surfaces ?? null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the stackable top height of a host (e.g. table surface, slab top,
|
|
83
|
+
* stair landing). Returns `null` when the host has no `surfaces.top`.
|
|
84
|
+
*/
|
|
85
|
+
export function getTopSurfaceHeight(host) {
|
|
86
|
+
const surfaces = getSurface(host);
|
|
87
|
+
if (!surfaces?.top)
|
|
88
|
+
return null;
|
|
89
|
+
const { height } = surfaces.top;
|
|
90
|
+
return typeof height === 'function' ? height(host) : height;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Pure host-discovery helper. Given a list of candidate hosts (already
|
|
94
|
+
* narrowed by spatial query) and a point, returns the first whose
|
|
95
|
+
* `capabilities.hostable` lists `placedKind` AND whose surface contains the
|
|
96
|
+
* point. The runtime is responsible for providing pre-filtered candidates;
|
|
97
|
+
* this function does not perform spatial queries itself.
|
|
98
|
+
*/
|
|
99
|
+
export function pickHost(args) {
|
|
100
|
+
for (const host of args.candidates) {
|
|
101
|
+
const def = nodeRegistry.get(host.type);
|
|
102
|
+
const hostable = def?.capabilities.hostable;
|
|
103
|
+
if (!hostable)
|
|
104
|
+
continue;
|
|
105
|
+
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
|
|
106
|
+
// capability declares specific parents; verify the placed kind's own def
|
|
107
|
+
// also permits this host kind.
|
|
108
|
+
}
|
|
109
|
+
if (args.hitTest && !args.hitTest(host, args.point))
|
|
110
|
+
continue;
|
|
111
|
+
return host;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Convenience: clamps a Y coordinate to the top of a host surface, when one
|
|
117
|
+
* is declared. Returns the original Y if the host has no top surface.
|
|
118
|
+
*/
|
|
119
|
+
export function clampYToHostTop(host, originalY) {
|
|
120
|
+
const top = getTopSurfaceHeight(host);
|
|
121
|
+
return top == null ? originalY : top;
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosting.test.d.ts","sourceRoot":"","sources":["../../src/services/hosting.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { nodeRegistry, registerNode } from '../registry/registry';
|
|
4
|
+
import { canAttach, clampYToHostTop, getSurface, getTopSurfaceHeight, MAX_HOST_DEPTH, pickHost, } from './hosting';
|
|
5
|
+
const id = (s) => s;
|
|
6
|
+
function makeDef(kind, capabilities = {}, overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
kind,
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
11
|
+
category: 'utility',
|
|
12
|
+
defaults: () => ({}),
|
|
13
|
+
capabilities,
|
|
14
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeNode(kind, idStr, parentId = null) {
|
|
19
|
+
return {
|
|
20
|
+
id: id(idStr),
|
|
21
|
+
type: kind,
|
|
22
|
+
parentId: parentId ? id(parentId) : null,
|
|
23
|
+
visible: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeFakeScene(nodes) {
|
|
27
|
+
return {
|
|
28
|
+
get: ((nid) => nodes[nid]),
|
|
29
|
+
nodes: () => nodes,
|
|
30
|
+
update: () => { },
|
|
31
|
+
upsert: () => id(''),
|
|
32
|
+
delete: () => { },
|
|
33
|
+
restore: () => { },
|
|
34
|
+
restoreAll: () => { },
|
|
35
|
+
markDirty: () => { },
|
|
36
|
+
pauseHistory: () => { },
|
|
37
|
+
resumeHistory: () => { },
|
|
38
|
+
getSubtree: () => null,
|
|
39
|
+
cloneNodesInto: () => null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
describe('canAttach', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
nodeRegistry._reset();
|
|
45
|
+
});
|
|
46
|
+
test('rejects self-host', () => {
|
|
47
|
+
const scene = makeFakeScene({ a: makeNode('thing', 'a') });
|
|
48
|
+
const result = canAttach(id('a'), id('a'), scene);
|
|
49
|
+
expect(result.ok).toBe(false);
|
|
50
|
+
if (!result.ok)
|
|
51
|
+
expect(result.error.kind).toBe('self-host');
|
|
52
|
+
});
|
|
53
|
+
test('rejects missing host', () => {
|
|
54
|
+
const scene = makeFakeScene({ a: makeNode('thing', 'a') });
|
|
55
|
+
const result = canAttach(id('a'), id('missing'), scene);
|
|
56
|
+
expect(result.ok).toBe(false);
|
|
57
|
+
if (!result.ok)
|
|
58
|
+
expect(result.error.kind).toBe('host-missing');
|
|
59
|
+
});
|
|
60
|
+
test('detects cycle when host is a descendant of child', () => {
|
|
61
|
+
// child=a, host=b, b's parent chain leads back to a → cycle.
|
|
62
|
+
const scene = makeFakeScene({
|
|
63
|
+
a: makeNode('thing', 'a'),
|
|
64
|
+
b: makeNode('thing', 'b', 'c'),
|
|
65
|
+
c: makeNode('thing', 'c', 'a'), // c.parent = a, b.parent = c → cycle if a → b
|
|
66
|
+
});
|
|
67
|
+
const result = canAttach(id('a'), id('b'), scene);
|
|
68
|
+
expect(result.ok).toBe(false);
|
|
69
|
+
if (!result.ok)
|
|
70
|
+
expect(result.error.kind).toBe('cycle');
|
|
71
|
+
});
|
|
72
|
+
test('rejects when chain would exceed MAX_HOST_DEPTH', () => {
|
|
73
|
+
const nodes = {};
|
|
74
|
+
for (let i = 0; i <= MAX_HOST_DEPTH; i++) {
|
|
75
|
+
nodes[`n${i}`] = makeNode('thing', `n${i}`, i === 0 ? null : `n${i - 1}`);
|
|
76
|
+
}
|
|
77
|
+
// n6 is already MAX_HOST_DEPTH deep — attaching n_new beneath it would
|
|
78
|
+
// push the child to MAX_HOST_DEPTH + 1.
|
|
79
|
+
nodes.candidate = makeNode('thing', 'candidate');
|
|
80
|
+
const scene = makeFakeScene(nodes);
|
|
81
|
+
const result = canAttach(id('candidate'), id(`n${MAX_HOST_DEPTH}`), scene);
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
if (!result.ok)
|
|
84
|
+
expect(result.error.kind).toBe('depth-exceeded');
|
|
85
|
+
});
|
|
86
|
+
test('accepts attach when chain stays within MAX_HOST_DEPTH', () => {
|
|
87
|
+
const scene = makeFakeScene({
|
|
88
|
+
root: makeNode('thing', 'root'),
|
|
89
|
+
candidate: makeNode('thing', 'candidate'),
|
|
90
|
+
});
|
|
91
|
+
expect(canAttach(id('candidate'), id('root'), scene).ok).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
test('rejects host kind not in child def.hostable.parents', () => {
|
|
94
|
+
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }));
|
|
95
|
+
const scene = makeFakeScene({
|
|
96
|
+
s: makeNode('shelf', 's'),
|
|
97
|
+
ceiling: makeNode('ceiling', 'ceiling'),
|
|
98
|
+
});
|
|
99
|
+
const result = canAttach(id('s'), id('ceiling'), scene);
|
|
100
|
+
expect(result.ok).toBe(false);
|
|
101
|
+
if (!result.ok)
|
|
102
|
+
expect(result.error.kind).toBe('kind-not-allowed');
|
|
103
|
+
});
|
|
104
|
+
test('accepts when host kind is in parents', () => {
|
|
105
|
+
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }));
|
|
106
|
+
const scene = makeFakeScene({
|
|
107
|
+
s: makeNode('shelf', 's'),
|
|
108
|
+
w: makeNode('wall', 'w'),
|
|
109
|
+
});
|
|
110
|
+
expect(canAttach(id('s'), id('w'), scene).ok).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test('no def or no hostable.parents = no kind restriction', () => {
|
|
113
|
+
// Some kinds (e.g. items via catalog) defer to runtime checks instead of
|
|
114
|
+
// declaring parents up front. canAttach should not block them.
|
|
115
|
+
const scene = makeFakeScene({
|
|
116
|
+
i: makeNode('item', 'i'),
|
|
117
|
+
w: makeNode('wall', 'w'),
|
|
118
|
+
});
|
|
119
|
+
expect(canAttach(id('i'), id('w'), scene).ok).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test('allows missing child (placement preview before commit)', () => {
|
|
122
|
+
const scene = makeFakeScene({ w: makeNode('wall', 'w') });
|
|
123
|
+
expect(canAttach(id('future-child'), id('w'), scene).ok).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('getSurface / getTopSurfaceHeight', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
nodeRegistry._reset();
|
|
129
|
+
});
|
|
130
|
+
test('returns null when host has no registered def', () => {
|
|
131
|
+
const node = makeNode('mystery', 'm');
|
|
132
|
+
expect(getSurface(node)).toBeNull();
|
|
133
|
+
expect(getTopSurfaceHeight(node)).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
test('returns surface config when declared', () => {
|
|
136
|
+
registerNode(makeDef('table', {
|
|
137
|
+
surfaces: { top: { height: 0.74 } },
|
|
138
|
+
}));
|
|
139
|
+
const t = makeNode('table', 't');
|
|
140
|
+
expect(getSurface(t)?.top?.height).toBe(0.74);
|
|
141
|
+
expect(getTopSurfaceHeight(t)).toBe(0.74);
|
|
142
|
+
});
|
|
143
|
+
test('evaluates function-valued height with the node', () => {
|
|
144
|
+
registerNode(makeDef('shelf', {
|
|
145
|
+
surfaces: {
|
|
146
|
+
top: {
|
|
147
|
+
height: (n) => (n.id === id('high') ? 1.8 : 0.3),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8);
|
|
152
|
+
expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('clampYToHostTop', () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
nodeRegistry._reset();
|
|
158
|
+
});
|
|
159
|
+
test('clamps to top when host has one', () => {
|
|
160
|
+
registerNode(makeDef('table', { surfaces: { top: { height: 0.74 } } }));
|
|
161
|
+
expect(clampYToHostTop(makeNode('table', 't'), 5)).toBe(0.74);
|
|
162
|
+
});
|
|
163
|
+
test('passes through when host has no top surface', () => {
|
|
164
|
+
registerNode(makeDef('plain'));
|
|
165
|
+
expect(clampYToHostTop(makeNode('plain', 'p'), 5)).toBe(5);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('pickHost', () => {
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
nodeRegistry._reset();
|
|
171
|
+
});
|
|
172
|
+
test('returns first candidate that has hostable capability', () => {
|
|
173
|
+
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }));
|
|
174
|
+
registerNode(makeDef('item')); // no hostable
|
|
175
|
+
const candidates = [makeNode('item', 'i'), makeNode('slab', 's')];
|
|
176
|
+
const picked = pickHost({
|
|
177
|
+
point: [0, 0, 0],
|
|
178
|
+
candidates,
|
|
179
|
+
placedKind: 'chair',
|
|
180
|
+
});
|
|
181
|
+
expect(picked?.id).toBe(id('s'));
|
|
182
|
+
});
|
|
183
|
+
test('returns null when nothing in candidates is hostable', () => {
|
|
184
|
+
registerNode(makeDef('item'));
|
|
185
|
+
const candidates = [makeNode('item', 'i')];
|
|
186
|
+
expect(pickHost({ point: [0, 0, 0], candidates, placedKind: 'chair' })).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
test('hitTest can reject hostable candidates', () => {
|
|
189
|
+
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }));
|
|
190
|
+
const candidates = [makeNode('slab', 's1'), makeNode('slab', 's2')];
|
|
191
|
+
const picked = pickHost({
|
|
192
|
+
point: [0, 0, 0],
|
|
193
|
+
candidates,
|
|
194
|
+
placedKind: 'chair',
|
|
195
|
+
hitTest: (host) => host.id === id('s2'),
|
|
196
|
+
});
|
|
197
|
+
expect(picked?.id).toBe(id('s2'));
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { type AlignmentAnchor, type AlignmentGuide, type AlignmentGuideAxis, type AnchorKind, type BuildingPose, bboxAnchors, bboxCornerAnchors, type ResolveAlignmentInBuildingResult, type ResolveAlignmentInput, type ResolveAlignmentResult, resolveAlignment, resolveAlignmentInBuildingWorld, } from './alignment';
|
|
2
|
+
export { collectAlignmentAnchors, type FootprintAABB, footprintAABB, footprintAABBAt, footprintAABBFrom, movingAlignmentAnchors, movingFootprintAnchors, nodeAlignmentAnchors, polygonAnchors, wallSegmentAnchors, } from './alignment-anchors';
|
|
3
|
+
export { createDragSession, type DragSession, type DragSessionInput, type DragSessionOptions, } from './drag-session';
|
|
4
|
+
export { type AttachError, type AttachResult, canAttach, clampYToHostTop, getSurface, getTopSurfaceHeight, MAX_HOST_DEPTH, pickHost, type Vec3, } from './hosting';
|
|
5
|
+
export { type AxisLock, applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable, } from './movement';
|
|
6
|
+
export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, type SnapServices, snapAngleToList, snapPointToAngle, snapPointToGrid, snapScalar, snapServices, snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/services/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,WAAW,EACX,iBAAiB,EACjB,KAAK,gCAAgC,EACrC,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,+BAA+B,GAChC,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,uBAAuB,EACvB,KAAK,aAAa,EAClB,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,EACtB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,GACnB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,SAAS,EACT,eAAe,EACf,UAAU,EACV,mBAAmB,EACnB,cAAc,EACd,QAAQ,EACR,KAAK,IAAI,GACV,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,KAAK,QAAQ,EACb,aAAa,EACb,SAAS,EACT,cAAc,EACd,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,KAAK,YAAY,EACjB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,UAAU,EACV,YAAY,EACZ,cAAc,EACd,0BAA0B,GAC3B,MAAM,QAAQ,CAAA"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { bboxAnchors, bboxCornerAnchors, resolveAlignment, resolveAlignmentInBuildingWorld, } from './alignment';
|
|
2
|
+
export { collectAlignmentAnchors, footprintAABB, footprintAABBAt, footprintAABBFrom, movingAlignmentAnchors, movingFootprintAnchors, nodeAlignmentAnchors, polygonAnchors, wallSegmentAnchors, } from './alignment-anchors';
|
|
3
|
+
export { createDragSession, } from './drag-session';
|
|
4
|
+
export { canAttach, clampYToHostTop, getSurface, getTopSurfaceHeight, MAX_HOST_DEPTH, pickHost, } from './hosting';
|
|
5
|
+
export { applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable, } from './movement';
|
|
6
|
+
export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, snapAngleToList, snapPointToAngle, snapPointToGrid, snapScalar, snapServices, snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MovableConfig } from '../registry/types';
|
|
2
|
+
import type { AnyNode } from '../schema/types';
|
|
3
|
+
import { type Vec3 } from './snap';
|
|
4
|
+
/**
|
|
5
|
+
* Pure movement constraint helpers. Given a node and a target position, apply
|
|
6
|
+
* the constraints declared in `def.capabilities.movable` (axis lock, grid
|
|
7
|
+
* snap, override callback) and return the constrained target.
|
|
8
|
+
*
|
|
9
|
+
* No scene access, no React, no Three.js — caller passes the node, this
|
|
10
|
+
* returns the math result.
|
|
11
|
+
*/
|
|
12
|
+
export type AxisLock = ReadonlyArray<'x' | 'y' | 'z'>;
|
|
13
|
+
/**
|
|
14
|
+
* Returns the MovableConfig effective for `node` after running its `override`
|
|
15
|
+
* callback if declared. Returns `null` if the node's def doesn't declare
|
|
16
|
+
* `movable` (i.e. the node is not movable).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveMovable(node: AnyNode): MovableConfig | null;
|
|
19
|
+
/**
|
|
20
|
+
* Projects a target X/Y/Z onto the axes a node is allowed to move on. Components
|
|
21
|
+
* outside the lock fall back to the node's current values, so caller-supplied
|
|
22
|
+
* positions can come from any 3D source without breaking axis-locked motion.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyAxisLock(current: Vec3, target: Vec3, axes: AxisLock): Vec3;
|
|
25
|
+
/**
|
|
26
|
+
* Top-level helper: takes a node and a desired position, returns the position
|
|
27
|
+
* filtered through the node's movable capability (axis lock + optional grid
|
|
28
|
+
* snap). Returns `null` when the node is not movable.
|
|
29
|
+
*/
|
|
30
|
+
export declare function moveToward(node: AnyNode, current: Vec3, target: Vec3, options?: {
|
|
31
|
+
gridStep?: number;
|
|
32
|
+
gridSnap?: boolean;
|
|
33
|
+
}): Vec3 | null;
|
|
34
|
+
/**
|
|
35
|
+
* 2D convenience: same as moveToward but for plan-view (X/Z) operations like
|
|
36
|
+
* floor placement. Returns a tuple in the X/Z plane so callers don't have to
|
|
37
|
+
* pack/unpack the dropped Y.
|
|
38
|
+
*/
|
|
39
|
+
export declare function movePlanToward(node: AnyNode, currentY: number, current: readonly [number, number], target: readonly [number, number], options?: {
|
|
40
|
+
gridStep?: number;
|
|
41
|
+
gridSnap?: boolean;
|
|
42
|
+
}): readonly [number, number] | null;
|
|
43
|
+
/**
|
|
44
|
+
* Returns true when a node's def declares it as movable on any axis.
|
|
45
|
+
* Quick predicate for tools/UI that gate on movability.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isMovable(node: AnyNode): boolean;
|
|
48
|
+
//# sourceMappingURL=movement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"movement.d.ts","sourceRoot":"","sources":["../../src/services/movement.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAC9C,OAAO,EAAkB,KAAK,IAAI,EAAE,MAAM,QAAQ,CAAA;AAElD;;;;;;;GAOG;AAEH,MAAM,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,CAAA;AAErD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CASlE;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAM/E;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,IAAI,EACb,MAAM,EAAE,IAAI,EACZ,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAO,GACtD,IAAI,GAAG,IAAI,CAYb;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,OAAO,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,EACjC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAO,GACtD,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CASlC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAGhD"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { nodeRegistry } from '../registry/registry';
|
|
2
|
+
import { snapVec3ToGrid } from './snap';
|
|
3
|
+
/**
|
|
4
|
+
* Returns the MovableConfig effective for `node` after running its `override`
|
|
5
|
+
* callback if declared. Returns `null` if the node's def doesn't declare
|
|
6
|
+
* `movable` (i.e. the node is not movable).
|
|
7
|
+
*/
|
|
8
|
+
export function resolveMovable(node) {
|
|
9
|
+
const def = nodeRegistry.get(node.type);
|
|
10
|
+
const base = def?.capabilities.movable;
|
|
11
|
+
if (!base)
|
|
12
|
+
return null;
|
|
13
|
+
if (base.override) {
|
|
14
|
+
const overridden = base.override({ node });
|
|
15
|
+
return overridden ?? base;
|
|
16
|
+
}
|
|
17
|
+
return base;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Projects a target X/Y/Z onto the axes a node is allowed to move on. Components
|
|
21
|
+
* outside the lock fall back to the node's current values, so caller-supplied
|
|
22
|
+
* positions can come from any 3D source without breaking axis-locked motion.
|
|
23
|
+
*/
|
|
24
|
+
export function applyAxisLock(current, target, axes) {
|
|
25
|
+
return [
|
|
26
|
+
axes.includes('x') ? target[0] : current[0],
|
|
27
|
+
axes.includes('y') ? target[1] : current[1],
|
|
28
|
+
axes.includes('z') ? target[2] : current[2],
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Top-level helper: takes a node and a desired position, returns the position
|
|
33
|
+
* filtered through the node's movable capability (axis lock + optional grid
|
|
34
|
+
* snap). Returns `null` when the node is not movable.
|
|
35
|
+
*/
|
|
36
|
+
export function moveToward(node, current, target, options = {}) {
|
|
37
|
+
const config = resolveMovable(node);
|
|
38
|
+
if (!config)
|
|
39
|
+
return null;
|
|
40
|
+
let next = applyAxisLock(current, target, config.axes);
|
|
41
|
+
const wantsGridSnap = options.gridSnap ?? config.gridSnap;
|
|
42
|
+
if (wantsGridSnap) {
|
|
43
|
+
next = snapVec3ToGrid(next, options.gridStep);
|
|
44
|
+
}
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 2D convenience: same as moveToward but for plan-view (X/Z) operations like
|
|
49
|
+
* floor placement. Returns a tuple in the X/Z plane so callers don't have to
|
|
50
|
+
* pack/unpack the dropped Y.
|
|
51
|
+
*/
|
|
52
|
+
export function movePlanToward(node, currentY, current, target, options = {}) {
|
|
53
|
+
const result = moveToward(node, [current[0], currentY, current[1]], [target[0], currentY, target[1]], options);
|
|
54
|
+
if (!result)
|
|
55
|
+
return null;
|
|
56
|
+
return [result[0], result[2]];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Returns true when a node's def declares it as movable on any axis.
|
|
60
|
+
* Quick predicate for tools/UI that gate on movability.
|
|
61
|
+
*/
|
|
62
|
+
export function isMovable(node) {
|
|
63
|
+
const config = resolveMovable(node);
|
|
64
|
+
return config != null && config.axes.length > 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"movement.test.d.ts","sourceRoot":"","sources":["../../src/services/movement.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { nodeRegistry, registerNode } from '../registry/registry';
|
|
4
|
+
import { applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable } from './movement';
|
|
5
|
+
const id = (s) => s;
|
|
6
|
+
function makeDef(kind, capabilities = {}) {
|
|
7
|
+
return {
|
|
8
|
+
kind,
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
11
|
+
category: 'utility',
|
|
12
|
+
defaults: () => ({}),
|
|
13
|
+
capabilities,
|
|
14
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function makeNode(kind, idStr) {
|
|
18
|
+
return {
|
|
19
|
+
id: id(idStr),
|
|
20
|
+
type: kind,
|
|
21
|
+
parentId: null,
|
|
22
|
+
visible: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe('resolveMovable', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
nodeRegistry._reset();
|
|
28
|
+
});
|
|
29
|
+
test('returns null when no def is registered', () => {
|
|
30
|
+
expect(resolveMovable(makeNode('mystery', 'm'))).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
test('returns null when def declares no movable capability', () => {
|
|
33
|
+
registerNode(makeDef('static'));
|
|
34
|
+
expect(resolveMovable(makeNode('static', 's'))).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
test('returns the declared config', () => {
|
|
37
|
+
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }));
|
|
38
|
+
const config = resolveMovable(makeNode('column', 'c'));
|
|
39
|
+
expect(config?.axes).toEqual(['x', 'z']);
|
|
40
|
+
expect(config?.gridSnap).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
test('runs override callback when present', () => {
|
|
43
|
+
let overrideRan = false;
|
|
44
|
+
const config = {
|
|
45
|
+
axes: ['x'],
|
|
46
|
+
override: () => {
|
|
47
|
+
overrideRan = true;
|
|
48
|
+
return { axes: ['y'] };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
registerNode(makeDef('weird', { movable: config }));
|
|
52
|
+
const resolved = resolveMovable(makeNode('weird', 'w'));
|
|
53
|
+
expect(overrideRan).toBe(true);
|
|
54
|
+
expect(resolved?.axes).toEqual(['y']);
|
|
55
|
+
});
|
|
56
|
+
test('override returning null falls back to base config', () => {
|
|
57
|
+
registerNode(makeDef('column', {
|
|
58
|
+
movable: { axes: ['x', 'z'], override: () => null },
|
|
59
|
+
}));
|
|
60
|
+
const resolved = resolveMovable(makeNode('column', 'c'));
|
|
61
|
+
expect(resolved?.axes).toEqual(['x', 'z']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('applyAxisLock', () => {
|
|
65
|
+
test('passes through unlocked axes only', () => {
|
|
66
|
+
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x'])).toEqual([10, 2, 3]);
|
|
67
|
+
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'z'])).toEqual([10, 2, 30]);
|
|
68
|
+
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'y', 'z'])).toEqual([10, 20, 30]);
|
|
69
|
+
});
|
|
70
|
+
test('empty lock returns current unchanged', () => {
|
|
71
|
+
expect(applyAxisLock([1, 2, 3], [10, 20, 30], [])).toEqual([1, 2, 3]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('moveToward', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
nodeRegistry._reset();
|
|
77
|
+
});
|
|
78
|
+
test('returns null when node is not movable', () => {
|
|
79
|
+
registerNode(makeDef('static'));
|
|
80
|
+
expect(moveToward(makeNode('static', 's'), [0, 0, 0], [1, 1, 1])).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
test('axis-locks and returns the constrained target', () => {
|
|
83
|
+
registerNode(makeDef('column', { movable: { axes: ['x', 'z'] } }));
|
|
84
|
+
expect(moveToward(makeNode('column', 'c'), [0, 0.5, 0], [1, 99, 2])).toEqual([1, 0.5, 2]);
|
|
85
|
+
});
|
|
86
|
+
test('applies grid snap when capability declares gridSnap: true', () => {
|
|
87
|
+
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }));
|
|
88
|
+
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
|
|
89
|
+
gridStep: 0.25,
|
|
90
|
+
});
|
|
91
|
+
expect(result).toEqual([0.25, 0, 0.5]);
|
|
92
|
+
});
|
|
93
|
+
test('grid snap can be overridden at call site', () => {
|
|
94
|
+
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }));
|
|
95
|
+
// Caller explicitly disables grid snap for this call
|
|
96
|
+
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
|
|
97
|
+
gridSnap: false,
|
|
98
|
+
});
|
|
99
|
+
expect(result).toEqual([0.3, 0, 0.6]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('movePlanToward', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
nodeRegistry._reset();
|
|
105
|
+
});
|
|
106
|
+
test('returns 2D point with X/Z constrained, Y dropped', () => {
|
|
107
|
+
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }));
|
|
108
|
+
const result = movePlanToward(makeNode('column', 'c'), 0.5, // currentY
|
|
109
|
+
[0, 0], [0.3, 0.6], { gridStep: 0.25 });
|
|
110
|
+
expect(result).toEqual([0.25, 0.5]);
|
|
111
|
+
});
|
|
112
|
+
test('returns null when node is not movable', () => {
|
|
113
|
+
registerNode(makeDef('static'));
|
|
114
|
+
expect(movePlanToward(makeNode('static', 's'), 0, [0, 0], [1, 1])).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('isMovable', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
nodeRegistry._reset();
|
|
120
|
+
});
|
|
121
|
+
test('true when movable.axes has entries', () => {
|
|
122
|
+
registerNode(makeDef('column', { movable: { axes: ['x'] } }));
|
|
123
|
+
expect(isMovable(makeNode('column', 'c'))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
test('false when no movable capability', () => {
|
|
126
|
+
registerNode(makeDef('static'));
|
|
127
|
+
expect(isMovable(makeNode('static', 's'))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
test('false when movable.axes is empty', () => {
|
|
130
|
+
registerNode(makeDef('locked', { movable: { axes: [] } }));
|
|
131
|
+
expect(isMovable(makeNode('locked', 'l'))).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-undo-dance.test.d.ts","sourceRoot":"","sources":["../../src/services/single-undo-dance.test.ts"],"names":[],"mappings":""}
|