@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,109 @@
|
|
|
1
|
+
import { createAnimationChannel, createAnimationClip, createAnimationTrack } from '@flighthq/animation';
|
|
2
|
+
import { copyQuaternion, copyVector3, createQuaternion, createVector3 } from '@flighthq/geometry';
|
|
3
|
+
import { createBoxMeshGeometry } from '@flighthq/mesh';
|
|
4
|
+
import type { MeshMorph } from '@flighthq/types';
|
|
5
|
+
import {
|
|
6
|
+
Scene3DAnimationPathRotation,
|
|
7
|
+
Scene3DAnimationPathScale,
|
|
8
|
+
Scene3DAnimationPathTranslation,
|
|
9
|
+
Scene3DAnimationPathWeights,
|
|
10
|
+
} from '@flighthq/types';
|
|
11
|
+
|
|
12
|
+
import { createMesh } from './mesh';
|
|
13
|
+
import { applyAnimationClipToScene3D } from './sceneAnimation';
|
|
14
|
+
import { createNode3D } from './sceneNode';
|
|
15
|
+
|
|
16
|
+
describe('applyAnimationClipToScene3D', () => {
|
|
17
|
+
it('drives a node translation from a Vector3 channel', () => {
|
|
18
|
+
const node = createNode3D();
|
|
19
|
+
const track = createAnimationTrack({ components: 3, times: [0, 1], values: [0, 0, 0, 10, 20, 30] });
|
|
20
|
+
const clip = createAnimationClip([createAnimationChannel(track, { node, path: Scene3DAnimationPathTranslation })]);
|
|
21
|
+
|
|
22
|
+
applyAnimationClipToScene3D(clip, 0.5);
|
|
23
|
+
|
|
24
|
+
const out = createVector3();
|
|
25
|
+
copyVector3(out, node.position);
|
|
26
|
+
expect(out.x).toBeCloseTo(5);
|
|
27
|
+
expect(out.y).toBeCloseTo(10);
|
|
28
|
+
expect(out.z).toBeCloseTo(15);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('drives a node rotation from a quaternion channel', () => {
|
|
32
|
+
const node = createNode3D();
|
|
33
|
+
const s = Math.sin(Math.PI / 4);
|
|
34
|
+
const c = Math.cos(Math.PI / 4);
|
|
35
|
+
const track = createAnimationTrack({
|
|
36
|
+
components: 4,
|
|
37
|
+
times: [0, 1],
|
|
38
|
+
values: [0, 0, 0, 1, 0, 0, s, c],
|
|
39
|
+
});
|
|
40
|
+
const clip = createAnimationClip([createAnimationChannel(track, { node, path: Scene3DAnimationPathRotation })]);
|
|
41
|
+
|
|
42
|
+
applyAnimationClipToScene3D(clip, 1);
|
|
43
|
+
|
|
44
|
+
const out = createQuaternion();
|
|
45
|
+
copyQuaternion(out, node.rotation);
|
|
46
|
+
expect(out.x).toBeCloseTo(0);
|
|
47
|
+
expect(out.y).toBeCloseTo(0);
|
|
48
|
+
expect(out.z).toBeCloseTo(s);
|
|
49
|
+
expect(out.w).toBeCloseTo(c);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('drives a node scale', () => {
|
|
53
|
+
const node = createNode3D();
|
|
54
|
+
const track = createAnimationTrack({ components: 3, times: [0, 1], values: [1, 1, 1, 3, 3, 3] });
|
|
55
|
+
const clip = createAnimationClip([createAnimationChannel(track, { node, path: Scene3DAnimationPathScale })]);
|
|
56
|
+
|
|
57
|
+
applyAnimationClipToScene3D(clip, 1);
|
|
58
|
+
|
|
59
|
+
const out = createVector3();
|
|
60
|
+
copyVector3(out, node.scale);
|
|
61
|
+
expect(out.x).toBeCloseTo(3);
|
|
62
|
+
expect(out.y).toBeCloseTo(3);
|
|
63
|
+
expect(out.z).toBeCloseTo(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('drives a mesh morph weight array from a Weights channel', () => {
|
|
67
|
+
const morph: MeshMorph = {
|
|
68
|
+
targets: [
|
|
69
|
+
{ normalDeltas: null, positionDeltas: new Float32Array(0), tangentDeltas: null },
|
|
70
|
+
{ normalDeltas: null, positionDeltas: new Float32Array(0), tangentDeltas: null },
|
|
71
|
+
],
|
|
72
|
+
weights: new Float32Array([0, 0]),
|
|
73
|
+
};
|
|
74
|
+
const mesh = createMesh(createBoxMeshGeometry(), []);
|
|
75
|
+
mesh.morph = morph;
|
|
76
|
+
// A 2-wide weights track: at t=0.5 it interpolates halfway between [0,0] and [1,0.5].
|
|
77
|
+
const track = createAnimationTrack({ components: 2, times: [0, 1], values: [0, 0, 1, 0.5] });
|
|
78
|
+
const clip = createAnimationClip([
|
|
79
|
+
createAnimationChannel(track, { node: mesh, path: Scene3DAnimationPathWeights }),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
applyAnimationClipToScene3D(clip, 0.5);
|
|
83
|
+
|
|
84
|
+
expect(morph.weights[0]).toBeCloseTo(0.5);
|
|
85
|
+
expect(morph.weights[1]).toBeCloseTo(0.25);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('skips a Weights channel whose target mesh carries no morph', () => {
|
|
89
|
+
const mesh = createMesh(createBoxMeshGeometry(), []);
|
|
90
|
+
const track = createAnimationTrack({ components: 1, times: [0, 1], values: [0, 1] });
|
|
91
|
+
const clip = createAnimationClip([
|
|
92
|
+
createAnimationChannel(track, { node: mesh, path: Scene3DAnimationPathWeights }),
|
|
93
|
+
]);
|
|
94
|
+
// No morph on the mesh — the channel is skipped without throwing.
|
|
95
|
+
expect(() => applyAnimationClipToScene3D(clip, 1)).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('skips channels whose targetRef is not a scene target', () => {
|
|
99
|
+
const node = createNode3D();
|
|
100
|
+
const track = createAnimationTrack({ components: 3, times: [0, 1], values: [0, 0, 0, 9, 9, 9] });
|
|
101
|
+
const clip = createAnimationClip([createAnimationChannel(track, null)]);
|
|
102
|
+
|
|
103
|
+
applyAnimationClipToScene3D(clip, 1);
|
|
104
|
+
|
|
105
|
+
const out = createVector3();
|
|
106
|
+
copyVector3(out, node.position);
|
|
107
|
+
expect(out.x).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createAnimationTrack } from '@flighthq/animation';
|
|
2
|
+
import { createTransform3D } from '@flighthq/geometry';
|
|
3
|
+
import { createStandardPbrMaterial } from '@flighthq/materials';
|
|
4
|
+
import { CANONICAL_MESH_GEOMETRY_LAYOUT, createMeshGeometry } from '@flighthq/mesh';
|
|
5
|
+
import { getNodeChildren } from '@flighthq/node';
|
|
6
|
+
import type { MaterialLike, Mesh, Scene3DDocument, Node3D } from '@flighthq/types';
|
|
7
|
+
import { EntityRuntimeKey, Scene3DAnimationPathTranslation, Node3DKind, MeshKind } from '@flighthq/types';
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { isMesh } from './mesh';
|
|
11
|
+
import { createScene3DFromDocument, createScene3DsFromDocument } from './sceneDocument';
|
|
12
|
+
|
|
13
|
+
function emptyDocument(): Scene3DDocument {
|
|
14
|
+
return {
|
|
15
|
+
animations: [],
|
|
16
|
+
cameras: [],
|
|
17
|
+
lights: [],
|
|
18
|
+
materials: [],
|
|
19
|
+
meshes: [],
|
|
20
|
+
metadata: null,
|
|
21
|
+
nodes: [],
|
|
22
|
+
resources: [],
|
|
23
|
+
scenes: [],
|
|
24
|
+
skins: [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function triangleGeometry(): ReturnType<typeof createMeshGeometry> {
|
|
29
|
+
return createMeshGeometry({ layout: CANONICAL_MESH_GEOMETRY_LAYOUT, vertices: new Float32Array(12 * 3) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('createScene3DFromDocument', () => {
|
|
33
|
+
it('returns an empty scene for an empty document', () => {
|
|
34
|
+
const scene = createScene3DFromDocument(emptyDocument());
|
|
35
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
36
|
+
expect(scene.metadata).toBe(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('builds a mesh node from a document mesh, resolving materials by index', () => {
|
|
40
|
+
const material = createStandardPbrMaterial() as unknown as MaterialLike;
|
|
41
|
+
const document = emptyDocument();
|
|
42
|
+
document.materials = [material];
|
|
43
|
+
document.meshes = [{ geometry: triangleGeometry(), materials: [0] }];
|
|
44
|
+
document.nodes = [{ children: [], kind: MeshKind, mesh: 0, transform: createTransform3D() }];
|
|
45
|
+
document.scenes = [{ rootNodes: [0] }];
|
|
46
|
+
|
|
47
|
+
const scene = createScene3DFromDocument(document);
|
|
48
|
+
const roots = getNodeChildren(scene.root);
|
|
49
|
+
expect(roots).toHaveLength(1);
|
|
50
|
+
expect(isMesh(roots[0] as Node3D)).toBe(true);
|
|
51
|
+
expect((roots[0] as unknown as Mesh).materials[0]).toBe(material);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('wires child index lists into the built hierarchy', () => {
|
|
55
|
+
const document = emptyDocument();
|
|
56
|
+
const parent = createTransform3D();
|
|
57
|
+
const child = createTransform3D();
|
|
58
|
+
document.nodes = [
|
|
59
|
+
{ children: [1], kind: Node3DKind, name: 'parent', transform: parent },
|
|
60
|
+
{ children: [], kind: Node3DKind, name: 'child', transform: child },
|
|
61
|
+
];
|
|
62
|
+
document.scenes = [{ rootNodes: [0] }];
|
|
63
|
+
|
|
64
|
+
const scene = createScene3DFromDocument(document);
|
|
65
|
+
const roots = getNodeChildren(scene.root);
|
|
66
|
+
expect(roots).toHaveLength(1);
|
|
67
|
+
expect(getNodeChildren(roots[0] as Node3D)).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('applies a node transform onto the built node', () => {
|
|
71
|
+
const document = emptyDocument();
|
|
72
|
+
const transform = createTransform3D();
|
|
73
|
+
transform.position.x = 5;
|
|
74
|
+
document.nodes = [{ children: [], kind: Node3DKind, transform }];
|
|
75
|
+
document.scenes = [{ rootNodes: [0] }];
|
|
76
|
+
|
|
77
|
+
const scene = createScene3DFromDocument(document);
|
|
78
|
+
const node = getNodeChildren(scene.root)[0] as Node3D;
|
|
79
|
+
expect(node.position.x).toBe(5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('binds a skin by joint index onto the mesh it names', () => {
|
|
83
|
+
const document = emptyDocument();
|
|
84
|
+
document.meshes = [{ geometry: triangleGeometry(), materials: [], skin: 0 }];
|
|
85
|
+
document.nodes = [
|
|
86
|
+
{ children: [1], kind: MeshKind, mesh: 0, transform: createTransform3D() },
|
|
87
|
+
{ children: [], kind: Node3DKind, name: 'joint', transform: createTransform3D() },
|
|
88
|
+
];
|
|
89
|
+
document.skins = [{ inverseBind: [{ m: identity16() }], joints: [1] }];
|
|
90
|
+
document.scenes = [{ rootNodes: [0] }];
|
|
91
|
+
|
|
92
|
+
const scene = createScene3DFromDocument(document);
|
|
93
|
+
const mesh = getNodeChildren(scene.root)[0] as unknown as Mesh;
|
|
94
|
+
expect(mesh.skin).toBeTruthy();
|
|
95
|
+
expect(mesh.skin?.skeleton.joints).toHaveLength(1);
|
|
96
|
+
expect(mesh.skin?.skeleton.names).toEqual(['joint']);
|
|
97
|
+
expect(EntityRuntimeKey in mesh.skin!.skeleton).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rebuilds a node-bound animation clip from a document channel', () => {
|
|
101
|
+
const document = emptyDocument();
|
|
102
|
+
document.nodes = [{ children: [], kind: Node3DKind, transform: createTransform3D() }];
|
|
103
|
+
document.scenes = [{ rootNodes: [0] }];
|
|
104
|
+
const track = createAnimationTrack({ components: 3, times: [0, 1], values: [0, 0, 0, 1, 2, 3] });
|
|
105
|
+
document.animations = [
|
|
106
|
+
{ channels: [{ node: 0, path: Scene3DAnimationPathTranslation, track }], duration: 1, name: 'move' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const scene = createScene3DFromDocument(document);
|
|
110
|
+
expect(Object.keys(scene.animations)).toEqual(['move']);
|
|
111
|
+
expect(scene.animations.move.duration).toBe(1);
|
|
112
|
+
expect(scene.animations.move.channels[0].track).toBe(track);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('selects the scene at the given index', () => {
|
|
116
|
+
const document = emptyDocument();
|
|
117
|
+
document.nodes = [
|
|
118
|
+
{ children: [], kind: Node3DKind, name: 'a', transform: createTransform3D() },
|
|
119
|
+
{ children: [], kind: Node3DKind, name: 'b', transform: createTransform3D() },
|
|
120
|
+
];
|
|
121
|
+
document.scenes = [{ rootNodes: [0] }, { rootNodes: [1] }];
|
|
122
|
+
|
|
123
|
+
const scene = createScene3DFromDocument(document, 1);
|
|
124
|
+
expect((getNodeChildren(scene.root)[0] as Node3D).name).toBe('b');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('createScene3DsFromDocument', () => {
|
|
129
|
+
it('builds every scene the document declares, sharing one node pool', () => {
|
|
130
|
+
const document = emptyDocument();
|
|
131
|
+
document.nodes = [
|
|
132
|
+
{ children: [], kind: Node3DKind, transform: createTransform3D() },
|
|
133
|
+
{ children: [], kind: Node3DKind, transform: createTransform3D() },
|
|
134
|
+
];
|
|
135
|
+
document.scenes = [{ rootNodes: [0] }, { rootNodes: [1] }];
|
|
136
|
+
|
|
137
|
+
const scenes = createScene3DsFromDocument(document);
|
|
138
|
+
expect(scenes).toHaveLength(2);
|
|
139
|
+
expect(getNodeChildren(scenes[0].root)).toHaveLength(1);
|
|
140
|
+
expect(getNodeChildren(scenes[1].root)).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('attaches the animations to the first scene, bound to the shared node instance', () => {
|
|
144
|
+
const document = emptyDocument();
|
|
145
|
+
document.nodes = [
|
|
146
|
+
{ children: [], kind: Node3DKind, transform: createTransform3D() },
|
|
147
|
+
{ children: [], kind: Node3DKind, transform: createTransform3D() },
|
|
148
|
+
];
|
|
149
|
+
document.scenes = [{ rootNodes: [0] }, { rootNodes: [1] }];
|
|
150
|
+
const track = createAnimationTrack({ components: 3, times: [0, 1], values: [0, 0, 0, 1, 1, 1] });
|
|
151
|
+
document.animations = [
|
|
152
|
+
{ channels: [{ node: 1, path: Scene3DAnimationPathTranslation, track }], duration: 1, name: 'spin' },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const scenes = createScene3DsFromDocument(document);
|
|
156
|
+
expect(Object.keys(scenes[0].animations)).toEqual(['spin']);
|
|
157
|
+
const target = scenes[0].animations.spin.channels[0].targetRef as { node: Node3D };
|
|
158
|
+
expect(target.node).toBe(getNodeChildren(scenes[1].root)[0]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns an empty array for a scene-less document', () => {
|
|
162
|
+
expect(createScene3DsFromDocument(emptyDocument())).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function identity16(): Float32Array {
|
|
167
|
+
const m = new Float32Array(16);
|
|
168
|
+
m[0] = 1;
|
|
169
|
+
m[5] = 1;
|
|
170
|
+
m[10] = 1;
|
|
171
|
+
m[15] = 1;
|
|
172
|
+
return m;
|
|
173
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createBlinnPhongMaterial, createStandardPbrMaterial } from '@flighthq/materials';
|
|
2
|
+
import { createBoxMeshGeometry } from '@flighthq/mesh';
|
|
3
|
+
import { addNodeChild } from '@flighthq/node';
|
|
4
|
+
import type { Material } from '@flighthq/types';
|
|
5
|
+
import { Node3DKind } from '@flighthq/types';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { createMesh } from './mesh';
|
|
9
|
+
import { findScene3DMaterialByName, getScene3DMaterials } from './sceneMaterial';
|
|
10
|
+
import { createNode3D } from './sceneNode';
|
|
11
|
+
|
|
12
|
+
describe('findScene3DMaterialByName', () => {
|
|
13
|
+
it('finds a named material on a descendant mesh', () => {
|
|
14
|
+
const material = createBlinnPhongMaterial();
|
|
15
|
+
material.name = 'canopy';
|
|
16
|
+
const scene = createNode3D(Node3DKind);
|
|
17
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [material]));
|
|
18
|
+
expect(findScene3DMaterialByName(scene, 'canopy')).toBe(material);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns null when no material carries the name', () => {
|
|
22
|
+
const material = createBlinnPhongMaterial();
|
|
23
|
+
material.name = 'canopy';
|
|
24
|
+
const scene = createNode3D(Node3DKind);
|
|
25
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [material]));
|
|
26
|
+
expect(findScene3DMaterialByName(scene, 'fuselage')).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('ignores anonymous materials (null name)', () => {
|
|
30
|
+
const scene = createNode3D(Node3DKind);
|
|
31
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [createBlinnPhongMaterial()]));
|
|
32
|
+
expect(findScene3DMaterialByName(scene, 'canopy')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('searches nested descendants depth-first', () => {
|
|
36
|
+
const material = createStandardPbrMaterial();
|
|
37
|
+
material.name = 'deep';
|
|
38
|
+
const scene = createNode3D(Node3DKind);
|
|
39
|
+
const group = createNode3D(Node3DKind);
|
|
40
|
+
addNodeChild(scene, group);
|
|
41
|
+
addNodeChild(group, createMesh(createBoxMeshGeometry(), [material]));
|
|
42
|
+
expect(findScene3DMaterialByName(scene, 'deep')).toBe(material);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('includes the root node itself', () => {
|
|
46
|
+
const material = createBlinnPhongMaterial();
|
|
47
|
+
material.name = 'root-mat';
|
|
48
|
+
const rootMesh = createMesh(createBoxMeshGeometry(), [material]);
|
|
49
|
+
expect(findScene3DMaterialByName(rootMesh, 'root-mat')).toBe(material);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns the first match in pre-order across meshes', () => {
|
|
53
|
+
const first = createBlinnPhongMaterial();
|
|
54
|
+
first.name = 'shared';
|
|
55
|
+
const second = createBlinnPhongMaterial();
|
|
56
|
+
second.name = 'shared';
|
|
57
|
+
const scene = createNode3D(Node3DKind);
|
|
58
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [first]));
|
|
59
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [second]));
|
|
60
|
+
expect(findScene3DMaterialByName(scene, 'shared')).toBe(first);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('skips null material slots without matching', () => {
|
|
64
|
+
const material = createBlinnPhongMaterial();
|
|
65
|
+
material.name = 'canopy';
|
|
66
|
+
const scene = createNode3D(Node3DKind);
|
|
67
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [null, material]));
|
|
68
|
+
expect(findScene3DMaterialByName(scene, 'canopy')).toBe(material);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('getScene3DMaterials', () => {
|
|
73
|
+
it('collects every distinct material across the scene, root included, deduped by reference', () => {
|
|
74
|
+
const shared = createBlinnPhongMaterial();
|
|
75
|
+
const other = createStandardPbrMaterial();
|
|
76
|
+
const scene = createNode3D(Node3DKind);
|
|
77
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [shared]));
|
|
78
|
+
const group = createNode3D(Node3DKind);
|
|
79
|
+
addNodeChild(group, createMesh(createBoxMeshGeometry(), [shared, other])); // shared reused
|
|
80
|
+
addNodeChild(scene, group);
|
|
81
|
+
const out: Material[] = [];
|
|
82
|
+
getScene3DMaterials(scene, out);
|
|
83
|
+
expect(out).toContain(shared);
|
|
84
|
+
expect(out).toContain(other);
|
|
85
|
+
expect(out).toHaveLength(2); // shared appears once
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('skips null material slots and collects nothing for a mesh-free scene', () => {
|
|
89
|
+
const scene = createNode3D(Node3DKind);
|
|
90
|
+
addNodeChild(scene, createMesh(createBoxMeshGeometry(), [null]));
|
|
91
|
+
const out: Material[] = [];
|
|
92
|
+
getScene3DMaterials(scene, out);
|
|
93
|
+
expect(out).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addNodeChild,
|
|
3
|
+
ensureNodeWorldMatrix4,
|
|
4
|
+
getNodeChildCount,
|
|
5
|
+
getNodeLocalMatrix4,
|
|
6
|
+
getNodeParent,
|
|
7
|
+
getNodeRoot,
|
|
8
|
+
getNodeWorldMatrix4,
|
|
9
|
+
initTransform3DRuntimeTrait,
|
|
10
|
+
initTransform3DTrait,
|
|
11
|
+
invalidateNodeLocalTransform,
|
|
12
|
+
invalidateNodeParentReference,
|
|
13
|
+
removeNodeChild,
|
|
14
|
+
setNodeLocalMatrix4,
|
|
15
|
+
} from '@flighthq/node';
|
|
16
|
+
import type { Matrix4, Node3D } from '@flighthq/types';
|
|
17
|
+
import type { Node3DRuntime } from '@flighthq/types';
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createNode3D,
|
|
22
|
+
createNode3DRuntime,
|
|
23
|
+
enableNode3DSignals,
|
|
24
|
+
getNode3DRuntime,
|
|
25
|
+
getNode3DSignals,
|
|
26
|
+
Node3DKind,
|
|
27
|
+
} from './sceneNode';
|
|
28
|
+
|
|
29
|
+
function createTransformNode(): Node3D {
|
|
30
|
+
const node = createNode3D();
|
|
31
|
+
initTransform3DTrait(node);
|
|
32
|
+
initTransform3DRuntimeTrait(getNode3DRuntime(node));
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('createNode3D', () => {
|
|
37
|
+
it('uses Node3DKind by default', () => {
|
|
38
|
+
const node = createNode3D();
|
|
39
|
+
expect(node.kind).toBe(Node3DKind);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts a custom kind', () => {
|
|
43
|
+
const MyKind = 'MyKind';
|
|
44
|
+
const node = createNode3D(MyKind);
|
|
45
|
+
expect(node.kind).toBe(MyKind);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('defaults enabled to true and name to null', () => {
|
|
49
|
+
const node = createNode3D();
|
|
50
|
+
expect(node.enabled).toBe(true);
|
|
51
|
+
expect(node.name).toBe(null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('accepts partial initial values', () => {
|
|
55
|
+
const node = createNode3D(Node3DKind, { enabled: false, name: 'root' });
|
|
56
|
+
expect(node.enabled).toBe(false);
|
|
57
|
+
expect(node.name).toBe('root');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('defaults alpha to 1 (fully opaque)', () => {
|
|
61
|
+
expect(createNode3D().alpha).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts an initial alpha', () => {
|
|
65
|
+
expect(createNode3D(Node3DKind, { alpha: 0.4 }).alpha).toBeCloseTo(0.4);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('createNode3DRuntime', () => {
|
|
70
|
+
it('initializes transform bookkeeping ids', () => {
|
|
71
|
+
const runtime = createNode3DRuntime();
|
|
72
|
+
expect(runtime.localTransformId).toBe(0);
|
|
73
|
+
expect(runtime.worldTransformId).toBe(0);
|
|
74
|
+
expect(runtime.worldTransformUsingLocalTransformId).toBe(-1);
|
|
75
|
+
expect(runtime.worldTransformUsingParentTransformId).toBe(-1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('initializes worldMatrix to null', () => {
|
|
79
|
+
const runtime = createNode3DRuntime();
|
|
80
|
+
expect(runtime.worldMatrix4).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('initializes worldAlpha to null (unresolved until prepared)', () => {
|
|
84
|
+
expect(createNode3DRuntime().worldAlpha).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('enableNode3DSignals', () => {
|
|
89
|
+
it('creates and returns the signal bag on first call', () => {
|
|
90
|
+
const node = createNode3D();
|
|
91
|
+
const signals = enableNode3DSignals(node);
|
|
92
|
+
expect(signals.onChildAdded).toBeDefined();
|
|
93
|
+
expect(signals.onParentChanged).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns the same object on subsequent calls', () => {
|
|
97
|
+
const node = createNode3D();
|
|
98
|
+
expect(enableNode3DSignals(node)).toBe(enableNode3DSignals(node));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('stores the signals on the runtime nodeSignals slot', () => {
|
|
102
|
+
const node = createNode3D();
|
|
103
|
+
const signals = enableNode3DSignals(node);
|
|
104
|
+
expect(getNode3DRuntime(node).nodeSignals).toBe(signals);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('getNode3DRuntime', () => {
|
|
109
|
+
it('returns a runtime with the expected initial state', () => {
|
|
110
|
+
const node = createNode3D();
|
|
111
|
+
const runtime = getNode3DRuntime(node);
|
|
112
|
+
expect(runtime.children).toBeNull();
|
|
113
|
+
expect(runtime.parent).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('getNode3DSignals', () => {
|
|
118
|
+
it('returns null before signals are enabled', () => {
|
|
119
|
+
const node = createNode3D();
|
|
120
|
+
expect(getNode3DSignals(node)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns the runtime nodeSignals after enableNode3DSignals', () => {
|
|
124
|
+
const node = createNode3D();
|
|
125
|
+
const signals = enableNode3DSignals(node);
|
|
126
|
+
expect(getNode3DSignals(node)).toBe(signals);
|
|
127
|
+
expect(getNode3DSignals(node)).toBe(getNode3DRuntime(node).nodeSignals);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('initTransform3DTrait', () => {
|
|
132
|
+
it('sets an identity localMatrix by default', () => {
|
|
133
|
+
const node = createTransformNode();
|
|
134
|
+
const m = getNodeLocalMatrix4(node).m;
|
|
135
|
+
expect(m[0]).toBe(1);
|
|
136
|
+
expect(m[5]).toBe(1);
|
|
137
|
+
expect(m[10]).toBe(1);
|
|
138
|
+
expect(m[15]).toBe(1);
|
|
139
|
+
expect(m[12]).toBe(0);
|
|
140
|
+
expect(m[13]).toBe(0);
|
|
141
|
+
expect(m[14]).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('accepts an existing matrix', () => {
|
|
145
|
+
const node = createNode3D();
|
|
146
|
+
const existing = { m: new Float32Array(16) } as unknown as Matrix4;
|
|
147
|
+
existing.m[12] = 42;
|
|
148
|
+
setNodeLocalMatrix4(node, existing);
|
|
149
|
+
expect(getNodeLocalMatrix4(node).m[12]).toBe(42);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('invalidateNodeLocalTransform', () => {
|
|
154
|
+
it('increments the local transform id', () => {
|
|
155
|
+
const node = createNode3D();
|
|
156
|
+
const before = getNode3DRuntime(node).localTransformId;
|
|
157
|
+
invalidateNodeLocalTransform(node);
|
|
158
|
+
expect(getNode3DRuntime(node).localTransformId).toBe(before + 1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('invalidateNodeParentReference', () => {
|
|
163
|
+
it('resets the cached parent transform id so the world matrix recomputes', () => {
|
|
164
|
+
const node = createNode3D();
|
|
165
|
+
getNode3DRuntime(node).worldTransformUsingParentTransformId = 5;
|
|
166
|
+
invalidateNodeParentReference(node);
|
|
167
|
+
expect(getNode3DRuntime(node).worldTransformUsingParentTransformId).toBe(-1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Node3DRuntime', () => {
|
|
172
|
+
it('starts with no parent, no children, and null worldMatrix', () => {
|
|
173
|
+
const node = createTransformNode();
|
|
174
|
+
const runtime = getNode3DRuntime(node) as Node3DRuntime;
|
|
175
|
+
expect(runtime.parent).toBe(null);
|
|
176
|
+
expect(runtime.children).toBe(null);
|
|
177
|
+
expect(runtime.worldMatrix4).toBe(null);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('worldHierarchy', () => {
|
|
182
|
+
it('addNodeChild links parent and child', () => {
|
|
183
|
+
const parent = createNode3D();
|
|
184
|
+
const child = createNode3D();
|
|
185
|
+
addNodeChild(parent, child);
|
|
186
|
+
expect(getNodeParent(child)).toBe(parent);
|
|
187
|
+
expect(getNodeChildCount(parent)).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('reparents a child from one node to another', () => {
|
|
191
|
+
const a = createNode3D();
|
|
192
|
+
const b = createNode3D();
|
|
193
|
+
const child = createNode3D();
|
|
194
|
+
addNodeChild(a, child);
|
|
195
|
+
addNodeChild(b, child);
|
|
196
|
+
expect(getNodeParent(child)).toBe(b);
|
|
197
|
+
expect(getNodeChildCount(a)).toBe(0);
|
|
198
|
+
expect(getNodeChildCount(b)).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('removeNodeChild unlinks parent and child', () => {
|
|
202
|
+
const parent = createNode3D();
|
|
203
|
+
const child = createNode3D();
|
|
204
|
+
addNodeChild(parent, child);
|
|
205
|
+
removeNodeChild(parent, child);
|
|
206
|
+
expect(getNodeParent(child)).toBe(null);
|
|
207
|
+
expect(getNodeChildCount(parent)).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('getNodeRoot traverses to the top ancestor', () => {
|
|
211
|
+
const root = createNode3D();
|
|
212
|
+
const mid = createNode3D();
|
|
213
|
+
const leaf = createNode3D();
|
|
214
|
+
addNodeChild(root, mid);
|
|
215
|
+
addNodeChild(mid, leaf);
|
|
216
|
+
expect(getNodeRoot(leaf)).toBe(root);
|
|
217
|
+
expect(getNodeRoot(mid)).toBe(root);
|
|
218
|
+
expect(getNodeRoot(root)).toBe(root);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('throws when adding a node as its own child', () => {
|
|
222
|
+
const node = createNode3D();
|
|
223
|
+
expect(() => addNodeChild(node, node)).toThrow(TypeError);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('worldTransform', () => {
|
|
228
|
+
it('worldMatrix equals localMatrix for a root node', () => {
|
|
229
|
+
const node = createTransformNode();
|
|
230
|
+
node.position.x = 10;
|
|
231
|
+
node.position.y = 20;
|
|
232
|
+
node.position.z = 30;
|
|
233
|
+
invalidateNodeLocalTransform(node);
|
|
234
|
+
|
|
235
|
+
const world = getNodeWorldMatrix4(node);
|
|
236
|
+
expect(world.m[12]).toBe(10);
|
|
237
|
+
expect(world.m[13]).toBe(20);
|
|
238
|
+
expect(world.m[14]).toBe(30);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('world matrix is parent * local for a child node', () => {
|
|
242
|
+
const parent = createTransformNode();
|
|
243
|
+
const child = createTransformNode();
|
|
244
|
+
addNodeChild(parent, child);
|
|
245
|
+
|
|
246
|
+
parent.position.x = 5;
|
|
247
|
+
invalidateNodeLocalTransform(parent);
|
|
248
|
+
|
|
249
|
+
child.position.x = 3;
|
|
250
|
+
invalidateNodeLocalTransform(child);
|
|
251
|
+
|
|
252
|
+
const world = getNodeWorldMatrix4(child);
|
|
253
|
+
expect(world.m[12]).toBeCloseTo(8);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('world matrix is recomputed after localMatrix changes', () => {
|
|
257
|
+
const node = createTransformNode();
|
|
258
|
+
invalidateNodeLocalTransform(node);
|
|
259
|
+
ensureNodeWorldMatrix4(node);
|
|
260
|
+
const first = getNode3DRuntime(node).worldTransformId;
|
|
261
|
+
|
|
262
|
+
node.position.x = 99;
|
|
263
|
+
invalidateNodeLocalTransform(node);
|
|
264
|
+
|
|
265
|
+
ensureNodeWorldMatrix4(node);
|
|
266
|
+
const second = getNode3DRuntime(node).worldTransformId;
|
|
267
|
+
|
|
268
|
+
expect(second).not.toBe(first);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('world matrix is cached when nothing changes', () => {
|
|
272
|
+
const node = createTransformNode();
|
|
273
|
+
ensureNodeWorldMatrix4(node);
|
|
274
|
+
const id1 = getNode3DRuntime(node).worldTransformId;
|
|
275
|
+
ensureNodeWorldMatrix4(node);
|
|
276
|
+
const id2 = getNode3DRuntime(node).worldTransformId;
|
|
277
|
+
expect(id1).toBe(id2);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('child world matrix updates when parent localMatrix changes', () => {
|
|
281
|
+
const parent = createTransformNode();
|
|
282
|
+
const child = createTransformNode();
|
|
283
|
+
addNodeChild(parent, child);
|
|
284
|
+
|
|
285
|
+
parent.position.x = 7;
|
|
286
|
+
invalidateNodeLocalTransform(parent);
|
|
287
|
+
|
|
288
|
+
const world = getNodeWorldMatrix4(child);
|
|
289
|
+
expect(world.m[12]).toBeCloseTo(7);
|
|
290
|
+
});
|
|
291
|
+
});
|