@flighthq/scene-document 0.4.1-next.37.ff47e13 → 0.4.1-next.515.b0834fe

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 (40) hide show
  1. package/README.md +2 -2
  2. package/dist/contract.d.ts +2 -1
  3. package/dist/contract.d.ts.map +1 -1
  4. package/dist/contract.js +2 -1
  5. package/dist/contract.js.map +1 -1
  6. package/dist/flightDocumentText.d.ts +5 -0
  7. package/dist/flightDocumentText.d.ts.map +1 -0
  8. package/dist/flightDocumentText.js +617 -0
  9. package/dist/flightDocumentText.js.map +1 -0
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/sceneDocumentMaterializationSelection.d.ts +13 -0
  15. package/dist/sceneDocumentMaterializationSelection.d.ts.map +1 -0
  16. package/dist/sceneDocumentMaterializationSelection.js +55 -0
  17. package/dist/sceneDocumentMaterializationSelection.js.map +1 -0
  18. package/dist/sceneDocumentRefusal.d.ts +6 -0
  19. package/dist/sceneDocumentRefusal.d.ts.map +1 -0
  20. package/dist/sceneDocumentRefusal.js +60 -0
  21. package/dist/sceneDocumentRefusal.js.map +1 -0
  22. package/dist/sceneDocumentScene2DMaterialization.d.ts +4 -5
  23. package/dist/sceneDocumentScene2DMaterialization.d.ts.map +1 -1
  24. package/dist/sceneDocumentScene2DMaterialization.js +81 -277
  25. package/dist/sceneDocumentScene2DMaterialization.js.map +1 -1
  26. package/dist/sceneDocumentScene3DMaterialization.d.ts +4 -4
  27. package/dist/sceneDocumentScene3DMaterialization.d.ts.map +1 -1
  28. package/dist/sceneDocumentScene3DMaterialization.js +145 -291
  29. package/dist/sceneDocumentScene3DMaterialization.js.map +1 -1
  30. package/dist/sceneDocumentYamlSubset.d.ts +3 -1
  31. package/dist/sceneDocumentYamlSubset.d.ts.map +1 -1
  32. package/dist/sceneDocumentYamlSubset.js.map +1 -1
  33. package/package.json +10 -9
  34. package/src/flightDocumentText.test.ts +179 -0
  35. package/src/sceneDocumentMaterializationSelection.test.ts +56 -0
  36. package/src/sceneDocumentMultiSceneMaterialization.test.ts +209 -0
  37. package/src/sceneDocumentRefusal.test.ts +143 -0
  38. package/src/sceneDocumentScene2DMaterialization.test.ts +188 -62
  39. package/src/sceneDocumentScene3DMaterialization.test.ts +485 -159
  40. package/src/sceneDocumentYamlSubset.test.ts +8 -0
@@ -1,29 +1,40 @@
1
- import { createTransform3D } from '@flighthq/geometry/contract';
2
- import { createAmbientLight, createDirectionalLight } from '@flighthq/lighting/contract';
3
- import { getNodeChildren } from '@flighthq/node/contract';
1
+ import { createTransform3D, createVector3, setQuaternionFromAxisAngle } from '@flighthq/geometry/contract';
2
+ import {
3
+ createAmbientLight,
4
+ createDirectionalLight,
5
+ createHemisphereLight,
6
+ createPointLight,
7
+ createSpotLight,
8
+ } from '@flighthq/lighting/contract';
9
+ import { getNodeChildren, invalidateNodeLocalTransform } from '@flighthq/node/contract';
4
10
  import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
11
+ import { createDisplayObject } from '@flighthq/scene2d/contract';
5
12
  import { createNode3D, createScene3D } from '@flighthq/scene3d/contract';
6
13
  import type {
7
14
  FlightDocument,
8
15
  FlightDocumentFields,
9
16
  FlightDocumentNodeSchema,
17
+ FlightDocumentResourceDescriptor,
10
18
  FlightDocumentResourceLookup,
11
- FlightDocumentScene3D,
19
+ FlightDocumentScene,
12
20
  FlightDocumentScene3DMaterialization,
13
21
  FlightDocumentSchemaRegistry,
22
+ Light,
14
23
  NodeAny,
24
+ Scene3DLightsLike,
15
25
  Scene3DDocumentCamera,
16
26
  Scene3DDocumentLight,
17
27
  } from '@flighthq/types/contract';
18
28
  import {
19
29
  AmbientLightKind,
20
30
  DirectionalLightKind,
31
+ DisplayObjectKind,
21
32
  FlightDocumentRefusalReason,
22
33
  Node3DKind,
23
34
  } from '@flighthq/types/contract';
24
35
  import { describe, expect, expectTypeOf, it } from 'vitest';
25
36
 
