@flighthq/scene3d 0.2.1-next.840.857425c
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/billboard.d.ts +8 -0
- package/dist/billboard.d.ts.map +1 -0
- package/dist/billboard.js +41 -0
- package/dist/billboard.js.map +1 -0
- package/dist/billboardCamera.d.ts +4 -0
- package/dist/billboardCamera.d.ts.map +1 -0
- package/dist/billboardCamera.js +238 -0
- package/dist/billboardCamera.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/mesh.d.ts +10 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +75 -0
- package/dist/mesh.js.map +1 -0
- package/dist/prepareScene3DMorph.d.ts +3 -0
- package/dist/prepareScene3DMorph.d.ts.map +1 -0
- package/dist/prepareScene3DMorph.js +34 -0
- package/dist/prepareScene3DMorph.js.map +1 -0
- package/dist/scene.d.ts +3 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +12 -0
- package/dist/scene.js.map +1 -0
- package/dist/sceneAnimation.d.ts +3 -0
- package/dist/sceneAnimation.d.ts.map +1 -0
- package/dist/sceneAnimation.js +43 -0
- package/dist/sceneAnimation.js.map +1 -0
- package/dist/sceneDocument.d.ts +4 -0
- package/dist/sceneDocument.d.ts.map +1 -0
- package/dist/sceneDocument.js +163 -0
- package/dist/sceneDocument.js.map +1 -0
- package/dist/sceneMaterial.d.ts +4 -0
- package/dist/sceneMaterial.d.ts.map +1 -0
- package/dist/sceneMaterial.js +58 -0
- package/dist/sceneMaterial.js.map +1 -0
- package/dist/sceneNode.d.ts +8 -0
- package/dist/sceneNode.d.ts.map +1 -0
- package/dist/sceneNode.js +29 -0
- package/dist/sceneNode.js.map +1 -0
- package/dist/sceneNodeAppearance.d.ts +5 -0
- package/dist/sceneNodeAppearance.d.ts.map +1 -0
- package/dist/sceneNodeAppearance.js +50 -0
- package/dist/sceneNodeAppearance.js.map +1 -0
- package/dist/sceneNodeBounds.d.ts +3 -0
- package/dist/sceneNodeBounds.d.ts.map +1 -0
- package/dist/sceneNodeBounds.js +49 -0
- package/dist/sceneNodeBounds.js.map +1 -0
- package/dist/sceneNodeCulling.d.ts +4 -0
- package/dist/sceneNodeCulling.d.ts.map +1 -0
- package/dist/sceneNodeCulling.js +62 -0
- package/dist/sceneNodeCulling.js.map +1 -0
- package/dist/sceneNodeDispose.d.ts +3 -0
- package/dist/sceneNodeDispose.d.ts.map +1 -0
- package/dist/sceneNodeDispose.js +15 -0
- package/dist/sceneNodeDispose.js.map +1 -0
- package/dist/sceneNodeTransform.d.ts +3 -0
- package/dist/sceneNodeTransform.d.ts.map +1 -0
- package/dist/sceneNodeTransform.js +65 -0
- package/dist/sceneNodeTransform.js.map +1 -0
- package/package.json +49 -0
- package/src/billboard.test.ts +79 -0
- package/src/billboardCamera.test.ts +152 -0
- package/src/mesh.test.ts +247 -0
- package/src/prepareScene3DMorph.test.ts +68 -0
- package/src/scene.test.ts +53 -0
- package/src/sceneAnimation.test.ts +109 -0
- package/src/sceneDocument.test.ts +173 -0
- package/src/sceneMaterial.test.ts +95 -0
- package/src/sceneNode.test.ts +291 -0
- package/src/sceneNodeAppearance.test.ts +80 -0
- package/src/sceneNodeBounds.test.ts +86 -0
- package/src/sceneNodeCulling.test.ts +123 -0
- package/src/sceneNodeDispose.test.ts +38 -0
- package/src/sceneNodeTransform.test.ts +40 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { addNodeChild, invalidateNodeAppearance } from '@flighthq/node';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createNode3D } from './sceneNode';
|
|
5
|
+
import { ensureNode3DWorldAlpha, getNode3DWorldAlpha, setNode3DAlpha } from './sceneNodeAppearance';
|
|
6
|
+
|
|
7
|
+
describe('ensureNode3DWorldAlpha', () => {
|
|
8
|
+
it('caches until the appearance is invalidated (revision-gated, not recomputed every read)', () => {
|
|
9
|
+
const node = createNode3D();
|
|
10
|
+
setNode3DAlpha(node, 0.5);
|
|
11
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(0.5);
|
|
12
|
+
|
|
13
|
+
// Raw field write bypassing the invalidation contract: the cache must hold.
|
|
14
|
+
node.alpha = 0.9;
|
|
15
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(0.5);
|
|
16
|
+
|
|
17
|
+
// Explicit invalidation recomputes.
|
|
18
|
+
invalidateNodeAppearance(node);
|
|
19
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(0.9);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('propagates a parent alpha change down to a resolved child', () => {
|
|
23
|
+
const parent = createNode3D();
|
|
24
|
+
const child = createNode3D();
|
|
25
|
+
addNodeChild(parent, child);
|
|
26
|
+
setNode3DAlpha(parent, 0.5);
|
|
27
|
+
setNode3DAlpha(child, 0.5);
|
|
28
|
+
expect(getNode3DWorldAlpha(child)).toBeCloseTo(0.25);
|
|
29
|
+
|
|
30
|
+
setNode3DAlpha(parent, 1);
|
|
31
|
+
expect(getNode3DWorldAlpha(child)).toBeCloseTo(0.5);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('propagates a grandparent alpha change through an unchanged middle node to a grandchild', () => {
|
|
35
|
+
const root = createNode3D();
|
|
36
|
+
const mid = createNode3D();
|
|
37
|
+
const leaf = createNode3D();
|
|
38
|
+
addNodeChild(root, mid);
|
|
39
|
+
addNodeChild(mid, leaf);
|
|
40
|
+
setNode3DAlpha(leaf, 0.5);
|
|
41
|
+
// Prime the grandchild's resolved worldAlpha (1 · 1 · 0.5), then fade only the grandparent.
|
|
42
|
+
expect(getNode3DWorldAlpha(leaf)).toBeCloseTo(0.5);
|
|
43
|
+
setNode3DAlpha(root, 0.2);
|
|
44
|
+
// The middle node's own alpha is unchanged, but its resolved alpha drops — the grandchild must follow.
|
|
45
|
+
expect(getNode3DWorldAlpha(leaf)).toBeCloseTo(0.1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getNode3DWorldAlpha', () => {
|
|
50
|
+
it('resolves to 1 for a fresh node', () => {
|
|
51
|
+
expect(getNode3DWorldAlpha(createNode3D())).toBeCloseTo(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('combines parent and self opacity', () => {
|
|
55
|
+
const parent = createNode3D();
|
|
56
|
+
const child = createNode3D();
|
|
57
|
+
addNodeChild(parent, child);
|
|
58
|
+
setNode3DAlpha(parent, 0.5);
|
|
59
|
+
setNode3DAlpha(child, 0.4);
|
|
60
|
+
expect(getNode3DWorldAlpha(child)).toBeCloseTo(0.2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('is correct on demand without a render walk', () => {
|
|
64
|
+
const node = createNode3D();
|
|
65
|
+
setNode3DAlpha(node, 0.6);
|
|
66
|
+
// No prepareScene3DRender — ensure-on-access must still resolve it.
|
|
67
|
+
ensureNode3DWorldAlpha(node);
|
|
68
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(0.6);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('setNode3DAlpha', () => {
|
|
73
|
+
it('sets the node opacity and invalidates so worldAlpha recomputes', () => {
|
|
74
|
+
const node = createNode3D();
|
|
75
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(1);
|
|
76
|
+
setNode3DAlpha(node, 0.3);
|
|
77
|
+
expect(node.alpha).toBeCloseTo(0.3);
|
|
78
|
+
expect(getNode3DWorldAlpha(node)).toBeCloseTo(0.3);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createAabb, setVector3 } from '@flighthq/geometry';
|
|
2
|
+
import { createBoxMeshGeometry } from '@flighthq/mesh';
|
|
3
|
+
import { addNodeChild, invalidateNodeLocalTransform } from '@flighthq/node';
|
|
4
|
+
|
|
5
|
+
import { createMesh } from './mesh';
|
|
6
|
+
import { createNode3D } from './sceneNode';
|
|
7
|
+
import { getNode3DWorldBounds } from './sceneNodeBounds';
|
|
8
|
+
|
|
9
|
+
describe('getNode3DWorldBounds', () => {
|
|
10
|
+
it('sets an empty box when the node has no Mesh geometry', () => {
|
|
11
|
+
const node = createNode3D();
|
|
12
|
+
const out = createAabb();
|
|
13
|
+
getNode3DWorldBounds(out, node);
|
|
14
|
+
expect(out.min.x).toBe(Number.POSITIVE_INFINITY);
|
|
15
|
+
expect(out.max.x).toBe(Number.NEGATIVE_INFINITY);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns an empty box when a scene with only group nodes has no meshes', () => {
|
|
19
|
+
const root = createNode3D();
|
|
20
|
+
addNodeChild(root, createNode3D());
|
|
21
|
+
addNodeChild(root, createNode3D());
|
|
22
|
+
const out = createAabb();
|
|
23
|
+
getNode3DWorldBounds(out, root);
|
|
24
|
+
expect(out.min.x).toBe(Number.POSITIVE_INFINITY);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns the world-space bounds of a single Mesh at the origin', () => {
|
|
28
|
+
const mesh = createMesh(createBoxMeshGeometry(), []);
|
|
29
|
+
const out = createAabb();
|
|
30
|
+
getNode3DWorldBounds(out, mesh);
|
|
31
|
+
// A default box is centered at origin with half-extents 0.5 in each axis.
|
|
32
|
+
expect(out.min.x).toBeCloseTo(-0.5);
|
|
33
|
+
expect(out.min.y).toBeCloseTo(-0.5);
|
|
34
|
+
expect(out.min.z).toBeCloseTo(-0.5);
|
|
35
|
+
expect(out.max.x).toBeCloseTo(0.5);
|
|
36
|
+
expect(out.max.y).toBeCloseTo(0.5);
|
|
37
|
+
expect(out.max.z).toBeCloseTo(0.5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('translates bounds when the Mesh is offset', () => {
|
|
41
|
+
const mesh = createMesh(createBoxMeshGeometry(), []);
|
|
42
|
+
setVector3(mesh.position, 10, 0, 0);
|
|
43
|
+
invalidateNodeLocalTransform(mesh);
|
|
44
|
+
const out = createAabb();
|
|
45
|
+
getNode3DWorldBounds(out, mesh);
|
|
46
|
+
expect(out.min.x).toBeCloseTo(9.5);
|
|
47
|
+
expect(out.max.x).toBeCloseTo(10.5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('accumulates bounds from multiple Mesh children', () => {
|
|
51
|
+
const root = createNode3D();
|
|
52
|
+
const meshA = createMesh(createBoxMeshGeometry(), []);
|
|
53
|
+
const meshB = createMesh(createBoxMeshGeometry(), []);
|
|
54
|
+
setVector3(meshA.position, -5, 0, 0);
|
|
55
|
+
invalidateNodeLocalTransform(meshA);
|
|
56
|
+
setVector3(meshB.position, 5, 0, 0);
|
|
57
|
+
invalidateNodeLocalTransform(meshB);
|
|
58
|
+
addNodeChild(root, meshA);
|
|
59
|
+
addNodeChild(root, meshB);
|
|
60
|
+
const out = createAabb();
|
|
61
|
+
getNode3DWorldBounds(out, root);
|
|
62
|
+
expect(out.min.x).toBeCloseTo(-5.5);
|
|
63
|
+
expect(out.max.x).toBeCloseTo(5.5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('accumulates bounds recursively through group nodes', () => {
|
|
67
|
+
const root = createNode3D();
|
|
68
|
+
const group = createNode3D();
|
|
69
|
+
const leaf = createMesh(createBoxMeshGeometry(), []);
|
|
70
|
+
setVector3(leaf.position, 3, 0, 0);
|
|
71
|
+
invalidateNodeLocalTransform(leaf);
|
|
72
|
+
addNodeChild(root, group);
|
|
73
|
+
addNodeChild(group, leaf);
|
|
74
|
+
const out = createAabb();
|
|
75
|
+
getNode3DWorldBounds(out, root);
|
|
76
|
+
expect(out.min.x).toBeCloseTo(2.5);
|
|
77
|
+
expect(out.max.x).toBeCloseTo(3.5);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is alias-safe (out can be any pre-existing Aabb)', () => {
|
|
81
|
+
const mesh = createMesh(createBoxMeshGeometry(), []);
|
|
82
|
+
const out = createAabb(0, 0, 0, 0, 0, 0);
|
|
83
|
+
getNode3DWorldBounds(out, mesh);
|
|
84
|
+
expect(out.min.x).toBeCloseTo(-0.5);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createFrustum, createMatrix4, setVector3 } from '@flighthq/geometry';
|
|
2
|
+
import { createMeshGeometry } from '@flighthq/mesh';
|
|
3
|
+
import { addNodeChild, invalidateNodeLocalTransform } from '@flighthq/node';
|
|
4
|
+
import { Node3DKind } from '@flighthq/types';
|
|
5
|
+
|
|
6
|
+
import { createMesh } from './mesh';
|
|
7
|
+
import { createNode3D } from './sceneNode';
|
|
8
|
+
import { buildScene3DFrustum, cullNode3DByFrustum } from './sceneNodeCulling';
|
|
9
|
+
|
|
10
|
+
// Builds a simple box geometry centered at origin with side 2 (bounds [-1,-1,-1] to [1,1,1]).
|
|
11
|
+
function makeBoxGeometry() {
|
|
12
|
+
return createMeshGeometry({
|
|
13
|
+
layout: {
|
|
14
|
+
attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
|
|
15
|
+
stride: 12,
|
|
16
|
+
},
|
|
17
|
+
subsets: [{ indexCount: 3, indexOffset: 0 }],
|
|
18
|
+
vertices: new Float32Array([-1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1]),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Builds a perspective projection matrix for a 90-degree FOV camera at the origin looking down
|
|
23
|
+
// -Z. Returns the MVP matrix that produces a frustum from z=0.1 to z=100.
|
|
24
|
+
function makeViewProjection() {
|
|
25
|
+
const mat = createMatrix4();
|
|
26
|
+
const m = mat.m;
|
|
27
|
+
// Perspective: fovy=90deg, aspect=1, near=0.1, far=100 — simple derivation
|
|
28
|
+
const f = 1.0; // cot(fov/2) for 90deg
|
|
29
|
+
const near = 0.1;
|
|
30
|
+
const far = 100;
|
|
31
|
+
m[0] = f;
|
|
32
|
+
m[5] = f;
|
|
33
|
+
m[10] = (far + near) / (near - far);
|
|
34
|
+
m[11] = -1;
|
|
35
|
+
m[14] = (2 * far * near) / (near - far);
|
|
36
|
+
m[15] = 0;
|
|
37
|
+
return mat;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('buildScene3DFrustum', () => {
|
|
41
|
+
it('writes frustum planes from a view-projection matrix', () => {
|
|
42
|
+
const frustum = createFrustum();
|
|
43
|
+
const vp = makeViewProjection();
|
|
44
|
+
buildScene3DFrustum(frustum, vp);
|
|
45
|
+
// All 6 named planes should have non-zero normals after extraction.
|
|
46
|
+
const planes = [frustum.near, frustum.far, frustum.left, frustum.right, frustum.top, frustum.bottom];
|
|
47
|
+
for (const p of planes) {
|
|
48
|
+
const len = Math.sqrt(p.a * p.a + p.b * p.b + p.c * p.c);
|
|
49
|
+
expect(len).toBeGreaterThan(0);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('cullNode3DByFrustum', () => {
|
|
55
|
+
it('returns empty when no meshes', () => {
|
|
56
|
+
const root = createNode3D(Node3DKind);
|
|
57
|
+
const frustum = createFrustum();
|
|
58
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
59
|
+
const out = cullNode3DByFrustum([], root, frustum);
|
|
60
|
+
expect(out).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('collects a mesh at the origin that is in frustum', () => {
|
|
64
|
+
const root = createNode3D(Node3DKind);
|
|
65
|
+
const mesh = createMesh(makeBoxGeometry(), []);
|
|
66
|
+
setVector3(mesh.position, 0, 0, -5); // in front of camera
|
|
67
|
+
invalidateNodeLocalTransform(mesh);
|
|
68
|
+
addNodeChild(root, mesh);
|
|
69
|
+
const frustum = createFrustum();
|
|
70
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
71
|
+
const out = cullNode3DByFrustum([], root, frustum);
|
|
72
|
+
expect(out).toContain(mesh);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('excludes disabled nodes and their subtrees', () => {
|
|
76
|
+
const root = createNode3D(Node3DKind);
|
|
77
|
+
const parent = createNode3D();
|
|
78
|
+
parent.enabled = false;
|
|
79
|
+
const mesh = createMesh(makeBoxGeometry(), []);
|
|
80
|
+
setVector3(mesh.position, 0, 0, -5);
|
|
81
|
+
invalidateNodeLocalTransform(mesh);
|
|
82
|
+
addNodeChild(parent, mesh);
|
|
83
|
+
addNodeChild(root, parent);
|
|
84
|
+
const frustum = createFrustum();
|
|
85
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
86
|
+
const out = cullNode3DByFrustum([], root, frustum);
|
|
87
|
+
expect(out).not.toContain(mesh);
|
|
88
|
+
expect(out).not.toContain(parent);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('excludes meshes behind the camera', () => {
|
|
92
|
+
const root = createNode3D(Node3DKind);
|
|
93
|
+
const mesh = createMesh(makeBoxGeometry(), []);
|
|
94
|
+
setVector3(mesh.position, 0, 0, 50); // behind camera (positive Z in RH)
|
|
95
|
+
invalidateNodeLocalTransform(mesh);
|
|
96
|
+
addNodeChild(root, mesh);
|
|
97
|
+
const frustum = createFrustum();
|
|
98
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
99
|
+
const out = cullNode3DByFrustum([], root, frustum);
|
|
100
|
+
expect(out).not.toContain(mesh);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not clear out before appending', () => {
|
|
104
|
+
const root = createNode3D(Node3DKind);
|
|
105
|
+
const mesh = createMesh(makeBoxGeometry(), []);
|
|
106
|
+
setVector3(mesh.position, 0, 0, -5);
|
|
107
|
+
invalidateNodeLocalTransform(mesh);
|
|
108
|
+
addNodeChild(root, mesh);
|
|
109
|
+
const frustum = createFrustum();
|
|
110
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
111
|
+
const existing = [createNode3D()];
|
|
112
|
+
cullNode3DByFrustum(existing, root, frustum);
|
|
113
|
+
expect(existing).toHaveLength(2); // original + new mesh
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns the out array', () => {
|
|
117
|
+
const root = createNode3D(Node3DKind);
|
|
118
|
+
const frustum = createFrustum();
|
|
119
|
+
buildScene3DFrustum(frustum, makeViewProjection());
|
|
120
|
+
const out: ReturnType<typeof createNode3D>[] = [];
|
|
121
|
+
expect(cullNode3DByFrustum(out, root, frustum)).toBe(out);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { addNodeChild, getNodeChildCount, getNodeParent } from '@flighthq/node';
|
|
2
|
+
|
|
3
|
+
import { createNode3D, enableNode3DSignals, getNode3DRuntime } from './sceneNode';
|
|
4
|
+
import { disposeNode3D } from './sceneNodeDispose';
|
|
5
|
+
|
|
6
|
+
describe('disposeNode3D', () => {
|
|
7
|
+
it('clears signals after disposal', () => {
|
|
8
|
+
const node = createNode3D();
|
|
9
|
+
enableNode3DSignals(node);
|
|
10
|
+
disposeNode3D(node);
|
|
11
|
+
expect(getNode3DRuntime(node).nodeSignals).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('detaches the node from its parent', () => {
|
|
15
|
+
const parent = createNode3D();
|
|
16
|
+
const child = createNode3D();
|
|
17
|
+
addNodeChild(parent, child);
|
|
18
|
+
disposeNode3D(child);
|
|
19
|
+
expect(getNodeParent(child)).toBeNull();
|
|
20
|
+
expect(getNodeChildCount(parent)).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('disposes a standalone leaf node without throwing', () => {
|
|
24
|
+
const leaf = createNode3D();
|
|
25
|
+
expect(() => disposeNode3D(leaf)).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('recursively disposes all descendants', () => {
|
|
29
|
+
const root = createNode3D();
|
|
30
|
+
const child = createNode3D();
|
|
31
|
+
const grandchild = createNode3D();
|
|
32
|
+
addNodeChild(root, child);
|
|
33
|
+
addNodeChild(child, grandchild);
|
|
34
|
+
disposeNode3D(root);
|
|
35
|
+
expect(getNodeParent(grandchild)).toBeNull();
|
|
36
|
+
expect(getNodeChildCount(child)).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createVector3 } from '@flighthq/geometry';
|
|
2
|
+
import { getNodeLocalMatrix4 } from '@flighthq/node';
|
|
3
|
+
|
|
4
|
+
import { createNode3D } from './sceneNode';
|
|
5
|
+
import { setNode3DLookAt } from './sceneNodeTransform';
|
|
6
|
+
|
|
7
|
+
describe('setNode3DLookAt', () => {
|
|
8
|
+
it('places the node at the eye position', () => {
|
|
9
|
+
const node = createNode3D();
|
|
10
|
+
const eye = createVector3(3, 4, 5);
|
|
11
|
+
const target = createVector3(0, 0, 0);
|
|
12
|
+
const up = createVector3(0, 1, 0);
|
|
13
|
+
setNode3DLookAt(node, eye, target, up);
|
|
14
|
+
// Model-matrix translation column = eye.
|
|
15
|
+
const m = getNodeLocalMatrix4(node).m;
|
|
16
|
+
expect(m[12]).toBeCloseTo(3);
|
|
17
|
+
expect(m[13]).toBeCloseTo(4);
|
|
18
|
+
expect(m[14]).toBeCloseTo(5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('Z-axis column points from target back to eye (RH -Z-forward convention)', () => {
|
|
22
|
+
const node = createNode3D();
|
|
23
|
+
setNode3DLookAt(node, createVector3(0, 0, 5), createVector3(0, 0, 0), createVector3(0, 1, 0));
|
|
24
|
+
// normalize(eye - target) = (0,0,1) → m[8]=0, m[9]=0, m[10]=1
|
|
25
|
+
const m = getNodeLocalMatrix4(node).m;
|
|
26
|
+
expect(m[8]).toBeCloseTo(0);
|
|
27
|
+
expect(m[9]).toBeCloseTo(0);
|
|
28
|
+
expect(m[10]).toBeCloseTo(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('preserves w = 1 and last column padding', () => {
|
|
32
|
+
const node = createNode3D();
|
|
33
|
+
setNode3DLookAt(node, createVector3(1, 2, 3), createVector3(0, 0, 0), createVector3(0, 1, 0));
|
|
34
|
+
const m = getNodeLocalMatrix4(node).m;
|
|
35
|
+
expect(m[3]).toBe(0);
|
|
36
|
+
expect(m[7]).toBe(0);
|
|
37
|
+
expect(m[11]).toBe(0);
|
|
38
|
+
expect(m[15]).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
});
|