@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 @@
|
|
|
1
|
+
{"version":3,"file":"alignment.test.d.ts","sourceRoot":"","sources":["../../src/services/alignment.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { bboxAnchors, resolveAlignment } from './alignment';
|
|
3
|
+
function center(nodeId, x, z) {
|
|
4
|
+
return { nodeId, kind: 'center', x, z };
|
|
5
|
+
}
|
|
6
|
+
function corner(nodeId, x, z) {
|
|
7
|
+
return { nodeId, kind: 'corner', x, z };
|
|
8
|
+
}
|
|
9
|
+
describe('resolveAlignment', () => {
|
|
10
|
+
test('returns empty when no candidates within threshold', () => {
|
|
11
|
+
const result = resolveAlignment({
|
|
12
|
+
moving: [center('m', 0, 0)],
|
|
13
|
+
candidates: [center('a', 1, 1)],
|
|
14
|
+
threshold: 0.5,
|
|
15
|
+
});
|
|
16
|
+
expect(result.guides).toEqual([]);
|
|
17
|
+
expect(result.snap).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
test('snaps moving anchor onto candidate X when within threshold', () => {
|
|
20
|
+
const result = resolveAlignment({
|
|
21
|
+
moving: [center('m', 0.03, 5)],
|
|
22
|
+
candidates: [center('a', 0, 2)],
|
|
23
|
+
threshold: 0.1,
|
|
24
|
+
});
|
|
25
|
+
expect(result.snap).toEqual({ dx: -0.03, dz: 0 });
|
|
26
|
+
expect(result.guides).toHaveLength(1);
|
|
27
|
+
expect(result.guides[0].axis).toBe('x');
|
|
28
|
+
expect(result.guides[0].coord).toBe(0);
|
|
29
|
+
expect(result.guides[0].from.z).toBe(2);
|
|
30
|
+
expect(result.guides[0].to.z).toBe(5);
|
|
31
|
+
});
|
|
32
|
+
test('snaps both axes when both match', () => {
|
|
33
|
+
const result = resolveAlignment({
|
|
34
|
+
moving: [center('m', 0.03, 0.04)],
|
|
35
|
+
candidates: [center('a', 0, 0)],
|
|
36
|
+
threshold: 0.1,
|
|
37
|
+
});
|
|
38
|
+
expect(result.snap).toEqual({ dx: -0.03, dz: -0.04 });
|
|
39
|
+
expect(result.guides).toHaveLength(2);
|
|
40
|
+
});
|
|
41
|
+
test('picks closest candidate per axis', () => {
|
|
42
|
+
const result = resolveAlignment({
|
|
43
|
+
moving: [center('m', 0.08, 0)],
|
|
44
|
+
candidates: [center('a', 0, 5), center('b', 0.1, 5), center('c', 0.05, 10)],
|
|
45
|
+
threshold: 0.1,
|
|
46
|
+
});
|
|
47
|
+
// |0.1 - 0.08| = 0.02 wins over |0.05 - 0.08| = 0.03 and |0 - 0.08| = 0.08
|
|
48
|
+
expect(result.snap?.dx).toBeCloseTo(0.02, 10);
|
|
49
|
+
expect(result.guides[0].candidateNodeId).toBe('b');
|
|
50
|
+
});
|
|
51
|
+
test('ties on the matched axis break toward the nearest perpendicular anchor', () => {
|
|
52
|
+
const result = resolveAlignment({
|
|
53
|
+
moving: [corner('m', 0.02, 4)],
|
|
54
|
+
candidates: [corner('far', 0, 0), corner('near', 0, 5)],
|
|
55
|
+
threshold: 0.1,
|
|
56
|
+
});
|
|
57
|
+
// Both share X (Δx = 0.02); 'near' (z=5) is closer to the moving z=4 than
|
|
58
|
+
// 'far' (z=0), so the guide connects to the nearest real anchor.
|
|
59
|
+
expect(result.guides[0].candidateNodeId).toBe('near');
|
|
60
|
+
});
|
|
61
|
+
test('threshold = 0 disables alignment', () => {
|
|
62
|
+
const result = resolveAlignment({
|
|
63
|
+
moving: [center('m', 0, 0)],
|
|
64
|
+
candidates: [center('a', 0, 0)],
|
|
65
|
+
threshold: 0,
|
|
66
|
+
});
|
|
67
|
+
expect(result.guides).toEqual([]);
|
|
68
|
+
expect(result.snap).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
test('distance is the perpendicular gap to the matched axis', () => {
|
|
71
|
+
const result = resolveAlignment({
|
|
72
|
+
moving: [center('m', 0.02, 3)],
|
|
73
|
+
candidates: [center('a', 0, 0)],
|
|
74
|
+
threshold: 0.1,
|
|
75
|
+
});
|
|
76
|
+
// After snap: moving at (0, 3). X guide runs along x=0 from z=0 to z=3.
|
|
77
|
+
expect(result.guides[0].distance).toBeCloseTo(3, 10);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('bboxAnchors', () => {
|
|
81
|
+
test('returns 9 anchors with correct kinds and positions', () => {
|
|
82
|
+
const anchors = bboxAnchors('node', 0, 0, 2, 4);
|
|
83
|
+
expect(anchors).toHaveLength(9);
|
|
84
|
+
const corners = anchors.filter((a) => a.kind === 'corner');
|
|
85
|
+
const edges = anchors.filter((a) => a.kind === 'edge-mid');
|
|
86
|
+
const centers = anchors.filter((a) => a.kind === 'center');
|
|
87
|
+
expect(corners).toHaveLength(4);
|
|
88
|
+
expect(edges).toHaveLength(4);
|
|
89
|
+
expect(centers).toHaveLength(1);
|
|
90
|
+
expect(centers[0]).toEqual({ nodeId: 'node', kind: 'center', x: 1, z: 2 });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ChildQuery, type SpatialQuery } from '../registry/relations-resolver';
|
|
2
|
+
import type { DragAction, Modifiers, SceneApi } from '../registry/types';
|
|
3
|
+
import type { AnyNode } from '../schema/types';
|
|
4
|
+
import type { Vec2 } from './snap';
|
|
5
|
+
/**
|
|
6
|
+
* Pure orchestrator for a single `DragAction` lifecycle:
|
|
7
|
+
* begin → (preview → snap? → apply → cascade dirty)* → commit | cancel
|
|
8
|
+
*
|
|
9
|
+
* Bracketed by `pauseHistory()` / `resumeHistory()` so the entire drag is one
|
|
10
|
+
* undo step. The React hook (`useDragAction` in `@pascal-app/editor`) wraps
|
|
11
|
+
* this with event subscriptions; tests drive it directly.
|
|
12
|
+
*/
|
|
13
|
+
export type DragSessionInput = {
|
|
14
|
+
node?: AnyNode;
|
|
15
|
+
point: Vec2;
|
|
16
|
+
handleId?: string;
|
|
17
|
+
modifiers?: Modifiers;
|
|
18
|
+
};
|
|
19
|
+
export type DragSessionOptions = {
|
|
20
|
+
spatialQuery?: SpatialQuery;
|
|
21
|
+
childQuery?: ChildQuery;
|
|
22
|
+
/** Called once the session terminates via `commit()`. */
|
|
23
|
+
onCommit?: () => void;
|
|
24
|
+
/** Called once the session terminates via `cancel()` or `dispose()`. */
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
};
|
|
27
|
+
export type DragSession<Ctx, Draft> = {
|
|
28
|
+
/** Begin the drag — pause history, capture ctx via `action.begin`. */
|
|
29
|
+
start: (input: DragSessionInput) => void;
|
|
30
|
+
/** Per-pointer-move tick — run preview/snap/apply and cascade dirty marks. */
|
|
31
|
+
move: (point: Vec2, modifiers: Modifiers) => void;
|
|
32
|
+
/** Pointer-up / discrete commit. Returns true if `action.commit` agreed. */
|
|
33
|
+
commit: () => boolean;
|
|
34
|
+
/** Pointer-cancel / Esc / external abort — restores all touched nodes. */
|
|
35
|
+
cancel: () => void;
|
|
36
|
+
/** Returns the latest draft `apply` produced (or null before first move). */
|
|
37
|
+
getDraft: () => Draft | null;
|
|
38
|
+
isActive: () => boolean;
|
|
39
|
+
/** Idempotent cleanup. If active, restores scene state and resumes
|
|
40
|
+
* history, but does **not** fire `onCancel`. Use for React-effect
|
|
41
|
+
* teardown — onCancel would re-trigger the parent's state machine and
|
|
42
|
+
* break StrictMode's double-mount cycle. Esc / external aborts must
|
|
43
|
+
* still call `cancel()` directly. */
|
|
44
|
+
dispose: () => void;
|
|
45
|
+
};
|
|
46
|
+
export declare function createDragSession<Ctx, Draft>(action: DragAction<Ctx, Draft>, scene: SceneApi, options?: DragSessionOptions): DragSession<Ctx, Draft>;
|
|
47
|
+
//# sourceMappingURL=drag-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drag-session.d.ts","sourceRoot":"","sources":["../../src/services/drag-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAAgB,KAAK,YAAY,EAAE,MAAM,gCAAgC,CAAA;AACjG,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AACxE,OAAO,KAAK,EAAE,OAAO,EAAa,MAAM,iBAAiB,CAAA;AACzD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAElC;;;;;;;GAOG;AAEH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,IAAI,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,WAAW,CAAC,GAAG,EAAE,KAAK,IAAI;IACpC,sEAAsE;IACtE,KAAK,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAA;IACxC,8EAA8E;IAC9E,IAAI,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACjD,4EAA4E;IAC5E,MAAM,EAAE,MAAM,OAAO,CAAA;IACrB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,KAAK,GAAG,IAAI,CAAA;IAC5B,QAAQ,EAAE,MAAM,OAAO,CAAA;IACvB;;;;yCAIqC;IACrC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAID,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,KAAK,EAC1C,MAAM,EAAE,UAAU,CAAC,GAAG,EAAE,KAAK,CAAC,EAC9B,KAAK,EAAE,QAAQ,EACf,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAiGzB"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { cascadeDirty } from '../registry/relations-resolver';
|
|
2
|
+
const EMPTY_MODIFIERS = { shift: false, alt: false, ctrl: false, meta: false };
|
|
3
|
+
export function createDragSession(action, scene, options = {}) {
|
|
4
|
+
let active = false;
|
|
5
|
+
let ctx = null;
|
|
6
|
+
let draft = null;
|
|
7
|
+
let dirtyMarked = new Set();
|
|
8
|
+
function markWithCascade(id) {
|
|
9
|
+
if (dirtyMarked.has(id))
|
|
10
|
+
return;
|
|
11
|
+
const ids = cascadeDirty(id, {
|
|
12
|
+
scene,
|
|
13
|
+
spatialQuery: options.spatialQuery,
|
|
14
|
+
childQuery: options.childQuery,
|
|
15
|
+
});
|
|
16
|
+
for (const dirtyId of ids) {
|
|
17
|
+
if (!dirtyMarked.has(dirtyId)) {
|
|
18
|
+
scene.markDirty(dirtyId);
|
|
19
|
+
dirtyMarked.add(dirtyId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function terminate(committed) {
|
|
24
|
+
if (!active)
|
|
25
|
+
return;
|
|
26
|
+
active = false;
|
|
27
|
+
ctx = null;
|
|
28
|
+
draft = null;
|
|
29
|
+
dirtyMarked = new Set();
|
|
30
|
+
scene.resumeHistory();
|
|
31
|
+
if (committed)
|
|
32
|
+
options.onCommit?.();
|
|
33
|
+
else
|
|
34
|
+
options.onCancel?.();
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
start(input) {
|
|
38
|
+
if (active)
|
|
39
|
+
return; // ignore re-entry
|
|
40
|
+
scene.pauseHistory();
|
|
41
|
+
ctx = action.begin({
|
|
42
|
+
node: input.node,
|
|
43
|
+
point: input.point,
|
|
44
|
+
handleId: input.handleId,
|
|
45
|
+
modifiers: input.modifiers ?? EMPTY_MODIFIERS,
|
|
46
|
+
});
|
|
47
|
+
active = true;
|
|
48
|
+
},
|
|
49
|
+
move(point, modifiers) {
|
|
50
|
+
if (!active || ctx == null)
|
|
51
|
+
return;
|
|
52
|
+
let next = action.preview(ctx, point, modifiers);
|
|
53
|
+
if (action.snap) {
|
|
54
|
+
next = action.snap(next, ctx, undefined);
|
|
55
|
+
}
|
|
56
|
+
draft = next;
|
|
57
|
+
const dirtyIds = action.apply(next, ctx, scene);
|
|
58
|
+
for (const id of dirtyIds)
|
|
59
|
+
markWithCascade(id);
|
|
60
|
+
},
|
|
61
|
+
commit() {
|
|
62
|
+
if (!active || ctx == null)
|
|
63
|
+
return false;
|
|
64
|
+
const ok = action.commit?.(draft, ctx, scene) ?? true;
|
|
65
|
+
if (!ok) {
|
|
66
|
+
action.cancel(ctx, scene);
|
|
67
|
+
scene.restoreAll();
|
|
68
|
+
}
|
|
69
|
+
terminate(ok);
|
|
70
|
+
return ok;
|
|
71
|
+
},
|
|
72
|
+
cancel() {
|
|
73
|
+
if (!active || ctx == null)
|
|
74
|
+
return;
|
|
75
|
+
action.cancel(ctx, scene);
|
|
76
|
+
scene.restoreAll();
|
|
77
|
+
terminate(false);
|
|
78
|
+
},
|
|
79
|
+
getDraft() {
|
|
80
|
+
return draft;
|
|
81
|
+
},
|
|
82
|
+
isActive() {
|
|
83
|
+
return active;
|
|
84
|
+
},
|
|
85
|
+
dispose() {
|
|
86
|
+
if (active && ctx != null) {
|
|
87
|
+
action.cancel(ctx, scene);
|
|
88
|
+
scene.restoreAll();
|
|
89
|
+
// Silent terminate: no onCancel. The caller (e.g. useDragAction's
|
|
90
|
+
// effect cleanup) is reacting to the parent unmounting and would
|
|
91
|
+
// loop the state machine if onCancel re-set the parent's state.
|
|
92
|
+
active = false;
|
|
93
|
+
ctx = null;
|
|
94
|
+
draft = null;
|
|
95
|
+
dirtyMarked = new Set();
|
|
96
|
+
scene.resumeHistory();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drag-session.test.d.ts","sourceRoot":"","sources":["../../src/services/drag-session.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { nodeRegistry, registerNode } from '../registry/registry';
|
|
4
|
+
import { createDragSession } from './drag-session';
|
|
5
|
+
const id = (s) => s;
|
|
6
|
+
function makeSpyScene(initial = {}) {
|
|
7
|
+
const calls = {
|
|
8
|
+
pauseHistory: 0,
|
|
9
|
+
resumeHistory: 0,
|
|
10
|
+
restoreAll: 0,
|
|
11
|
+
markedDirty: [],
|
|
12
|
+
updated: [],
|
|
13
|
+
};
|
|
14
|
+
const nodes = { ...initial };
|
|
15
|
+
return {
|
|
16
|
+
get: ((nid) => nodes[nid]),
|
|
17
|
+
nodes: () => nodes,
|
|
18
|
+
update: (nid, patch) => {
|
|
19
|
+
calls.updated.push([nid, patch]);
|
|
20
|
+
const existing = nodes[nid];
|
|
21
|
+
if (existing)
|
|
22
|
+
nodes[nid] = { ...existing, ...patch };
|
|
23
|
+
},
|
|
24
|
+
upsert: (n) => {
|
|
25
|
+
nodes[n.id] = n;
|
|
26
|
+
return n.id;
|
|
27
|
+
},
|
|
28
|
+
delete: (nid) => {
|
|
29
|
+
delete nodes[nid];
|
|
30
|
+
},
|
|
31
|
+
restore: () => { },
|
|
32
|
+
restoreAll: () => {
|
|
33
|
+
calls.restoreAll += 1;
|
|
34
|
+
},
|
|
35
|
+
markDirty: (nid) => {
|
|
36
|
+
calls.markedDirty.push(nid);
|
|
37
|
+
},
|
|
38
|
+
pauseHistory: () => {
|
|
39
|
+
calls.pauseHistory += 1;
|
|
40
|
+
},
|
|
41
|
+
resumeHistory: () => {
|
|
42
|
+
calls.resumeHistory += 1;
|
|
43
|
+
},
|
|
44
|
+
getSubtree: () => null,
|
|
45
|
+
cloneNodesInto: () => null,
|
|
46
|
+
_calls: calls,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function makeDef(kind, relations) {
|
|
50
|
+
return {
|
|
51
|
+
kind,
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
54
|
+
category: 'utility',
|
|
55
|
+
defaults: () => ({}),
|
|
56
|
+
capabilities: {},
|
|
57
|
+
relations,
|
|
58
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function makeAction() {
|
|
62
|
+
return {
|
|
63
|
+
begin: ({ node }) => ({ id: node?.id ?? id('default') }),
|
|
64
|
+
preview: (_ctx, point) => ({ x: point[0] }),
|
|
65
|
+
apply: (_draft, ctx) => [ctx.id],
|
|
66
|
+
cancel: () => { },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
describe('createDragSession', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
nodeRegistry._reset();
|
|
72
|
+
});
|
|
73
|
+
test('start pauses history; commit resumes', () => {
|
|
74
|
+
const scene = makeSpyScene();
|
|
75
|
+
const session = createDragSession(makeAction(), scene);
|
|
76
|
+
session.start({ point: [0, 0] });
|
|
77
|
+
expect(scene._calls.pauseHistory).toBe(1);
|
|
78
|
+
expect(scene._calls.resumeHistory).toBe(0);
|
|
79
|
+
session.commit();
|
|
80
|
+
expect(scene._calls.resumeHistory).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
test('cancel resumes history and calls restoreAll', () => {
|
|
83
|
+
const scene = makeSpyScene();
|
|
84
|
+
const session = createDragSession(makeAction(), scene);
|
|
85
|
+
session.start({ point: [0, 0] });
|
|
86
|
+
session.cancel();
|
|
87
|
+
expect(scene._calls.resumeHistory).toBe(1);
|
|
88
|
+
expect(scene._calls.restoreAll).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
test('move runs preview + apply and marks the returned id dirty', () => {
|
|
91
|
+
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } });
|
|
92
|
+
const session = createDragSession(makeAction(), scene);
|
|
93
|
+
session.start({ point: [0, 0], node: { id: id('a') } });
|
|
94
|
+
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
95
|
+
expect(session.getDraft()).toEqual({ x: 1 });
|
|
96
|
+
expect(scene._calls.markedDirty).toContain(id('a'));
|
|
97
|
+
});
|
|
98
|
+
test('snap callback is invoked when defined', () => {
|
|
99
|
+
const action = {
|
|
100
|
+
...makeAction(),
|
|
101
|
+
snap: (draft) => ({ x: Math.round(draft.x) }),
|
|
102
|
+
};
|
|
103
|
+
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } });
|
|
104
|
+
const session = createDragSession(action, scene);
|
|
105
|
+
session.start({ point: [0, 0], node: { id: id('a') } });
|
|
106
|
+
session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
107
|
+
expect(session.getDraft()).toEqual({ x: 1 });
|
|
108
|
+
});
|
|
109
|
+
test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => {
|
|
110
|
+
const cancelSpy = mock(() => { });
|
|
111
|
+
const action = {
|
|
112
|
+
...makeAction(),
|
|
113
|
+
cancel: cancelSpy,
|
|
114
|
+
commit: () => false,
|
|
115
|
+
};
|
|
116
|
+
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } });
|
|
117
|
+
const session = createDragSession(action, scene);
|
|
118
|
+
session.start({ point: [0, 0], node: { id: id('a') } });
|
|
119
|
+
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
120
|
+
const result = session.commit();
|
|
121
|
+
expect(result).toBe(false);
|
|
122
|
+
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(scene._calls.restoreAll).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
test('move is a no-op when session is not active', () => {
|
|
126
|
+
const scene = makeSpyScene();
|
|
127
|
+
const session = createDragSession(makeAction(), scene);
|
|
128
|
+
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
129
|
+
expect(scene._calls.markedDirty.length).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
test('repeated start is a no-op (re-entry guard)', () => {
|
|
132
|
+
const scene = makeSpyScene();
|
|
133
|
+
const session = createDragSession(makeAction(), scene);
|
|
134
|
+
session.start({ point: [0, 0] });
|
|
135
|
+
session.start({ point: [99, 99] });
|
|
136
|
+
expect(scene._calls.pauseHistory).toBe(1); // only one pause
|
|
137
|
+
});
|
|
138
|
+
test('dispose mid-drag cancels and cleans up', () => {
|
|
139
|
+
const scene = makeSpyScene();
|
|
140
|
+
const session = createDragSession(makeAction(), scene);
|
|
141
|
+
session.start({ point: [0, 0] });
|
|
142
|
+
expect(session.isActive()).toBe(true);
|
|
143
|
+
session.dispose();
|
|
144
|
+
expect(session.isActive()).toBe(false);
|
|
145
|
+
expect(scene._calls.resumeHistory).toBe(1);
|
|
146
|
+
expect(scene._calls.restoreAll).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
test('dispose when inactive is a no-op', () => {
|
|
149
|
+
const scene = makeSpyScene();
|
|
150
|
+
const session = createDragSession(makeAction(), scene);
|
|
151
|
+
session.dispose();
|
|
152
|
+
expect(scene._calls.pauseHistory).toBe(0);
|
|
153
|
+
expect(scene._calls.resumeHistory).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
test('onCommit callback fires on successful commit', () => {
|
|
156
|
+
const onCommit = mock(() => { });
|
|
157
|
+
const onCancel = mock(() => { });
|
|
158
|
+
const scene = makeSpyScene();
|
|
159
|
+
const session = createDragSession(makeAction(), scene, { onCommit, onCancel });
|
|
160
|
+
session.start({ point: [0, 0] });
|
|
161
|
+
session.commit();
|
|
162
|
+
expect(onCommit).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(onCancel).toHaveBeenCalledTimes(0);
|
|
164
|
+
});
|
|
165
|
+
test('onCancel callback fires on explicit cancel', () => {
|
|
166
|
+
const onCommit = mock(() => { });
|
|
167
|
+
const onCancel = mock(() => { });
|
|
168
|
+
const scene = makeSpyScene();
|
|
169
|
+
const session = createDragSession(makeAction(), scene, { onCommit, onCancel });
|
|
170
|
+
session.start({ point: [0, 0] });
|
|
171
|
+
session.cancel();
|
|
172
|
+
expect(onCommit).toHaveBeenCalledTimes(0);
|
|
173
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
test('dispose does NOT fire onCancel (silent cleanup)', () => {
|
|
176
|
+
const onCommit = mock(() => { });
|
|
177
|
+
const onCancel = mock(() => { });
|
|
178
|
+
const scene = makeSpyScene();
|
|
179
|
+
const session = createDragSession(makeAction(), scene, { onCommit, onCancel });
|
|
180
|
+
session.start({ point: [0, 0] });
|
|
181
|
+
session.dispose();
|
|
182
|
+
expect(onCommit).toHaveBeenCalledTimes(0);
|
|
183
|
+
expect(onCancel).toHaveBeenCalledTimes(0);
|
|
184
|
+
expect(scene._calls.resumeHistory).toBe(1);
|
|
185
|
+
expect(scene._calls.restoreAll).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
test('dirty cascade fires once per id even across multiple move ticks', () => {
|
|
188
|
+
// Register a kind with no relations — cascade returns just {startId}.
|
|
189
|
+
registerNode(makeDef('thing'));
|
|
190
|
+
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } });
|
|
191
|
+
const session = createDragSession(makeAction(), scene);
|
|
192
|
+
session.start({ point: [0, 0], node: { id: id('a') } });
|
|
193
|
+
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
194
|
+
session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
195
|
+
session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
196
|
+
// a is marked once, not three times
|
|
197
|
+
expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
test('dirty cascade follows hosts relations from the registry', () => {
|
|
200
|
+
registerNode(makeDef('wall', { hosts: ['door'] }));
|
|
201
|
+
registerNode(makeDef('door'));
|
|
202
|
+
const scene = makeSpyScene({
|
|
203
|
+
w: { id: id('w'), type: 'wall', children: [id('d')] },
|
|
204
|
+
d: { id: id('d'), type: 'door', parentId: id('w') },
|
|
205
|
+
});
|
|
206
|
+
const action = {
|
|
207
|
+
begin: () => ({ id: id('w') }),
|
|
208
|
+
preview: (_ctx, point) => ({ x: point[0] }),
|
|
209
|
+
apply: (_draft, ctx) => [ctx.id],
|
|
210
|
+
cancel: () => { },
|
|
211
|
+
};
|
|
212
|
+
const session = createDragSession(action, scene);
|
|
213
|
+
session.start({ point: [0, 0], node: { id: id('w') } });
|
|
214
|
+
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false });
|
|
215
|
+
// both wall and door marked dirty
|
|
216
|
+
expect(scene._calls.markedDirty).toContain(id('w'));
|
|
217
|
+
expect(scene._calls.markedDirty).toContain(id('d'));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SceneApi, SurfacesConfig } from '../registry/types';
|
|
2
|
+
import type { AnyNode, AnyNodeId } from '../schema/types';
|
|
3
|
+
/**
|
|
4
|
+
* Maximum depth a node tree can host. Guards items-on-items-on-items chains
|
|
5
|
+
* from growing pathological — pre-Phase-1 the editor had no cap. Set high
|
|
6
|
+
* enough to allow legitimate stacking (chair on platform on truck on floor)
|
|
7
|
+
* while preventing AI/plugin-generated runaway.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_HOST_DEPTH = 6;
|
|
10
|
+
export type Vec3 = readonly [number, number, number];
|
|
11
|
+
export type AttachError = {
|
|
12
|
+
kind: 'self-host';
|
|
13
|
+
nodeId: AnyNodeId;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'cycle';
|
|
16
|
+
nodeId: AnyNodeId;
|
|
17
|
+
hostId: AnyNodeId;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'depth-exceeded';
|
|
20
|
+
depth: number;
|
|
21
|
+
max: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'host-missing';
|
|
24
|
+
hostId: AnyNodeId;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'kind-not-allowed';
|
|
27
|
+
hostKind: string;
|
|
28
|
+
allowed: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
export type AttachResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: AttachError;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Validates that attaching `child` to `host` is safe and either returns an
|
|
38
|
+
* actionable error or signals OK. Does NOT mutate the scene — callers apply
|
|
39
|
+
* the patch after a successful check.
|
|
40
|
+
*
|
|
41
|
+
* Rules:
|
|
42
|
+
* - A node cannot host itself.
|
|
43
|
+
* - The hosting chain (child → host → host.parent → ...) must not contain
|
|
44
|
+
* `child` (cycle prevention).
|
|
45
|
+
* - The resulting chain must not exceed {@link MAX_HOST_DEPTH}.
|
|
46
|
+
* - If the child's NodeDefinition declares `capabilities.hostable.parents`,
|
|
47
|
+
* `host.type` must appear in that list.
|
|
48
|
+
*/
|
|
49
|
+
export declare function canAttach(childId: AnyNodeId, hostId: AnyNodeId, scene: SceneApi): AttachResult;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the surfaces declared by a host's NodeDefinition. Surfaces describe
|
|
52
|
+
* where other nodes can stack/mount — the `top` of a slab, the `sides` of a
|
|
53
|
+
* wall, or a custom callback. Returns null when the host's def declares no
|
|
54
|
+
* surfaces (or no def is registered).
|
|
55
|
+
*/
|
|
56
|
+
export declare function getSurface(host: AnyNode): SurfacesConfig | null;
|
|
57
|
+
/**
|
|
58
|
+
* Resolves the stackable top height of a host (e.g. table surface, slab top,
|
|
59
|
+
* stair landing). Returns `null` when the host has no `surfaces.top`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function getTopSurfaceHeight(host: AnyNode): number | null;
|
|
62
|
+
/**
|
|
63
|
+
* Pure host-discovery helper. Given a list of candidate hosts (already
|
|
64
|
+
* narrowed by spatial query) and a point, returns the first whose
|
|
65
|
+
* `capabilities.hostable` lists `placedKind` AND whose surface contains the
|
|
66
|
+
* point. The runtime is responsible for providing pre-filtered candidates;
|
|
67
|
+
* this function does not perform spatial queries itself.
|
|
68
|
+
*/
|
|
69
|
+
export declare function pickHost(args: {
|
|
70
|
+
point: Vec3;
|
|
71
|
+
candidates: readonly AnyNode[];
|
|
72
|
+
placedKind: string;
|
|
73
|
+
hitTest?: (host: AnyNode, point: Vec3) => boolean;
|
|
74
|
+
}): AnyNode | null;
|
|
75
|
+
/**
|
|
76
|
+
* Convenience: clamps a Y coordinate to the top of a host surface, when one
|
|
77
|
+
* is declared. Returns the original Y if the host has no top surface.
|
|
78
|
+
*/
|
|
79
|
+
export declare function clampYToHostTop(host: AnyNode, originalY: number): number;
|
|
80
|
+
//# sourceMappingURL=hosting.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../../src/services/hosting.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEzD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,IAAI,CAAA;AAE/B,MAAM,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;AAEpD,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAA;AAE9E,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAA;AAE3E;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,GAAG,YAAY,CAoC9F;AAoBD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,cAAc,GAAG,IAAI,CAG/D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAKhE;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE;IAC7B,KAAK,EAAE,IAAI,CAAA;IACX,UAAU,EAAE,SAAS,OAAO,EAAE,CAAA;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,KAAK,OAAO,CAAA;CAClD,GAAG,OAAO,GAAG,IAAI,CAajB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAGxE"}
|