26
- import { serializeFlightDocument } from './sceneDocumentScene2DMaterialization';
37
+ import { formatFlightDocumentText } from './flightDocumentText';
27
38
  import {
28
39
  createFlightDocumentFromScene3D,
29
40
  createFlightDocumentScene3DMaterialization,
@@ -37,7 +48,6 @@ describe('createFlightDocumentFromScene3D', () => {
37
48
  const scene = createScene3D();
38
49
  const document = createFlightDocumentFromScene3D(scene, [], [], createTestSchemas());
39
50
  expect(document.kind).toBe('Scene3D');
40
- expect(document.version).toBe(1);
41
51
  expect(document.scene.children).toHaveLength(0);
42
52
  expect(document.cameras).toHaveLength(0);
43
53
  expect(document.lights).toHaveLength(0);
@@ -71,14 +81,12 @@ describe('createFlightDocumentFromScene3D', () => {
71
81
 
72
82
  describe('createFlightDocumentScene3DMaterialization', () => {
73
83
  it('materializes an empty Scene3D from a minimal model', () => {
74
- const document: FlightDocumentScene3D = {
84
+ const document = createTestDocument({
75
85
  cameras: [],
76
86
  kind: 'Scene3D',
77
87
  lights: [],
78
- resources: [],
79
88
  scene: { children: [], fields: {}, kind: Node3DKind },
80
- version: 1,
81
- };
89
+ });
82
90
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
83
91
  expect(result).not.toBeNull();
84
92
  const materialization = result as FlightDocumentScene3DMaterialization;
@@ -88,7 +96,7 @@ describe('createFlightDocumentScene3DMaterialization', () => {
88
96
  });
89
97
 
90
98
  it('materializes cameras from document camera descriptors', () => {
91
- const document: FlightDocumentScene3D = {
99
+ const document = createTestDocument({
92
100
  cameras: [
93
101
  {
94
102
  far: 500,
@@ -99,10 +107,8 @@ describe('createFlightDocumentScene3DMaterialization', () => {
99
107
  ],
100
108
  kind: 'Scene3D',
101
109
  lights: [],
102
- resources: [],
103
110
  scene: { children: [], fields: {}, kind: Node3DKind },
104
- version: 1,
105
- };
111
+ });
106
112
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
107
113
  expect(result).not.toBeNull();
108
114
  const materialization = result as FlightDocumentScene3DMaterialization;
@@ -112,19 +118,133 @@ describe('createFlightDocumentScene3DMaterialization', () => {
112
118
  expect(materialization.cameras[0].projection.kind).toBe('perspective');
113
119
  });
114
120
 
121
+ it('applies authored camera placement to the runtime view matrix', () => {
122
+ const transform = createTransform3D();
123
+ transform.position.x = 7;
124
+ const document = createTestDocument({
125
+ cameras: [
126
+ {
127
+ far: 500,
128
+ near: 0.5,
129
+ projection: { aspect: 1.77, fovY: 1.047, kind: 'perspective' },
130
+ transform,
131
+ },
132
+ ],
133
+ kind: 'Scene3D',
134
+ lights: [],
135
+ scene: { children: [], fields: {}, kind: Node3DKind },
136
+ });
137
+
138
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
139
+
140
+ expect(result?.cameras[0].view.m[12]).toBe(-7);
141
+ });
142
+
143
+ it('applies a bound depth-first node placement to the runtime camera', () => {
144
+ const transform = createTransform3D();
145
+ transform.position.x = 7;
146
+ const document = createTestDocument({
147
+ cameras: [
148
+ {
149
+ far: 500,
150
+ near: 0.5,
151
+ node: 2,
152
+ projection: { aspect: 1.77, fovY: 1.047, kind: 'perspective' },
153
+ transform,
154
+ },
155
+ ],
156
+ kind: 'Scene3D',
157
+ lights: [],
158
+ scene: {
159
+ children: [
160
+ {
161
+ children: [{ children: [], fields: { positionX: 3 }, kind: Node3DKind }],
162
+ fields: { positionX: 2 },
163
+ kind: Node3DKind,
164
+ },
165
+ ],
166
+ fields: {},
167
+ kind: Node3DKind,
168
+ },
169
+ });
170
+
171
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
172
+
173
+ expect(result?.cameras[0].view.m[12]).toBe(-5);
174
+ });
175
+
176
+ it('preserves the source-derived representable light population and authored placement', () => {
177
+ const transform = createTransform3D();
178
+ transform.position.x = 10;
179
+ transform.position.y = 20;
180
+ transform.position.z = 30;
181
+ transform.scale.x = 2;
182
+ transform.scale.y = 3;
183
+ transform.scale.z = 4;
184
+ setQuaternionFromAxisAngle(transform.rotation, createVector3(0, 1, 0), Math.PI / 2);
185
+ const authoredLights: Required<Scene3DLightsLike> = {
186
+ ambient: createAmbientLight(),
187
+ directional: createDirectionalLight({ direction: { x: 0, y: 0, z: -1 } }),
188
+ hemisphere: [createHemisphereLight()],
189
+ point: [createPointLight({ position: { x: 1, y: 0, z: 0 } })],
190
+ spot: [createSpotLight({ direction: { x: 0, y: 0, z: -1 }, position: { x: 1, y: 0, z: 0 } })],
191
+ };
192
+ const document = createTestDocument({
193
+ cameras: [],
194
+ kind: 'Scene3D',
195
+ lights: getDocumentLights(authoredLights, transform),
196
+ scene: { children: [], fields: {}, kind: Node3DKind },
197
+ });
198
+
199
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
200
+
201
+ expect(getScene3DLightKinds(result?.lights ?? null).sort()).toEqual(getScene3DLightKinds(authoredLights).sort());
202
+ expect(result?.lights.directional?.direction.x).toBeCloseTo(-1);
203
+ expect(result?.lights.directional?.direction.y).toBeCloseTo(0);
204
+ expect(result?.lights.directional?.direction.z).toBeCloseTo(0);
205
+ expect(result?.lights.point?.[0].position).toMatchObject({ x: 10, y: 20, z: 28 });
206
+ expect(result?.lights.spot?.[0].position).toMatchObject({ x: 10, y: 20, z: 28 });
207
+ expect(result?.lights.spot?.[0].direction.x).toBeCloseTo(-1);
208
+ expect(result?.lights.spot?.[0].direction.y).toBeCloseTo(0);
209
+ expect(result?.lights.spot?.[0].direction.z).toBeCloseTo(0);
210
+ });
211
+
212
+ it('applies a bound depth-first node placement to a punctual light', () => {
213
+ const transform = createTransform3D();
214
+ transform.position.x = 7;
215
+ const document = createTestDocument({
216
+ cameras: [],
217
+ kind: 'Scene3D',
218
+ lights: [{ descriptor: createPointLight(), node: 2, transform }],
219
+ scene: {
220
+ children: [
221
+ {
222
+ children: [{ children: [], fields: { positionX: 3 }, kind: Node3DKind }],
223
+ fields: { positionX: 2 },
224
+ kind: Node3DKind,
225
+ },
226
+ ],
227
+ fields: {},
228
+ kind: Node3DKind,
229
+ },
230
+ });
231
+
232
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
233
+
234
+ expect(result?.lights.point?.[0].position).toMatchObject({ x: 5, y: 0, z: 0 });
235
+ });
236
+
115
237
  it('materializes a tree with children', () => {
116
- const document: FlightDocumentScene3D = {
238
+ const document = createTestDocument({
117
239
  cameras: [],
118
240
  kind: 'Scene3D',
119
241
  lights: [],
120
- resources: [],
121
242
  scene: {
122
243
  children: [{ children: [], fields: {}, kind: Node3DKind }],
123
244
  fields: {},
124
245
  kind: Node3DKind,
125
246
  },
126
- version: 1,
127
- };
247
+ });
128
248
  const schemas = createTestSchemas();
129
249
  const result = createFlightDocumentScene3DMaterialization(document, schemas);
130
250
  expect(result).not.toBeNull();
@@ -135,58 +255,53 @@ describe('createFlightDocumentScene3DMaterialization', () => {
135
255
  });
136
256
 
137
257
  it('returns null for a Scene2D document', () => {
138
- const document = {
258
+ const document = createTestDocument({
139
259
  backgroundColor: null,
140
260
  kind: 'Scene2D',
141
- resources: [],
142
261
  scene: { children: [], fields: {}, kind: 'DisplayObject' },
143
- version: 1,
144
- } as FlightDocument;
262
+ });
145
263
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
146
264
  expect(result).toBeNull();
147
265
  });
148
266
 
149
267
  it('returns null for an unsupported version', () => {
150
268
  const document = {
151
- cameras: [],
152
- kind: 'Scene3D',
153
- lights: [],
154
- resources: [],
155
- scene: { children: [], fields: {}, kind: Node3DKind },
269
+ ...createTestDocument({
270
+ cameras: [],
271
+ kind: 'Scene3D',
272
+ lights: [],
273
+ scene: { children: [], fields: {}, kind: Node3DKind },
274
+ }),
156
275
  version: 99,
157
- } as unknown as FlightDocumentScene3D;
276
+ } as unknown as FlightDocument;
158
277
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
159
278
  expect(result).toBeNull();
160
279
  });
161
280
 
162
281
  it('returns null for duplicate ambient lights', () => {
163
- const document: FlightDocumentScene3D = {
282
+ const document = createTestDocument({
164
283
  cameras: [],
165
284
  kind: 'Scene3D',
166
285
  lights: [
167
286
  { descriptor: createAmbientLight(), transform: createTransform3D() },
168
287
  { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
169
288
  ],
170
- resources: [],
171
289
  scene: { children: [], fields: {}, kind: Node3DKind },
172
- version: 1,
173
- };
290
+ });
174
291
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
175
292
  expect(result).toBeNull();
176
293
  });
177
294
 
178
295
  it('returns null for duplicate directional lights', () => {
179
- const document: FlightDocumentScene3D = {
296
+ const document = createTestDocument({
180
297
  cameras: [],
181
298
  kind: 'Scene3D',
182
299
  lights: [
183
300
  { descriptor: createDirectionalLight(), transform: createTransform3D() },
184
301
  { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
185
302
  ],
186
- resources: [],
187
303
  scene: { children: [], fields: {}, kind: Node3DKind },
188
- version: 1,
189
- };
304
+ });
190
305
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
191
306
  expect(result).toBeNull();
192
307
  });
@@ -194,7 +309,14 @@ describe('createFlightDocumentScene3DMaterialization', () => {
194
309
 
195
310
  describe('createFlightDocumentScene3DMaterializationFromText', () => {
196
311
  it('materializes a Scene3D from YAML text', () => {
197
- const yaml = ['flight: 1', 'kind: Scene3D', 'scene:', ' kind: Node3D'].join('\n');
312
+ const yaml = [
313
+ 'flight: 1',
314
+ 'defaultScene: 0',
315
+ 'scenes:',
316
+ ' - kind: Scene3D',
317
+ ' scene:',
318
+ ' kind: Node3D',
319
+ ].join('\n');
198
320
  const schemas = createTestSchemas();
199
321
  const result = createFlightDocumentScene3DMaterializationFromText(yaml, schemas);
200
322
  expect(result).not.toBeNull();
@@ -203,87 +325,43 @@ describe('createFlightDocumentScene3DMaterializationFromText', () => {
203
325
  });
204
326
 
205
327
  it('returns null for a Scene2D YAML document', () => {
206
- const yaml = ['flight: 1', 'kind: Scene2D', 'scene:', ' kind: DisplayObject'].join('\n');
328
+ const yaml = [
329
+ 'flight: 1',
330
+ 'defaultScene: 0',
331
+ 'scenes:',
332
+ ' - kind: Scene2D',
333
+ ' scene:',
334
+ ' kind: DisplayObject',
335
+ ].join('\n');
207
336
  const result = createFlightDocumentScene3DMaterializationFromText(yaml, createTestSchemas());
208
337
  expect(result).toBeNull();
209
338
  });
210
339
  });
211
340
 
212
- describe('duplicate-light explain parity', () => {
213
- // The table is annotated so each fixture is checked AGAINST THE SCHEMA rather than inferred from its own
214
- // literal. Without this, `version: 1` widens to `number` and the row stops being a `FlightDocument` — and
215
- // the two obvious local repairs both make the fixture worse: `1 as const` and a cast silence the widening
216
- // while also silencing every other way a row could stop matching the document type, which is the only
217
- // thing this parity test has to hold the fixtures to.
218
- it.each<{ document: Readonly<FlightDocument>; expectedReason: FlightDocumentRefusalReason; label: string }>([
219
- {
220
- label: 'duplicate ambient',
221
- document: {
222
- cameras: [],
223
- kind: 'Scene3D',
224
- lights: [
225
- { descriptor: createAmbientLight(), transform: createTransform3D() },
226
- { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
227
- ],
228
- resources: [],
229
- scene: { children: [], fields: {}, kind: Node3DKind },
230
- version: 1,
231
- },
232
- expectedReason: FlightDocumentRefusalReason.DuplicateAmbientLight,
233
- },
234
- {
235
- label: 'duplicate directional',
236
- document: {
237
- cameras: [],
238
- kind: 'Scene3D',
239
- lights: [
240
- { descriptor: createDirectionalLight(), transform: createTransform3D() },
241
- { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
242
- ],
243
- resources: [],
244
- scene: { children: [], fields: {}, kind: Node3DKind },
245
- version: 1,
246
- },
247
- expectedReason: FlightDocumentRefusalReason.DuplicateDirectionalLight,
248
- },
249
- ])('model and text explain agree on $label', ({ document, expectedReason }) => {
250
- const modelResult = explainFlightDocumentScene3DRefusal(document);
251
- const text = serializeFlightDocument(document);
252
- const textResult = explainFlightDocumentScene3DRefusalFromText(text);
253
- expect(modelResult).not.toBeNull();
254
- expect(textResult).not.toBeNull();
255
- expect(textResult!.reason).toBe(modelResult!.reason);
256
- expect(textResult!.path).toBe(modelResult!.path);
257
- expect(modelResult!.reason).toBe(expectedReason);
258
- expect(modelResult!.path).toBe('lights');
259
- });
260
- });
261
-
262
341
  describe('explainFlightDocumentScene3DRefusal', () => {
263
342
  it('explains a dimension mismatch', () => {
264
- const document = {
343
+ const document = createTestDocument({
265
344
  backgroundColor: null,
266
345
  kind: 'Scene2D',
267
- resources: [],
268
346
  scene: { children: [], fields: {}, kind: 'DisplayObject' },
269
- version: 1,
270
- } as FlightDocument;
271
- const explanation = explainFlightDocumentScene3DRefusal(document);
347
+ });
348
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
272
349
  expect(explanation).not.toBeNull();
273
350
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
274
- expect(explanation!.path).toBe('kind');
351
+ expect(explanation!.path).toBe('scenes[0].kind');
275
352
  });
276
353
 
277
354
  it('explains an unsupported version', () => {
278
355
  const document = {
279
- cameras: [],
280
- kind: 'Scene3D',
281
- lights: [],
282
- resources: [],
283
- scene: { children: [], fields: {}, kind: Node3DKind },
356
+ ...createTestDocument({
357
+ cameras: [],
358
+ kind: 'Scene3D',
359
+ lights: [],
360
+ scene: { children: [], fields: {}, kind: Node3DKind },
361
+ }),
284
362
  version: 99,
285
- } as unknown as FlightDocumentScene3D;
286
- const explanation = explainFlightDocumentScene3DRefusal(document);
363
+ } as unknown as FlightDocument;
364
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
287
365
  expect(explanation).not.toBeNull();
288
366
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
289
367
  expect(explanation!.path).toBe('version');
@@ -291,51 +369,45 @@ describe('explainFlightDocumentScene3DRefusal', () => {
291
369
  });
292
370
 
293
371
  it('explains a duplicate ambient light', () => {
294
- const document: FlightDocumentScene3D = {
372
+ const document = createTestDocument({
295
373
  cameras: [],
296
374
  kind: 'Scene3D',
297
375
  lights: [
298
376
  { descriptor: createAmbientLight(), transform: createTransform3D() },
299
377
  { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
300
378
  ],
301
- resources: [],
302
379
  scene: { children: [], fields: {}, kind: Node3DKind },
303
- version: 1,
304
- };
305
- const explanation = explainFlightDocumentScene3DRefusal(document);
380
+ });
381
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
306
382
  expect(explanation).not.toBeNull();
307
383
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateAmbientLight);
308
- expect(explanation!.path).toBe('lights');
384
+ expect(explanation!.path).toBe('scenes[0].lights');
309
385
  });
310
386
 
311
387
  it('explains a duplicate directional light', () => {
312
- const document: FlightDocumentScene3D = {
388
+ const document = createTestDocument({
313
389
  cameras: [],
314
390
  kind: 'Scene3D',
315
391
  lights: [
316
392
  { descriptor: createDirectionalLight(), transform: createTransform3D() },
317
393
  { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
318
394
  ],
319
- resources: [],
320
395
  scene: { children: [], fields: {}, kind: Node3DKind },
321
- version: 1,
322
- };
323
- const explanation = explainFlightDocumentScene3DRefusal(document);
396
+ });
397
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
324
398
  expect(explanation).not.toBeNull();
325
399
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateDirectionalLight);
326
- expect(explanation!.path).toBe('lights');
400
+ expect(explanation!.path).toBe('scenes[0].lights');
327
401
  });
328
402
 
329
403
  it('returns null when the document is valid', () => {
330
- const document: FlightDocumentScene3D = {
404
+ const document = createTestDocument({
331
405
  cameras: [],
332
406
  kind: 'Scene3D',
333
407
  lights: [],
334
- resources: [],
335
408
  scene: { children: [], fields: {}, kind: Node3DKind },
336
- version: 1,
337
- };
338
- const explanation = explainFlightDocumentScene3DRefusal(document);
409
+ });
410
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
339
411
  expect(explanation).toBeNull();
340
412
  });
341
413
  });
@@ -343,15 +415,15 @@ describe('explainFlightDocumentScene3DRefusal', () => {
343
415
  describe('explainFlightDocumentScene3DRefusalFromText', () => {
344
416
  it('explains an anchor refusal with parser position', () => {
345
417
  const yaml = 'flight: 1\nkind: Scene3D\nanchor: &ref value\n';
346
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
418
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
347
419
  expect(explanation).not.toBeNull();
348
420
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.AnchorUnsupported);
349
421
  expect(explanation!.line).toBeGreaterThan(0);
350
422
  });
351
423
 
352
424
  it('explains a version mismatch in valid YAML', () => {
353
- const yaml = 'flight: 99\nkind: Scene3D\nscene:\n kind: Node3D\n';
354
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
425
+ const yaml = 'flight: 99\ndefaultScene: 0\nscenes:\n - kind: Scene3D\n scene:\n kind: Node3D\n';
426
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
355
427
  expect(explanation).not.toBeNull();
356
428
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
357
429
  expect(explanation!.path).toBe('version');
@@ -359,54 +431,251 @@ describe('explainFlightDocumentScene3DRefusalFromText', () => {
359
431
  });
360
432
 
361
433
  it('explains a dimension mismatch in valid YAML', () => {
362
- const yaml = 'flight: 1\nkind: Scene2D\nscene:\n kind: DisplayObject\n';
363
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
434
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene2D\n scene:\n kind: DisplayObject\n';
435
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
364
436
  expect(explanation).not.toBeNull();
365
437
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
366
- expect(explanation!.path).toBe('kind');
438
+ expect(explanation!.path).toBe('scenes[0].kind');
367
439
  });
368
440
 
369
441
  it('explains a duplicate ambient light from text', () => {
370
- const yaml = [
371
- 'flight: 1',
372
- 'kind: Scene3D',
373
- 'scene:',
374
- ' kind: Node3D',
375
- 'lights:',
376
- ' - descriptor:',
377
- ' kind: AmbientLight',
378
- ' - descriptor:',
379
- ' kind: AmbientLight',
380
- ].join('\n');
381
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
442
+ const yaml = formatFlightDocumentText(
443
+ createTestDocument({
444
+ cameras: [],
445
+ kind: 'Scene3D',
446
+ lights: [
447
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
448
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
449
+ ],
450
+ scene: { children: [], fields: {}, kind: Node3DKind },
451
+ }),
452
+ );
453
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
382
454
  expect(explanation).not.toBeNull();
383
455
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateAmbientLight);
384
- expect(explanation!.path).toBe('lights');
456
+ expect(explanation!.path).toBe('scenes[0].lights');
385
457
  });
386
458
 
387
459
  it('explains a duplicate directional light from text', () => {
388
- const yaml = [
389
- 'flight: 1',
390
- 'kind: Scene3D',
391
- 'scene:',
392
- ' kind: Node3D',
393
- 'lights:',
394
- ' - descriptor:',
395
- ' kind: DirectionalLight',
396
- ' - descriptor:',
397
- ' kind: DirectionalLight',
398
- ].join('\n');
399
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
460
+ const yaml = formatFlightDocumentText(
461
+ createTestDocument({
462
+ cameras: [],
463
+ kind: 'Scene3D',
464
+ lights: [
465
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
466
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
467
+ ],
468
+ scene: { children: [], fields: {}, kind: Node3DKind },
469
+ }),
470
+ );
471
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
400
472
  expect(explanation).not.toBeNull();
401
473
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateDirectionalLight);
402
- expect(explanation!.path).toBe('lights');
474
+ expect(explanation!.path).toBe('scenes[0].lights');
403
475
  });
404
476
 
405
477
  it('returns null for valid Scene3D text', () => {
406
- const yaml = 'flight: 1\nkind: Scene3D\nscene:\n kind: Node3D\n';
407
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
478
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene3D\n scene:\n kind: Node3D\n';
479
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
480
+ expect(explanation).toBeNull();
481
+ });
482
+ });
483
+
484
+ describe('model-to-text explain parity', () => {
485
+ // Keep the table annotated so every fixture is checked against the container schema rather than inferred
486
+ // only from its own literal.
487
+ it.each<{
488
+ document: Readonly<FlightDocument>;
489
+ expectedPath: string;
490
+ expectedReason: FlightDocumentRefusalReason;
491
+ label: string;
492
+ }>([
493
+ {
494
+ label: 'duplicate ambient (scene 0)',
495
+ document: createTestDocument({
496
+ cameras: [],
497
+ kind: 'Scene3D',
498
+ lights: [
499
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
500
+ { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
501
+ ],
502
+ scene: { children: [], fields: {}, kind: Node3DKind },
503
+ }),
504
+ expectedReason: FlightDocumentRefusalReason.DuplicateAmbientLight,
505
+ expectedPath: 'scenes[0].lights',
506
+ },
507
+ {
508
+ label: 'duplicate directional (scene 0)',
509
+ document: createTestDocument({
510
+ cameras: [],
511
+ kind: 'Scene3D',
512
+ lights: [
513
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
514
+ { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
515
+ ],
516
+ scene: { children: [], fields: {}, kind: Node3DKind },
517
+ }),
518
+ expectedReason: FlightDocumentRefusalReason.DuplicateDirectionalLight,
519
+ expectedPath: 'scenes[0].lights',
520
+ },
521
+ {
522
+ label: 'wrong dimension (scene 0)',
523
+ document: createTestDocument({
524
+ backgroundColor: null,
525
+ kind: 'Scene2D',
526
+ scene: { children: [], fields: {}, kind: 'DisplayObject' },
527
+ }),
528
+ expectedReason: FlightDocumentRefusalReason.StructureInvalid,
529
+ expectedPath: 'scenes[0].kind',
530
+ },
531
+ {
532
+ label: 'unregistered node kind (scene 0)',
533
+ document: createTestDocument({
534
+ cameras: [],
535
+ kind: 'Scene3D',
536
+ lights: [],
537
+ scene: { children: [{ children: [], fields: {}, kind: 'acme.Unknown' }], fields: {}, kind: Node3DKind },
538
+ }),
539
+ expectedReason: FlightDocumentRefusalReason.NodeKindUnregistered,
540
+ expectedPath: 'scenes[0].scene.children[0]',
541
+ },
542
+ ])('model and text explain agree on $label', ({ document, expectedPath, expectedReason }) => {
543
+ const modelResult = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
544
+ const text = formatFlightDocumentText(document);
545
+ const textResult = explainFlightDocumentScene3DRefusalFromText(text, createTestSchemas());
546
+ expect(modelResult).not.toBeNull();
547
+ expect(textResult).not.toBeNull();
548
+ expect(textResult!.reason).toBe(modelResult!.reason);
549
+ expect(textResult!.path).toBe(modelResult!.path);
550
+ expect(modelResult!.reason).toBe(expectedReason);
551
+ expect(modelResult!.path).toBe(expectedPath);
552
+ });
553
+ });
554
+
555
+ describe('NodeKindUnregistered', () => {
556
+ it('materializer returns null for nested unregistered kind two levels deep', () => {
557
+ const document = createTestDocument({
558
+ cameras: [],
559
+ kind: 'Scene3D',
560
+ lights: [],
561
+ scene: {
562
+ children: [
563
+ {
564
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
565
+ fields: {},
566
+ kind: Node3DKind,
567
+ },
568
+ ],
569
+ fields: {},
570
+ kind: Node3DKind,
571
+ },
572
+ });
573
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
574
+ expect(result).toBeNull();
575
+ });
576
+
577
+ it('explain names the unregistered kind with qualified scene path', () => {
578
+ const document = createTestDocument({
579
+ cameras: [],
580
+ kind: 'Scene3D',
581
+ lights: [],
582
+ scene: {
583
+ children: [
584
+ {
585
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
586
+ fields: {},
587
+ kind: Node3DKind,
588
+ },
589
+ ],
590
+ fields: {},
591
+ kind: Node3DKind,
592
+ },
593
+ });
594
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
595
+ expect(explanation).not.toBeNull();
596
+ expect(explanation!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
597
+ expect(explanation!.kind).toBe('acme.Unknown');
598
+ expect(explanation!.path).toBe('scenes[0].scene.children[0].children[0]');
599
+ });
600
+
601
+ it('registration mutation control: same tree passes when kind is registered', () => {
602
+ const document = createTestDocument({
603
+ cameras: [],
604
+ kind: 'Scene3D',
605
+ lights: [],
606
+ scene: {
607
+ children: [
608
+ {
609
+ children: [{ children: [], fields: {}, kind: Node3DKind }],
610
+ fields: {},
611
+ kind: Node3DKind,
612
+ },
613
+ ],
614
+ fields: {},
615
+ kind: Node3DKind,
616
+ },
617
+ });
618
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
619
+ expect(result).not.toBeNull();
620
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
408
621
  expect(explanation).toBeNull();
409
622
  });
623
+
624
+ it('text explain reports unregistered kind from YAML', () => {
625
+ const yaml = formatFlightDocumentText(
626
+ createTestDocument({
627
+ cameras: [],
628
+ kind: 'Scene3D',
629
+ lights: [],
630
+ scene: {
631
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
632
+ fields: {},
633
+ kind: Node3DKind,
634
+ },
635
+ }),
636
+ );
637
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
638
+ expect(explanation).not.toBeNull();
639
+ expect(explanation!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
640
+ expect(explanation!.kind).toBe('acme.Unknown');
641
+ expect(explanation!.path).toBe('scenes[0].scene.children[0]');
642
+ });
643
+ });
644
+
645
+ // ★ ROOT-NODE FIDELITY, 3D half. Same defect as 2D: the reader built a fresh container and materialized
646
+ // only the authored root's children, so the root's own kind and fields were dropped while the writer
647
+ // captured them.
648
+ describe('Scene3D root fidelity', () => {
649
+ it('preserves the authored root kind', () => {
650
+ const schemas = createTestSchemas();
651
+ const document = rootDocument3D(Node3DKind, {}, schemas);
652
+ const materialization = createFlightDocumentScene3DMaterialization(document, schemas);
653
+ expect(materialization).not.toBeNull();
654
+ expect(materialization!.scene.root.kind).toBe(Node3DKind);
655
+ });
656
+
657
+ it('preserves fields authored on the root', () => {
658
+ const schemas = createTestSchemas();
659
+ const document = rootDocument3D(Node3DKind, { name: 'authored-root' }, schemas);
660
+ const materialization = createFlightDocumentScene3DMaterialization(document, schemas);
661
+ expect(materialization).not.toBeNull();
662
+ expect(materialization!.scene.root.name).toBe('authored-root');
663
+ });
664
+
665
+ it('refuses a registered root whose kind belongs to the other dimension', () => {
666
+ const schemas = createTestSchemas();
667
+ schemas.nodeSchemas = withRegistryTableEntry(schemas.nodeSchemas, DisplayObjectKind, {
668
+ createNode: () => createDisplayObject() as unknown as NodeAny,
669
+ fields: [],
670
+ kind: DisplayObjectKind,
671
+ writeNodeFields: () => true,
672
+ });
673
+ const document = rootDocument3D(DisplayObjectKind, {}, schemas);
674
+ expect(createFlightDocumentScene3DMaterialization(document, schemas)).toBeNull();
675
+ expect(explainFlightDocumentScene3DRefusal(document, schemas)).toMatchObject({
676
+ reason: FlightDocumentRefusalReason.RootKindMismatch,
677
+ });
678
+ });
410
679
  });
411
680
 
412
681
  describe('Scene3DDocumentCamera', () => {
@@ -417,16 +686,23 @@ describe('Scene3DDocumentCamera', () => {
417
686
  });
418
687
  });
419
688
 
420
- describe('Scene3DDocumentLight', () => {
421
- it('has no direction field', () => {
422
- expectTypeOf<keyof Scene3DDocumentLight>().toEqualTypeOf<'descriptor' | 'name' | 'node' | 'transform'>();
423
- });
424
- });
689
+ function createTestDocument(
690
+ scene: FlightDocumentScene,
691
+ resources: FlightDocumentResourceDescriptor[] = [],
692
+ ): FlightDocument {
693
+ return { defaultScene: 0, resources, scenes: [scene], version: 1 };
694
+ }
425
695
 
426
696
  function createTestSchemas(): FlightDocumentSchemaRegistry {
427
697
  const node3DSchema: FlightDocumentNodeSchema = {
428
- createNode: (_fields: Readonly<FlightDocumentFields>, _resources: FlightDocumentResourceLookup) => {
429
- return createNode3D();
698
+ createNode: (fields: Readonly<FlightDocumentFields>, _resources: FlightDocumentResourceLookup) => {
699
+ const node = createNode3D();
700
+ const positionX = fields['positionX'];
701
+ if (typeof positionX === 'number') {
702
+ node.position.x = positionX;
703
+ invalidateNodeLocalTransform(node);
704
+ }
705
+ return node;
430
706
  },
431
707
  fields: [],
432
708
  kind: Node3DKind,
@@ -444,3 +720,53 @@ function createTestSchemas(): FlightDocumentSchemaRegistry {
444
720
  shapeCommandSchemas: createKeyedTable('flight-document.shape-command', 'none'),
445
721
  };
446
722
  }
723
+
724
+ describe('Scene3DDocumentLight', () => {
725
+ it('has no direction field', () => {
726
+ expectTypeOf<keyof Scene3DDocumentLight>().toEqualTypeOf<'descriptor' | 'name' | 'node' | 'transform'>();
727
+ });
728
+ });
729
+
730
+ function rootDocument3D(
731
+ kind: string,
732
+ fields: Record<string, unknown>,
733
+ schemas: FlightDocumentSchemaRegistry,
734
+ ): FlightDocument {
735
+ schemas.nodeSchemas = withRegistryTableEntry(schemas.nodeSchemas, Node3DKind, {
736
+ createNode: (nodeFields) => {
737
+ const node = createNode3D();
738
+ if (typeof nodeFields['name'] === 'string') node.name = nodeFields['name'];
739
+ return node as unknown as NodeAny;
740
+ },
741
+ fields: [],
742
+ kind: Node3DKind,
743
+ writeNodeFields: () => true,
744
+ });
745
+ return {
746
+ defaultScene: 0,
747
+ resources: [],
748
+ scenes: [{ cameras: [], kind: 'Scene3D', lights: [], scene: { children: [], fields, kind } }],
749
+ version: 1,
750
+ } as unknown as FlightDocument;
751
+ }
752
+
753
+ function getDocumentLights(
754
+ source: Readonly<Required<Scene3DLightsLike>>,
755
+ transform: Readonly<Scene3DDocumentLight['transform']>,
756
+ ): Scene3DDocumentLight[] {
757
+ return getScene3DLights(source).map((descriptor) => ({ descriptor, transform }));
758
+ }
759
+
760
+ function getScene3DLightKinds(source: Readonly<Scene3DLightsLike> | null): string[] {
761
+ return source === null ? [] : getScene3DLights(source).map((light) => light.kind);
762
+ }
763
+
764
+ function getScene3DLights(source: Readonly<Scene3DLightsLike>): Readonly<Light>[] {
765
+ const lights: Readonly<Light>[] = [];
766
+ for (const value of Object.values(source)) {
767
+ if (value === null) continue;
768
+ if ('kind' in value) lights.push(value);
769
+ else lights.push(...value);
770
+ }
771
+ return lights;
772
+ }