@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.
Files changed (75) hide show
  1. package/dist/billboard.d.ts +8 -0
  2. package/dist/billboard.d.ts.map +1 -0
  3. package/dist/billboard.js +41 -0
  4. package/dist/billboard.js.map +1 -0
  5. package/dist/billboardCamera.d.ts +4 -0
  6. package/dist/billboardCamera.d.ts.map +1 -0
  7. package/dist/billboardCamera.js +238 -0
  8. package/dist/billboardCamera.js.map +1 -0
  9. package/dist/index.d.ts +15 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +15 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/mesh.d.ts +10 -0
  14. package/dist/mesh.d.ts.map +1 -0
  15. package/dist/mesh.js +75 -0
  16. package/dist/mesh.js.map +1 -0
  17. package/dist/prepareScene3DMorph.d.ts +3 -0
  18. package/dist/prepareScene3DMorph.d.ts.map +1 -0
  19. package/dist/prepareScene3DMorph.js +34 -0
  20. package/dist/prepareScene3DMorph.js.map +1 -0
  21. package/dist/scene.d.ts +3 -0
  22. package/dist/scene.d.ts.map +1 -0
  23. package/dist/scene.js +12 -0
  24. package/dist/scene.js.map +1 -0
  25. package/dist/sceneAnimation.d.ts +3 -0
  26. package/dist/sceneAnimation.d.ts.map +1 -0
  27. package/dist/sceneAnimation.js +43 -0
  28. package/dist/sceneAnimation.js.map +1 -0
  29. package/dist/sceneDocument.d.ts +4 -0
  30. package/dist/sceneDocument.d.ts.map +1 -0
  31. package/dist/sceneDocument.js +163 -0
  32. package/dist/sceneDocument.js.map +1 -0
  33. package/dist/sceneMaterial.d.ts +4 -0
  34. package/dist/sceneMaterial.d.ts.map +1 -0
  35. package/dist/sceneMaterial.js +58 -0
  36. package/dist/sceneMaterial.js.map +1 -0
  37. package/dist/sceneNode.d.ts +8 -0
  38. package/dist/sceneNode.d.ts.map +1 -0
  39. package/dist/sceneNode.js +29 -0
  40. package/dist/sceneNode.js.map +1 -0
  41. package/dist/sceneNodeAppearance.d.ts +5 -0
  42. package/dist/sceneNodeAppearance.d.ts.map +1 -0
  43. package/dist/sceneNodeAppearance.js +50 -0
  44. package/dist/sceneNodeAppearance.js.map +1 -0
  45. package/dist/sceneNodeBounds.d.ts +3 -0
  46. package/dist/sceneNodeBounds.d.ts.map +1 -0
  47. package/dist/sceneNodeBounds.js +49 -0
  48. package/dist/sceneNodeBounds.js.map +1 -0
  49. package/dist/sceneNodeCulling.d.ts +4 -0
  50. package/dist/sceneNodeCulling.d.ts.map +1 -0
  51. package/dist/sceneNodeCulling.js +62 -0
  52. package/dist/sceneNodeCulling.js.map +1 -0
  53. package/dist/sceneNodeDispose.d.ts +3 -0
  54. package/dist/sceneNodeDispose.d.ts.map +1 -0
  55. package/dist/sceneNodeDispose.js +15 -0
  56. package/dist/sceneNodeDispose.js.map +1 -0
  57. package/dist/sceneNodeTransform.d.ts +3 -0
  58. package/dist/sceneNodeTransform.d.ts.map +1 -0
  59. package/dist/sceneNodeTransform.js +65 -0
  60. package/dist/sceneNodeTransform.js.map +1 -0
  61. package/package.json +49 -0
  62. package/src/billboard.test.ts +79 -0
  63. package/src/billboardCamera.test.ts +152 -0
  64. package/src/mesh.test.ts +247 -0
  65. package/src/prepareScene3DMorph.test.ts +68 -0
  66. package/src/scene.test.ts +53 -0
  67. package/src/sceneAnimation.test.ts +109 -0
  68. package/src/sceneDocument.test.ts +173 -0
  69. package/src/sceneMaterial.test.ts +95 -0
  70. package/src/sceneNode.test.ts +291 -0
  71. package/src/sceneNodeAppearance.test.ts +80 -0
  72. package/src/sceneNodeBounds.test.ts +86 -0
  73. package/src/sceneNodeCulling.test.ts +123 -0
  74. package/src/sceneNodeDispose.test.ts +38 -0
  75. package/src/sceneNodeTransform.test.ts +40 -0
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@flighthq/scene3d",
3
+ "version": "0.2.1-next.840.857425c",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/flighthq/flight.git",
7
+ "directory": "packages/scene3d"
8
+ },
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src/**/*.test.ts",
21
+ "!dist/**/*.test.js",
22
+ "!dist/**/*.test.d.ts",
23
+ "!dist/**/*.test.js.map",
24
+ "!dist/**/*.test.d.ts.map"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "clean": "tsc -b --clean",
29
+ "test": "vitest run --config vitest.config.ts",
30
+ "test:watch": "vitest --watch --config vitest.config.ts",
31
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
32
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
33
+ },
34
+ "dependencies": {
35
+ "@flighthq/animation": "0.2.1-next.840.857425c",
36
+ "@flighthq/entity": "0.2.1-next.840.857425c",
37
+ "@flighthq/geometry": "0.2.1-next.840.857425c",
38
+ "@flighthq/mesh": "0.2.1-next.840.857425c",
39
+ "@flighthq/node": "0.2.1-next.840.857425c",
40
+ "@flighthq/types": "0.2.1-next.840.857425c"
41
+ },
42
+ "devDependencies": {
43
+ "@flighthq/camera": "*",
44
+ "@flighthq/materials": "*",
45
+ "typescript": "^5.3.0"
46
+ },
47
+ "description": "3D scene graph and spatial node hierarchy",
48
+ "sideEffects": false
49
+ }
@@ -0,0 +1,79 @@
1
+ import { createStandardPbrMaterial } from '@flighthq/materials';
2
+ import { createPlaneMeshGeometry } from '@flighthq/mesh';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ BillboardKind,
7
+ createBillboard,
8
+ enableBillboardSignals,
9
+ getBillboardRuntime,
10
+ getBillboardSignals,
11
+ isBillboard,
12
+ } from './billboard';
13
+ import { createMesh } from './mesh';
14
+ import { createNode3D } from './sceneNode';
15
+
16
+ describe('createBillboard', () => {
17
+ it('uses BillboardKind and full facing by default', () => {
18
+ const billboard = createBillboard(createPlaneMeshGeometry(), [createStandardPbrMaterial()]);
19
+ expect(billboard.kind).toBe(BillboardKind);
20
+ expect(billboard.mode).toBe('full');
21
+ });
22
+
23
+ it('stores geometry and materials by reference', () => {
24
+ const geometry = createPlaneMeshGeometry();
25
+ const material = createStandardPbrMaterial();
26
+ const billboard = createBillboard(geometry, [material]);
27
+ expect(billboard.geometry).toBe(geometry);
28
+ expect(billboard.materials[0]).toBe(material);
29
+ });
30
+
31
+ it('accepts an explicit facing mode', () => {
32
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'axisY');
33
+ expect(billboard.mode).toBe('axisY');
34
+ });
35
+
36
+ it('accepts a custom kind', () => {
37
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'screenAligned', 'acme.Billboard');
38
+ expect(billboard.kind).toBe('acme.Billboard');
39
+ expect(isBillboard(billboard)).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('enableBillboardSignals', () => {
44
+ it('returns the node signal group', () => {
45
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null]);
46
+ expect(enableBillboardSignals(billboard)).not.toBeNull();
47
+ expect(getBillboardSignals(billboard)).not.toBeNull();
48
+ });
49
+ });
50
+
51
+ describe('getBillboardRuntime', () => {
52
+ it('returns the scene-node runtime with a world-matrix slot', () => {
53
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null]);
54
+ const runtime = getBillboardRuntime(billboard);
55
+ expect(runtime).toBe(getBillboardRuntime(billboard));
56
+ expect(runtime.worldMatrix4).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe('getBillboardSignals', () => {
61
+ it('returns null before signals are enabled', () => {
62
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null]);
63
+ expect(getBillboardSignals(billboard)).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('isBillboard', () => {
68
+ it('is true for a billboard', () => {
69
+ expect(isBillboard(createBillboard(createPlaneMeshGeometry(), [null]))).toBe(true);
70
+ });
71
+
72
+ it('is false for a plain mesh (geometry but no mode)', () => {
73
+ expect(isBillboard(createMesh(createPlaneMeshGeometry(), [null]))).toBe(false);
74
+ });
75
+
76
+ it('is false for a transform-only scene node', () => {
77
+ expect(isBillboard(createNode3D())).toBe(false);
78
+ });
79
+ });
@@ -0,0 +1,152 @@
1
+ import { createCamera3D, createPerspectiveProjection, setCamera3DViewMatrix4FromLookAt } from '@flighthq/camera';
2
+ import { copyQuaternion, createQuaternion, setQuaternionFromAxisAngle, setVector3 } from '@flighthq/geometry';
3
+ import { createPlaneMeshGeometry } from '@flighthq/mesh';
4
+ import { addNodeChild, getNodeWorldMatrix4, invalidateNodeLocalTransform } from '@flighthq/node';
5
+ import type { Camera3D } from '@flighthq/types';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { createBillboard } from './billboard';
9
+ import { orientBillboardToCamera, orientScene3DBillboardsToCamera } from './billboardCamera';
10
+ import { createMesh } from './mesh';
11
+ import { createNode3D } from './sceneNode';
12
+
13
+ // A camera at (ex,ey,ez) looking at the world origin with world +Y up.
14
+ function cameraLookingFrom(ex: number, ey: number, ez: number): Camera3D {
15
+ const camera = createCamera3D({
16
+ far: 100,
17
+ near: 0.1,
18
+ projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }),
19
+ });
20
+ setCamera3DViewMatrix4FromLookAt(camera, { x: ex, y: ey, z: ez }, { x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 });
21
+ return camera;
22
+ }
23
+
24
+ describe('orientBillboardToCamera', () => {
25
+ it('full mode at the origin faces a +Z camera with an identity basis', () => {
26
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
27
+ orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10));
28
+ const m = getNodeWorldMatrix4(billboard).m;
29
+ // right = +X, up = +Y, normal(+Z) toward the camera.
30
+ expect(m[0]).toBeCloseTo(1, 5);
31
+ expect(m[5]).toBeCloseTo(1, 5);
32
+ expect(m[10]).toBeCloseTo(1, 5);
33
+ expect(m[1]).toBeCloseTo(0, 5);
34
+ expect(m[4]).toBeCloseTo(0, 5);
35
+ expect(m[8]).toBeCloseTo(0, 5);
36
+ });
37
+
38
+ it('full mode points the normal at the camera eye and preserves world position', () => {
39
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
40
+ setVector3(billboard.position, 3, 0, 0);
41
+ invalidateNodeLocalTransform(billboard);
42
+ orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10));
43
+ const m = getNodeWorldMatrix4(billboard).m;
44
+ // Position preserved.
45
+ expect(m[12]).toBeCloseTo(3, 5);
46
+ expect(m[13]).toBeCloseTo(0, 5);
47
+ expect(m[14]).toBeCloseTo(0, 5);
48
+ // Normal = normalize(eye - position) = normalize((-3, 0, 10)).
49
+ const length = Math.hypot(-3, 0, 10);
50
+ expect(m[8]).toBeCloseTo(-3 / length, 5);
51
+ expect(m[9]).toBeCloseTo(0, 5);
52
+ expect(m[10]).toBeCloseTo(10 / length, 5);
53
+ });
54
+
55
+ it('full mode preserves world scale in the facing basis', () => {
56
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
57
+ setVector3(billboard.scale, 2, 2, 2);
58
+ invalidateNodeLocalTransform(billboard);
59
+ orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10));
60
+ const m = getNodeWorldMatrix4(billboard).m;
61
+ expect(Math.hypot(m[0], m[1], m[2])).toBeCloseTo(2, 5);
62
+ expect(Math.hypot(m[4], m[5], m[6])).toBeCloseTo(2, 5);
63
+ expect(Math.hypot(m[8], m[9], m[10])).toBeCloseTo(2, 5);
64
+ });
65
+
66
+ it('axisY mode yaws about world +Y and stays upright', () => {
67
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'axisY');
68
+ orientBillboardToCamera(billboard, cameraLookingFrom(10, 5, 0));
69
+ const m = getNodeWorldMatrix4(billboard).m;
70
+ // Normal projected onto XZ toward the camera = +X; up stays world +Y; right = up x normal = -Z.
71
+ expect(m[8]).toBeCloseTo(1, 5);
72
+ expect(m[9]).toBeCloseTo(0, 5);
73
+ expect(m[10]).toBeCloseTo(0, 5);
74
+ expect(m[4]).toBeCloseTo(0, 5);
75
+ expect(m[5]).toBeCloseTo(1, 5);
76
+ expect(m[6]).toBeCloseTo(0, 5);
77
+ expect(m[0]).toBeCloseTo(0, 5);
78
+ expect(m[2]).toBeCloseTo(-1, 5);
79
+ });
80
+
81
+ it('screenAligned mode adopts the camera basis regardless of position', () => {
82
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'screenAligned');
83
+ setVector3(billboard.position, 5, 3, 0);
84
+ invalidateNodeLocalTransform(billboard);
85
+ orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10));
86
+ const m = getNodeWorldMatrix4(billboard).m;
87
+ expect(m[0]).toBeCloseTo(1, 5);
88
+ expect(m[5]).toBeCloseTo(1, 5);
89
+ expect(m[10]).toBeCloseTo(1, 5);
90
+ expect(m[12]).toBeCloseTo(5, 5);
91
+ expect(m[13]).toBeCloseTo(3, 5);
92
+ expect(m[14]).toBeCloseTo(0, 5);
93
+ });
94
+
95
+ it('is stable across repeated orientation (no scale or position drift)', () => {
96
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
97
+ setVector3(billboard.position, 3, 0, 0);
98
+ invalidateNodeLocalTransform(billboard);
99
+ setVector3(billboard.scale, 2, 2, 2);
100
+ invalidateNodeLocalTransform(billboard);
101
+ const camera = cameraLookingFrom(0, 0, 10);
102
+ orientBillboardToCamera(billboard, camera);
103
+ orientBillboardToCamera(billboard, camera);
104
+ orientBillboardToCamera(billboard, camera);
105
+ const m = getNodeWorldMatrix4(billboard).m;
106
+ expect(m[12]).toBeCloseTo(3, 5);
107
+ expect(Math.hypot(m[0], m[1], m[2])).toBeCloseTo(2, 5);
108
+ expect(Math.hypot(m[8], m[9], m[10])).toBeCloseTo(2, 5);
109
+ });
110
+ });
111
+
112
+ describe('orientScene3DBillboardsToCamera', () => {
113
+ it('orients billboards in a subtree and leaves non-billboards untouched', () => {
114
+ const root = createNode3D();
115
+ const mesh = createMesh(createPlaneMeshGeometry(), [null]);
116
+ setVector3(mesh.position, 7, 0, 0);
117
+ invalidateNodeLocalTransform(mesh);
118
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
119
+ addNodeChild(root, mesh);
120
+ addNodeChild(root, billboard);
121
+
122
+ orientScene3DBillboardsToCamera(root, cameraLookingFrom(0, 0, 10));
123
+
124
+ // Billboard faces the camera.
125
+ const b = getNodeWorldMatrix4(billboard).m;
126
+ expect(b[0]).toBeCloseTo(1, 5);
127
+ expect(b[10]).toBeCloseTo(1, 5);
128
+ // Mesh is untouched: its authored position is intact.
129
+ const meshMatrix = getNodeWorldMatrix4(mesh).m;
130
+ expect(meshMatrix[12]).toBeCloseTo(7, 5);
131
+ });
132
+
133
+ it('faces the camera in world space regardless of parent rotation', () => {
134
+ const parent = createNode3D();
135
+ const rotation = createQuaternion(0, 0, 0, 1);
136
+ setQuaternionFromAxisAngle(rotation, { x: 0, y: 1, z: 0 }, Math.PI / 2);
137
+ copyQuaternion(parent.rotation, rotation);
138
+ invalidateNodeLocalTransform(parent);
139
+ const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full');
140
+ addNodeChild(parent, billboard);
141
+
142
+ orientScene3DBillboardsToCamera(parent, cameraLookingFrom(0, 0, 10));
143
+
144
+ // Parent is yawed 90°, but the billboard's world basis still faces the camera (identity).
145
+ const m = getNodeWorldMatrix4(billboard).m;
146
+ expect(m[0]).toBeCloseTo(1, 5);
147
+ expect(m[5]).toBeCloseTo(1, 5);
148
+ expect(m[10]).toBeCloseTo(1, 5);
149
+ expect(m[2]).toBeCloseTo(0, 5);
150
+ expect(m[8]).toBeCloseTo(0, 5);
151
+ });
152
+ });
@@ -0,0 +1,247 @@
1
+ import { createStandardPbrMaterial } from '@flighthq/materials';
2
+ import { createBoxMeshGeometry, getMeshGeometryVertexCount } from '@flighthq/mesh';
3
+ import { updateMeshMorph } from '@flighthq/mesh';
4
+ import {
5
+ addNodeChild,
6
+ getNodeChildCount,
7
+ getNodeLocalMatrix4,
8
+ getNodeWorldMatrix4,
9
+ invalidateNodeLocalTransform,
10
+ } from '@flighthq/node';
11
+ import type { MeshMorph, Skin } from '@flighthq/types';
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ import {
15
+ cloneMesh,
16
+ createMesh,
17
+ enableMeshSignals,
18
+ getMeshDeformer,
19
+ getMeshRuntime,
20
+ getMeshSignals,
21
+ isMesh,
22
+ MeshKind,
23
+ } from './mesh';
24
+ import { createNode3D } from './sceneNode';
25
+
26
+ describe('cloneMesh', () => {
27
+ it('shares the geometry by reference', () => {
28
+ const geometry = createBoxMeshGeometry();
29
+ const source = createMesh(geometry, [createStandardPbrMaterial()]);
30
+ expect(cloneMesh(source).geometry).toBe(geometry);
31
+ });
32
+
33
+ it('copies the materials array but shares its entries', () => {
34
+ const material = createStandardPbrMaterial();
35
+ const source = createMesh(createBoxMeshGeometry(), [material]);
36
+ const clone = cloneMesh(source);
37
+ expect(clone.materials).not.toBe(source.materials);
38
+ expect(clone.materials[0]).toBe(material);
39
+ });
40
+
41
+ it('copies the transform into a distinct matrix', () => {
42
+ const source = createMesh(createBoxMeshGeometry(), []);
43
+ source.position.x = 5;
44
+ source.position.y = -2;
45
+ const clone = cloneMesh(source);
46
+ expect(clone.position).not.toBe(source.position);
47
+ expect(getNodeLocalMatrix4(clone).m[12]).toBe(5);
48
+ expect(getNodeLocalMatrix4(clone).m[13]).toBe(-2);
49
+ });
50
+
51
+ it('copies alpha, enabled, name, and kind', () => {
52
+ const source = createMesh(createBoxMeshGeometry(), [], 'Custom', { enabled: false, name: 'hero' });
53
+ source.alpha = 0.25;
54
+ const clone = cloneMesh(source);
55
+ expect(clone.alpha).toBe(0.25);
56
+ expect(clone.enabled).toBe(false);
57
+ expect(clone.name).toBe('hero');
58
+ expect(clone.kind).toBe('Custom');
59
+ });
60
+
61
+ it('shares skin pose explicitly but detaches CPU-deformed geometry', () => {
62
+ const skin = {} as Skin;
63
+ const source = createMesh(createBoxMeshGeometry(), []);
64
+ source.skin = skin;
65
+ const clone = cloneMesh(source);
66
+ expect(clone.skin).toBe(skin);
67
+ expect(clone.geometry).not.toBe(source.geometry);
68
+ });
69
+
70
+ it('shares immutable morph targets but detaches weights and geometry', () => {
71
+ const targets: MeshMorph['targets'] = [];
72
+ const morph: MeshMorph = { targets, weights: new Float32Array([0.5]) };
73
+ const source = createMesh(createBoxMeshGeometry(), []);
74
+ source.morph = morph;
75
+ const clone = cloneMesh(source);
76
+ expect(clone.morph).not.toBe(morph);
77
+ expect(clone.morph?.targets).toBe(targets);
78
+ expect(clone.morph?.weights).not.toBe(morph.weights);
79
+ expect(clone.morph?.weights[0]).toBe(0.5);
80
+ expect(clone.geometry).not.toBe(source.geometry);
81
+ });
82
+
83
+ it('restores base geometry when cloning an already-morphed mesh', () => {
84
+ const geometry = createBoxMeshGeometry();
85
+ const deltas = new Float32Array(getMeshGeometryVertexCount(geometry) * 3);
86
+ deltas[0] = 5;
87
+ const source = createMesh(geometry, []);
88
+ source.morph = {
89
+ targets: [{ normalDeltas: null, positionDeltas: deltas, tangentDeltas: null }],
90
+ weights: new Float32Array([1]),
91
+ };
92
+ const baseX = geometry.vertices[0];
93
+ updateMeshMorph(source);
94
+ expect(source.geometry.vertices[0]).toBe(baseX + 5);
95
+
96
+ const clone = cloneMesh(source);
97
+
98
+ expect(clone.geometry.vertices[0]).toBe(baseX);
99
+ expect(clone.morph?.weights[0]).toBe(1);
100
+ if (clone.morph) clone.morph.weights[0] = 2;
101
+ updateMeshMorph(clone);
102
+ expect(clone.geometry.vertices[0]).toBe(baseX + 10);
103
+ expect(source.geometry.vertices[0]).toBe(baseX + 5);
104
+ });
105
+
106
+ it('does not copy children', () => {
107
+ const source = createMesh(createBoxMeshGeometry(), []);
108
+ addNodeChild(source, createMesh(createBoxMeshGeometry(), []));
109
+ expect(getNodeChildCount(cloneMesh(source))).toBe(0);
110
+ });
111
+ });
112
+
113
+ describe('createMesh', () => {
114
+ it('uses MeshKind by default', () => {
115
+ const mesh = createMesh(createBoxMeshGeometry(), [createStandardPbrMaterial()]);
116
+ expect(mesh.kind).toBe(MeshKind);
117
+ });
118
+
119
+ it('accepts a custom kind', () => {
120
+ const MyKind = 'MyKind';
121
+ const mesh = createMesh(createBoxMeshGeometry(), [], MyKind);
122
+ expect(mesh.kind).toBe(MyKind);
123
+ });
124
+
125
+ it('stores the geometry by reference', () => {
126
+ const geometry = createBoxMeshGeometry();
127
+ const mesh = createMesh(geometry, [createStandardPbrMaterial()]);
128
+ expect(mesh.geometry).toBe(geometry);
129
+ });
130
+
131
+ it('stores the materials array by reference', () => {
132
+ const materials = [createStandardPbrMaterial()];
133
+ const mesh = createMesh(createBoxMeshGeometry(), materials);
134
+ expect(mesh.materials).toBe(materials);
135
+ });
136
+
137
+ it('accepts a null material slot', () => {
138
+ const mesh = createMesh(createBoxMeshGeometry(), [null]);
139
+ expect(mesh.materials[0]).toBeNull();
140
+ });
141
+
142
+ it('defaults enabled to true and name to null', () => {
143
+ const mesh = createMesh(createBoxMeshGeometry(), []);
144
+ expect(mesh.enabled).toBe(true);
145
+ expect(mesh.name).toBe(null);
146
+ });
147
+
148
+ it('accepts partial initial values', () => {
149
+ const mesh = createMesh(createBoxMeshGeometry(), [], MeshKind, { enabled: false, name: 'crate' });
150
+ expect(mesh.enabled).toBe(false);
151
+ expect(mesh.name).toBe('crate');
152
+ });
153
+
154
+ it('starts with an identity localMatrix', () => {
155
+ const mesh = createMesh(createBoxMeshGeometry(), []);
156
+ expect(getNodeLocalMatrix4(mesh).m[0]).toBe(1);
157
+ expect(getNodeLocalMatrix4(mesh).m[5]).toBe(1);
158
+ expect(getNodeLocalMatrix4(mesh).m[10]).toBe(1);
159
+ expect(getNodeLocalMatrix4(mesh).m[15]).toBe(1);
160
+ });
161
+
162
+ it('participates in the Node3D hierarchy and world transform', () => {
163
+ const parent = createMesh(createBoxMeshGeometry(), []);
164
+ const child = createMesh(createBoxMeshGeometry(), []);
165
+ addNodeChild(parent, child);
166
+ parent.position.x = 4;
167
+ invalidateNodeLocalTransform(parent);
168
+ child.position.x = 3;
169
+ invalidateNodeLocalTransform(child);
170
+ expect(getNodeChildCount(parent)).toBe(1);
171
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(7);
172
+ });
173
+ });
174
+
175
+ describe('enableMeshSignals', () => {
176
+ it('creates and returns the signal bag on first call', () => {
177
+ const mesh = createMesh(createBoxMeshGeometry(), []);
178
+ const signals = enableMeshSignals(mesh);
179
+ expect(signals.onChildAdded).toBeDefined();
180
+ expect(signals.onParentChanged).toBeDefined();
181
+ });
182
+
183
+ it('returns the same object on subsequent calls', () => {
184
+ const mesh = createMesh(createBoxMeshGeometry(), []);
185
+ expect(enableMeshSignals(mesh)).toBe(enableMeshSignals(mesh));
186
+ });
187
+ });
188
+
189
+ describe('getMeshDeformer', () => {
190
+ it('reports none for a rigid mesh', () => {
191
+ expect(getMeshDeformer(createMesh(createBoxMeshGeometry(), []))).toBe('none');
192
+ });
193
+
194
+ it('reports skeletal when the mesh carries a skin', () => {
195
+ const mesh = createMesh(createBoxMeshGeometry(), []);
196
+ mesh.skin = {} as Skin;
197
+ expect(getMeshDeformer(mesh)).toBe('skeletal');
198
+ });
199
+
200
+ it('reports morph when the mesh carries a morph set', () => {
201
+ const mesh = createMesh(createBoxMeshGeometry(), []);
202
+ mesh.morph = { targets: [], weights: new Float32Array(0) };
203
+ expect(getMeshDeformer(mesh)).toBe('morph');
204
+ });
205
+
206
+ it('reports skeletal for the composed skin+morph case (skin is the outer deform)', () => {
207
+ const mesh = createMesh(createBoxMeshGeometry(), []);
208
+ mesh.skin = {} as Skin;
209
+ mesh.morph = { targets: [], weights: new Float32Array(0) };
210
+ expect(getMeshDeformer(mesh)).toBe('skeletal');
211
+ });
212
+ });
213
+
214
+ describe('getMeshRuntime', () => {
215
+ it('returns a runtime with the expected initial state', () => {
216
+ const mesh = createMesh(createBoxMeshGeometry(), []);
217
+ const runtime = getMeshRuntime(mesh);
218
+ expect(runtime.children).toBeNull();
219
+ expect(runtime.parent).toBeNull();
220
+ expect(runtime.worldMatrix4).toBeNull();
221
+ });
222
+ });
223
+
224
+ describe('getMeshSignals', () => {
225
+ it('returns null before signals are enabled', () => {
226
+ const mesh = createMesh(createBoxMeshGeometry(), []);
227
+ expect(getMeshSignals(mesh)).toBeNull();
228
+ });
229
+
230
+ it('returns the runtime nodeSignals after enableMeshSignals', () => {
231
+ const mesh = createMesh(createBoxMeshGeometry(), []);
232
+ const signals = enableMeshSignals(mesh);
233
+ expect(getMeshSignals(mesh)).toBe(signals);
234
+ expect(getMeshSignals(mesh)).toBe(getMeshRuntime(mesh).nodeSignals);
235
+ });
236
+ });
237
+
238
+ describe('isMesh', () => {
239
+ it('is true for a node created by createMesh', () => {
240
+ const mesh = createMesh(createBoxMeshGeometry(), []);
241
+ expect(isMesh(mesh)).toBe(true);
242
+ });
243
+
244
+ it('is false for a bare Node3D group', () => {
245
+ expect(isMesh(createNode3D())).toBe(false);
246
+ });
247
+ });
@@ -0,0 +1,68 @@
1
+ import { createMeshGeometry } from '@flighthq/mesh';
2
+ import { addNodeChild } from '@flighthq/node';
3
+ import type { MeshMorph, VertexAttributeLayout } from '@flighthq/types';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { createMesh } from './mesh';
7
+ import { prepareScene3DMorph } from './prepareScene3DMorph';
8
+ import { createNode3D } from './sceneNode';
9
+
10
+ const POSITION_LAYOUT: VertexAttributeLayout = {
11
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
12
+ stride: 12,
13
+ };
14
+
15
+ function morphedMesh(positions: readonly number[], deltas: readonly number[], weight: number) {
16
+ const geometry = createMeshGeometry({ layout: POSITION_LAYOUT, vertices: new Float32Array(positions) });
17
+ const mesh = createMesh(geometry, []);
18
+ const morph: MeshMorph = {
19
+ targets: [{ normalDeltas: null, positionDeltas: new Float32Array(deltas), tangentDeltas: null }],
20
+ weights: new Float32Array([weight]),
21
+ };
22
+ mesh.morph = morph;
23
+ return mesh;
24
+ }
25
+
26
+ describe('prepareScene3DMorph', () => {
27
+ it('blends every morphed mesh in the subtree into its geometry', () => {
28
+ const scene = createNode3D();
29
+ const a = morphedMesh([0, 0, 0], [1, 0, 0], 1);
30
+ const group = createNode3D();
31
+ const b = morphedMesh([0, 0, 0], [0, 2, 0], 1);
32
+ addNodeChild(group, b);
33
+ addNodeChild(scene, a);
34
+ addNodeChild(scene, group);
35
+
36
+ prepareScene3DMorph(scene);
37
+
38
+ expect(a.geometry.vertices[0]).toBe(1);
39
+ expect(b.geometry.vertices[1]).toBe(2);
40
+ });
41
+
42
+ it('skips disabled subtrees', () => {
43
+ const scene = createNode3D();
44
+ const disabled = createNode3D(undefined, { enabled: false });
45
+ const mesh = morphedMesh([0, 0, 0], [5, 0, 0], 1);
46
+ addNodeChild(disabled, mesh);
47
+ addNodeChild(scene, disabled);
48
+
49
+ prepareScene3DMorph(scene);
50
+
51
+ // Untouched: the disabled subtree is never walked.
52
+ expect(mesh.geometry.vertices[0]).toBe(0);
53
+ });
54
+
55
+ it('is a no-op for a scene with no morphed meshes', () => {
56
+ const scene = createNode3D();
57
+ const rigid = createMesh(
58
+ createMeshGeometry({ layout: POSITION_LAYOUT, vertices: new Float32Array([3, 3, 3]) }),
59
+ [],
60
+ );
61
+ addNodeChild(scene, rigid);
62
+ const version = rigid.geometry.version;
63
+
64
+ prepareScene3DMorph(scene);
65
+
66
+ expect(rigid.geometry.version).toBe(version);
67
+ });
68
+ });
@@ -0,0 +1,53 @@
1
+ import { addNodeChild, getNodeChildCount, getNodeLocalMatrix4, getNodeRoot } from '@flighthq/node';
2
+ import { Node3DKind } from '@flighthq/types';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { createScene3D } from './scene';
6
+ import { createNode3D, getNode3DRuntime } from './sceneNode';
7
+
8
+ describe('createScene3D', () => {
9
+ it('owns a root Node3D with Node3DKind', () => {
10
+ const scene = createScene3D();
11
+ expect(scene.root.kind).toBe(Node3DKind);
12
+ });
13
+
14
+ it('starts with empty animations and null metadata', () => {
15
+ const scene = createScene3D();
16
+ expect(scene.animations).toEqual({});
17
+ expect(scene.metadata).toBeNull();
18
+ });
19
+
20
+ it('defaults the root enabled to true and name to null', () => {
21
+ const scene = createScene3D();
22
+ expect(scene.root.enabled).toBe(true);
23
+ expect(scene.root.name).toBe(null);
24
+ });
25
+
26
+ it('passes partial initial values to the root', () => {
27
+ const scene = createScene3D({ enabled: false, name: 'world' });
28
+ expect(scene.root.enabled).toBe(false);
29
+ expect(scene.root.name).toBe('world');
30
+ });
31
+
32
+ it('starts the root with an identity localMatrix and a null worldMatrix slot', () => {
33
+ const scene = createScene3D();
34
+ expect(getNodeLocalMatrix4(scene.root).m[0]).toBe(1);
35
+ expect(getNodeLocalMatrix4(scene.root).m[5]).toBe(1);
36
+ expect(getNodeLocalMatrix4(scene.root).m[10]).toBe(1);
37
+ expect(getNodeLocalMatrix4(scene.root).m[15]).toBe(1);
38
+ expect(getNode3DRuntime(scene.root).worldMatrix4).toBeNull();
39
+ });
40
+
41
+ it('starts the root with no children', () => {
42
+ const scene = createScene3D();
43
+ expect(getNodeChildCount(scene.root)).toBe(0);
44
+ });
45
+
46
+ it('the root parents nodes attached beneath it', () => {
47
+ const scene = createScene3D();
48
+ const child = createNode3D();
49
+ addNodeChild(scene.root, child);
50
+ expect(getNodeRoot(child)).toBe(scene.root);
51
+ expect(getNodeChildCount(scene.root)).toBe(1);
52
+ });
53
+ });