@skenora/scene-plan 0.1.2

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 (66) hide show
  1. package/README.md +114 -0
  2. package/dist/check/check-scene-input.d.ts +38 -0
  3. package/dist/check/check-scene-input.d.ts.map +1 -0
  4. package/dist/check/check-scene-input.js +209 -0
  5. package/dist/check/check-scene-input.js.map +1 -0
  6. package/dist/compilation/compile.d.ts +4 -0
  7. package/dist/compilation/compile.d.ts.map +1 -0
  8. package/dist/compilation/compile.js +498 -0
  9. package/dist/compilation/compile.js.map +1 -0
  10. package/dist/description/example-recipes.d.ts +66 -0
  11. package/dist/description/example-recipes.d.ts.map +1 -0
  12. package/dist/description/example-recipes.js +1097 -0
  13. package/dist/description/example-recipes.js.map +1 -0
  14. package/dist/description/examples.d.ts +18 -0
  15. package/dist/description/examples.d.ts.map +1 -0
  16. package/dist/description/examples.js +58 -0
  17. package/dist/description/examples.js.map +1 -0
  18. package/dist/description/information.d.ts +415 -0
  19. package/dist/description/information.d.ts.map +1 -0
  20. package/dist/description/information.js +980 -0
  21. package/dist/description/information.js.map +1 -0
  22. package/dist/description/recipes.d.ts +20 -0
  23. package/dist/description/recipes.d.ts.map +1 -0
  24. package/dist/description/recipes.js +163 -0
  25. package/dist/description/recipes.js.map +1 -0
  26. package/dist/description/scene-fields.d.ts +11 -0
  27. package/dist/description/scene-fields.d.ts.map +1 -0
  28. package/dist/description/scene-fields.js +471 -0
  29. package/dist/description/scene-fields.js.map +1 -0
  30. package/dist/diagnostics/diagnostics.d.ts +79 -0
  31. package/dist/diagnostics/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics/diagnostics.js +91 -0
  33. package/dist/diagnostics/diagnostics.js.map +1 -0
  34. package/dist/generated/example-gallery.d.ts +2897 -0
  35. package/dist/generated/example-gallery.d.ts.map +1 -0
  36. package/dist/generated/example-gallery.js +3290 -0
  37. package/dist/generated/example-gallery.js.map +1 -0
  38. package/dist/index.d.ts +15 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +15 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/model/json.d.ts +14 -0
  43. package/dist/model/json.d.ts.map +1 -0
  44. package/dist/model/json.js +60 -0
  45. package/dist/model/json.js.map +1 -0
  46. package/dist/model/types.d.ts +329 -0
  47. package/dist/model/types.d.ts.map +1 -0
  48. package/dist/model/types.js +5 -0
  49. package/dist/model/types.js.map +1 -0
  50. package/dist/normalization/normalize.d.ts +7 -0
  51. package/dist/normalization/normalize.d.ts.map +1 -0
  52. package/dist/normalization/normalize.js +145 -0
  53. package/dist/normalization/normalize.js.map +1 -0
  54. package/dist/parsing/parse.d.ts +5 -0
  55. package/dist/parsing/parse.d.ts.map +1 -0
  56. package/dist/parsing/parse.js +330 -0
  57. package/dist/parsing/parse.js.map +1 -0
  58. package/dist/patch/patch.d.ts +6 -0
  59. package/dist/patch/patch.d.ts.map +1 -0
  60. package/dist/patch/patch.js +319 -0
  61. package/dist/patch/patch.js.map +1 -0
  62. package/dist/validation/validate.d.ts +16 -0
  63. package/dist/validation/validate.d.ts.map +1 -0
  64. package/dist/validation/validate.js +704 -0
  65. package/dist/validation/validate.js.map +1 -0
  66. package/package.json +31 -0
@@ -0,0 +1,704 @@
1
+ import { createBuiltInFlowNodeRegistry, validateFlowGraph, } from "@skenora/flow";
2
+ import { validateSceneDocument, validateAuthoredMaterial, validateFieldRule, validateProceduralGeometryProgram, MATERIAL_SLOT_TARGET_RULE, } from "@skenora/contracts";
3
+ import { createDiagnostic, finalizeDiagnostics, hasErrors, } from "../diagnostics/diagnostics.js";
4
+ import { isJsonObject, isJsonValue } from "../model/json.js";
5
+ import { normalizeSceneBlueprint } from "../normalization/normalize.js";
6
+ const ENTITY_KINDS = new Set([
7
+ "group",
8
+ "model",
9
+ "primitive",
10
+ "procedural",
11
+ "shape",
12
+ "particle",
13
+ "annotation",
14
+ "camera",
15
+ ]);
16
+ const RESOURCE_KINDS = new Set([
17
+ "model",
18
+ "texture",
19
+ "environment",
20
+ "audio",
21
+ "data",
22
+ ]);
23
+ const ID_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/;
24
+ const EXTENSION_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
25
+ const QUATERNION_TOLERANCE = 0.001;
26
+ export function validateSceneBlueprint(input, context = {}) {
27
+ const normalized = normalizeSceneBlueprint(input);
28
+ if (!normalized.ok || !normalized.value) {
29
+ return {
30
+ valid: false,
31
+ diagnostics: normalized.diagnostics,
32
+ };
33
+ }
34
+ const diagnostics = [
35
+ ...normalized.diagnostics,
36
+ ...validateNormalizedSceneBlueprint(normalized.value, context, {
37
+ requireResourceBindings: context.resourceBindings !== undefined,
38
+ }),
39
+ ];
40
+ const finalized = finalizeDiagnostics(diagnostics);
41
+ return {
42
+ valid: !hasErrors(finalized),
43
+ normalized: normalized.value,
44
+ diagnostics: finalized,
45
+ };
46
+ }
47
+ export function validateNormalizedSceneBlueprint(blueprint, context = {}, options = {}) {
48
+ const diagnostics = [];
49
+ validateBlueprintHeader(blueprint, diagnostics);
50
+ const resources = validateResources(blueprint.resources, context, options, diagnostics);
51
+ const entityIds = new Set();
52
+ for (const [index, entity] of blueprint.entities.entries()) {
53
+ validateEntity(entity, index, entityIds, resources, diagnostics);
54
+ }
55
+ validateHierarchy(blueprint.entities, entityIds, diagnostics);
56
+ const materialIds = validateMaterials(blueprint.materials, diagnostics);
57
+ const bindingIds = validateBindings(blueprint.materialBindings, materialIds, entityIds, diagnostics);
58
+ validateTextureAnimations(blueprint.textureAnimations, materialIds, resources, diagnostics);
59
+ validateCameraPaths(blueprint.cameraPaths, diagnostics);
60
+ validateSettings(blueprint, resources, diagnostics);
61
+ validateCapabilities(blueprint.requires, context, diagnostics);
62
+ validateExtensions(blueprint.extensions, context, diagnostics);
63
+ validateFlows(blueprint.flows, context, entityIds, materialIds, new Set(blueprint.cameraPaths.map((path) => path.id)), new Set(blueprint.textureAnimations.map((animation) => animation.id)), new Map(blueprint.materials.map((material) => [
64
+ material.id,
65
+ new Set(material.creation && Array.isArray(material.animations)
66
+ ? material.animations.flatMap((animation) => isJsonObject(animation) && typeof animation.id === "string"
67
+ ? [animation.id]
68
+ : [])
69
+ : []),
70
+ ])), resources, diagnostics);
71
+ if (Object.keys(blueprint.initialVariables).length > 0 &&
72
+ context.allowVariables !== true) {
73
+ diagnostics.push(createDiagnostic({
74
+ code: "unsupported-capability",
75
+ stage: "validate",
76
+ path: ["initialVariables"],
77
+ message: "Initial Flow variables require an explicit host allowance",
78
+ recoverable: true,
79
+ }));
80
+ }
81
+ return finalizeDiagnostics(diagnostics);
82
+ }
83
+ export function validateSceneDocumentCandidate(document) {
84
+ const result = validateSceneDocument(document);
85
+ return finalizeDiagnostics(result.issues.map((issue) => nativeDiagnostic(issue)));
86
+ }
87
+ export function isStableScenePlanId(value) {
88
+ return typeof value === "string" && ID_PATTERN.test(value);
89
+ }
90
+ function validateBlueprintHeader(blueprint, diagnostics) {
91
+ if (blueprint.schema !== "skenora.scene.blueprint") {
92
+ add(diagnostics, "invalid-schema", ["schema"], "Expected skenora.scene.blueprint");
93
+ }
94
+ if (blueprint.version !== 1) {
95
+ add(diagnostics, "unsupported-version", ["version"], "Only SceneBlueprint version 1 is supported");
96
+ }
97
+ if (blueprint.defaultsProfile !== "scene-v1") {
98
+ add(diagnostics, "unsupported-version", ["defaultsProfile"], "Only the scene-v1 defaults profile is supported");
99
+ }
100
+ validateId(blueprint.scene.id, ["scene", "id"], diagnostics);
101
+ validateName(blueprint.scene.name, ["scene", "name"], diagnostics);
102
+ }
103
+ function validateResources(resources, context, options, diagnostics) {
104
+ const result = new Map();
105
+ for (const [index, resource] of resources.entries()) {
106
+ const path = ["resources", index];
107
+ if (!isJsonObject(resource)) {
108
+ add(diagnostics, "invalid-value", path, "Resource must be a JSON object");
109
+ continue;
110
+ }
111
+ validateId(resource.id, [...path, "id"], diagnostics);
112
+ if (result.has(resource.id)) {
113
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate resource id: ${resource.id}`);
114
+ }
115
+ else {
116
+ result.set(resource.id, resource);
117
+ }
118
+ if (!RESOURCE_KINDS.has(resource.kind)) {
119
+ add(diagnostics, "invalid-value", [...path, "kind"], `Unsupported resource kind: ${String(resource.kind)}`);
120
+ }
121
+ if (resource.mediaType !== undefined &&
122
+ !isNonEmptyString(resource.mediaType)) {
123
+ add(diagnostics, "invalid-value", [...path, "mediaType"], "Resource mediaType must be a non-empty string");
124
+ }
125
+ if (resource.label !== undefined && typeof resource.label !== "string") {
126
+ add(diagnostics, "invalid-value", [...path, "label"], "Resource label must be a string");
127
+ }
128
+ validateIntegrity(resource.integrity, [...path, "integrity"], diagnostics);
129
+ if (options.requireResourceBindings ||
130
+ context.resourceBindings !== undefined) {
131
+ const binding = context.resourceBindings?.[resource.id];
132
+ if (!binding) {
133
+ add(diagnostics, "missing-resource-binding", [...path, "id"], `No authorized resource binding was supplied for ${resource.id}`, { assetId: resource.id, recoverable: true });
134
+ }
135
+ else {
136
+ validateResourceBinding(resource, binding, [...path, "id"], diagnostics);
137
+ }
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ function validateResourceBinding(resource, binding, path, diagnostics) {
143
+ const bindingKind = binding.kind;
144
+ if (bindingKind !== resource.kind) {
145
+ add(diagnostics, "resource-kind-mismatch", path, `Resource ${resource.id} expects kind ${resource.kind}, received ${bindingKind}`, { assetId: resource.id, recoverable: true });
146
+ }
147
+ const source = binding.source ?? binding.uri;
148
+ if (!source) {
149
+ add(diagnostics, "invalid-value", path, `Resource binding ${resource.id} has no source`, { assetId: resource.id });
150
+ }
151
+ if (resource.integrity && binding.integrity) {
152
+ if (resource.integrity.algorithm !== binding.integrity.algorithm ||
153
+ resource.integrity.value !== binding.integrity.value) {
154
+ add(diagnostics, "resource-integrity-mismatch", path, `Resource binding integrity does not match ${resource.id}`, { assetId: resource.id, recoverable: true });
155
+ }
156
+ }
157
+ if (!isJsonValue(binding)) {
158
+ add(diagnostics, "invalid-json", path, `Resource binding ${resource.id} must be serializable JSON`, { assetId: resource.id });
159
+ }
160
+ }
161
+ function validateEntity(entity, index, entityIds, resources, diagnostics) {
162
+ const path = ["entities", index];
163
+ if (!isJsonObject(entity)) {
164
+ add(diagnostics, "invalid-value", path, "Entity must be a JSON object");
165
+ return;
166
+ }
167
+ validateId(entity.id, [...path, "id"], diagnostics);
168
+ if (entityIds.has(entity.id)) {
169
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate entity id: ${entity.id}`, { entityId: entity.id });
170
+ }
171
+ else {
172
+ entityIds.add(entity.id);
173
+ }
174
+ validateName(entity.name, [...path, "name"], diagnostics);
175
+ if (!ENTITY_KINDS.has(entity.kind)) {
176
+ add(diagnostics, "unsupported-entity-kind", [...path, "kind"], `Unsupported Blueprint entity kind: ${String(entity.kind)}`, { entityId: entity.id });
177
+ }
178
+ if (entity.parentId !== null && entity.parentId !== undefined) {
179
+ validateId(entity.parentId, [...path, "parentId"], diagnostics);
180
+ }
181
+ if (typeof entity.enabled !== "boolean") {
182
+ add(diagnostics, "invalid-value", [...path, "enabled"], "Entity enabled must be boolean", { entityId: entity.id });
183
+ }
184
+ if (typeof entity.visible !== "boolean") {
185
+ add(diagnostics, "invalid-value", [...path, "visible"], "Entity visible must be boolean", { entityId: entity.id });
186
+ }
187
+ validateTransform(entity.transform, [...path, "transform"], diagnostics, entity.id);
188
+ if (entity.properties !== undefined && !isJsonObject(entity.properties)) {
189
+ add(diagnostics, "invalid-value", [...path, "properties"], "Entity properties must be a JSON object", { entityId: entity.id });
190
+ }
191
+ if (entity.kind === "model") {
192
+ validateAssetReference(entity.assetRef, "model", resources, [...path, "assetRef"], diagnostics, entity.id);
193
+ if (entity.sourceNode !== undefined &&
194
+ !isNonEmptyString(entity.sourceNode)) {
195
+ add(diagnostics, "invalid-value", [...path, "sourceNode"], "Model sourceNode must be a non-empty string", { entityId: entity.id });
196
+ }
197
+ }
198
+ else if (entity.kind === "primitive") {
199
+ if (!["box", "sphere", "plane", "cylinder", "torus"].includes(entity.primitive)) {
200
+ add(diagnostics, "invalid-value", [...path, "primitive"], "Primitive kind is invalid", { entityId: entity.id });
201
+ }
202
+ for (const key of [
203
+ "size",
204
+ "width",
205
+ "height",
206
+ "depth",
207
+ "diameter",
208
+ ]) {
209
+ validatePositiveNumber(entity[key], [...path, key], diagnostics, entity.id);
210
+ }
211
+ if (entity.color !== undefined)
212
+ validateColor(entity.color, [...path, "color"], diagnostics, entity.id);
213
+ }
214
+ else if (entity.kind === "procedural") {
215
+ for (const issue of validateProceduralGeometryProgram(entity.program)
216
+ .issues)
217
+ add(diagnostics, issue.code === "budget-exceeded" ? "budget-exceeded" : "invalid-value", [...path, "program", ...issue.path], issue.message, { entityId: entity.id });
218
+ if (entity.color !== undefined)
219
+ validateColor(entity.color, [...path, "color"], diagnostics, entity.id);
220
+ }
221
+ else if (entity.kind === "shape") {
222
+ if (![
223
+ "rectangle",
224
+ "circle",
225
+ "polygon",
226
+ "polyline",
227
+ "tube",
228
+ "wall",
229
+ "box",
230
+ ].includes(entity.shape)) {
231
+ add(diagnostics, "invalid-value", [...path, "shape"], "Shape kind is invalid", { entityId: entity.id });
232
+ }
233
+ if (entity.points !== undefined) {
234
+ if (!Array.isArray(entity.points)) {
235
+ add(diagnostics, "invalid-value", [...path, "points"], "Shape points must be an array", { entityId: entity.id });
236
+ }
237
+ else {
238
+ for (const [pointIndex, point] of entity.points.entries()) {
239
+ validateVector3(point, [...path, "points", pointIndex], diagnostics, entity.id);
240
+ }
241
+ }
242
+ }
243
+ for (const key of [
244
+ "width",
245
+ "height",
246
+ "radius",
247
+ "depth",
248
+ "tubeRadius",
249
+ "wallHeight",
250
+ ]) {
251
+ validatePositiveNumber(entity[key], [...path, key], diagnostics, entity.id);
252
+ }
253
+ if (entity.segments !== undefined &&
254
+ (!Number.isInteger(entity.segments) || entity.segments < 3)) {
255
+ add(diagnostics, "invalid-value", [...path, "segments"], "Shape segments must be an integer >= 3", { entityId: entity.id });
256
+ }
257
+ if (entity.closed !== undefined && typeof entity.closed !== "boolean") {
258
+ add(diagnostics, "invalid-value", [...path, "closed"], "Shape closed must be boolean", { entityId: entity.id });
259
+ }
260
+ if (entity.fillColor !== undefined)
261
+ validateColor(entity.fillColor, [...path, "fillColor"], diagnostics, entity.id);
262
+ if (entity.strokeColor !== undefined)
263
+ validateColor(entity.strokeColor, [...path, "strokeColor"], diagnostics, entity.id);
264
+ if (entity.fillAlpha !== undefined &&
265
+ (!Number.isFinite(entity.fillAlpha) ||
266
+ entity.fillAlpha < 0 ||
267
+ entity.fillAlpha > 1)) {
268
+ add(diagnostics, "invalid-value", [...path, "fillAlpha"], "Shape fillAlpha must be in the 0..1 range", { entityId: entity.id });
269
+ }
270
+ }
271
+ else if (entity.kind === "particle" &&
272
+ entity.textureAssetRef !== undefined) {
273
+ validateAssetReference(entity.textureAssetRef, "texture", resources, [...path, "textureAssetRef"], diagnostics, entity.id);
274
+ }
275
+ }
276
+ function validateHierarchy(entities, entityIds, diagnostics) {
277
+ for (const [index, entity] of entities.entries()) {
278
+ if (entity.parentId === null || entity.parentId === undefined)
279
+ continue;
280
+ if (!entityIds.has(entity.parentId)) {
281
+ add(diagnostics, "missing-reference", ["entities", index, "parentId"], `Parent entity not found: ${entity.parentId}`, { entityId: entity.id });
282
+ }
283
+ }
284
+ const state = new Map();
285
+ const entitiesById = new Map(entities.map((entity) => [entity.id, entity]));
286
+ const visit = (id, path) => {
287
+ const current = state.get(id);
288
+ if (current === "visiting") {
289
+ add(diagnostics, "hierarchy-cycle", path, `Entity hierarchy contains a cycle at ${id}`, { entityId: id });
290
+ return;
291
+ }
292
+ if (current === "visited")
293
+ return;
294
+ state.set(id, "visiting");
295
+ const entity = entitiesById.get(id);
296
+ if (entity?.parentId && entitiesById.has(entity.parentId)) {
297
+ visit(entity.parentId, [...path, "parentId"]);
298
+ }
299
+ state.set(id, "visited");
300
+ };
301
+ for (const entity of entities)
302
+ visit(entity.id, ["entities", entity.id]);
303
+ }
304
+ function validateMaterials(materials, diagnostics) {
305
+ const ids = new Set();
306
+ for (const [index, material] of materials.entries()) {
307
+ const path = ["materials", index];
308
+ if (!isJsonObject(material)) {
309
+ add(diagnostics, "invalid-value", path, "Material must be a JSON object");
310
+ continue;
311
+ }
312
+ validateId(material.id, [...path, "id"], diagnostics);
313
+ if (ids.has(material.id))
314
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate material id: ${material.id}`, { materialId: material.id });
315
+ ids.add(material.id);
316
+ if (material.creation !== undefined) {
317
+ for (const issue of validateAuthoredMaterial(material, path)) {
318
+ add(diagnostics, issue.code, issue.path, issue.message, {
319
+ materialId: material.id,
320
+ });
321
+ }
322
+ }
323
+ const textures = material.textures;
324
+ if (textures !== undefined) {
325
+ if (!isJsonObject(textures)) {
326
+ add(diagnostics, "invalid-value", [...path, "textures"], "Material textures must be an object", { materialId: material.id });
327
+ }
328
+ else {
329
+ for (const [slot, texture] of Object.entries(textures)) {
330
+ if (!isJsonObject(texture) || typeof texture.assetId !== "string") {
331
+ add(diagnostics, "invalid-value", [...path, "textures", slot], "Material texture requires an assetId", { materialId: material.id });
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ return ids;
338
+ }
339
+ function validateBindings(bindings, materialIds, entityIds, diagnostics) {
340
+ const ids = new Set();
341
+ for (const [index, binding] of bindings.entries()) {
342
+ const path = ["materialBindings", index];
343
+ if (binding.target !== undefined) {
344
+ for (const issue of validateFieldRule(binding.target, MATERIAL_SLOT_TARGET_RULE, [...path, "target"])) {
345
+ add(diagnostics, issue.code, issue.path, issue.message);
346
+ }
347
+ }
348
+ validateId(binding.id, [...path, "id"], diagnostics);
349
+ if (ids.has(binding.id))
350
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate material binding id: ${binding.id}`);
351
+ ids.add(binding.id);
352
+ if (!materialIds.has(binding.materialId))
353
+ add(diagnostics, "missing-reference", [...path, "materialId"], `Material not found: ${binding.materialId}`, { materialId: binding.materialId });
354
+ if (!entityIds.has(binding.entityId))
355
+ add(diagnostics, "missing-reference", [...path, "entityId"], `Entity not found: ${binding.entityId}`, { entityId: binding.entityId });
356
+ if (binding.targetName !== undefined &&
357
+ typeof binding.targetName !== "string")
358
+ add(diagnostics, "invalid-value", [...path, "targetName"], "Material binding targetName must be a string");
359
+ }
360
+ return ids;
361
+ }
362
+ function validateTextureAnimations(animations, materialIds, resources, diagnostics) {
363
+ const ids = new Set();
364
+ for (const [index, animation] of animations.entries()) {
365
+ const path = ["textureAnimations", index];
366
+ validateId(animation.id, [...path, "id"], diagnostics);
367
+ if (ids.has(animation.id))
368
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate texture animation id: ${animation.id}`);
369
+ ids.add(animation.id);
370
+ if (!materialIds.has(animation.materialId))
371
+ add(diagnostics, "missing-reference", [...path, "materialId"], `Material not found: ${animation.materialId}`);
372
+ const texture = animation
373
+ .textureAssetId;
374
+ if (texture !== undefined)
375
+ validateAssetReference(texture, "texture", resources, [...path, "textureAssetId"], diagnostics);
376
+ }
377
+ }
378
+ function validateCameraPaths(paths, diagnostics) {
379
+ const ids = new Set();
380
+ for (const [index, pathValue] of paths.entries()) {
381
+ const path = ["cameraPaths", index];
382
+ validateId(pathValue.id, [...path, "id"], diagnostics);
383
+ if (ids.has(pathValue.id))
384
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate camera path id: ${pathValue.id}`);
385
+ ids.add(pathValue.id);
386
+ validateName(pathValue.name, [...path, "name"], diagnostics);
387
+ if (!Array.isArray(pathValue.keyframes) || pathValue.keyframes.length < 2)
388
+ add(diagnostics, "invalid-value", [...path, "keyframes"], "Camera path requires at least two keyframes");
389
+ const keyframeIds = new Set();
390
+ for (const [keyframeIndex, keyframe] of (pathValue.keyframes ?? []).entries()) {
391
+ if (!isJsonObject(keyframe)) {
392
+ add(diagnostics, "invalid-value", [...path, "keyframes", keyframeIndex], "Camera keyframe must be a JSON object");
393
+ continue;
394
+ }
395
+ if (typeof keyframe.id !== "string" || !isStableScenePlanId(keyframe.id))
396
+ add(diagnostics, "invalid-id", [...path, "keyframes", keyframeIndex, "id"], "Camera keyframe id is invalid");
397
+ if (typeof keyframe.id === "string" && keyframeIds.has(keyframe.id))
398
+ add(diagnostics, "duplicate-id", [...path, "keyframes", keyframeIndex, "id"], `Duplicate camera keyframe id: ${keyframe.id}`);
399
+ if (typeof keyframe.id === "string")
400
+ keyframeIds.add(keyframe.id);
401
+ if (typeof keyframe.timeMs !== "number" ||
402
+ !Number.isFinite(keyframe.timeMs) ||
403
+ keyframe.timeMs < 0)
404
+ add(diagnostics, "invalid-value", [...path, "keyframes", keyframeIndex, "timeMs"], "Camera keyframe timeMs must be finite and non-negative");
405
+ }
406
+ }
407
+ }
408
+ function validateSettings(blueprint, resources, diagnostics) {
409
+ const environment = blueprint.environment;
410
+ if (environment?.assetId !== undefined)
411
+ validateAssetReference(environment.assetId, "environment", resources, ["environment", "assetId"], diagnostics);
412
+ const background = isJsonObject(environment?.background)
413
+ ? environment.background
414
+ : undefined;
415
+ if (background?.assetId !== undefined) {
416
+ const resource = resources.get(String(background.assetId));
417
+ if (!resource ||
418
+ (resource.kind !== "texture" && resource.kind !== "environment"))
419
+ add(diagnostics, "missing-reference", ["environment", "background", "assetId"], `Background asset is not a texture or environment: ${String(background.assetId)}`);
420
+ }
421
+ const skybox = isJsonObject(background?.skybox)
422
+ ? background.skybox
423
+ : undefined;
424
+ const skyboxSource = isJsonObject(skybox?.source) ? skybox.source : undefined;
425
+ if (skyboxSource?.kind === "environment") {
426
+ validateAssetReference(skyboxSource.assetId, "environment", resources, ["environment", "background", "skybox", "source", "assetId"], diagnostics);
427
+ }
428
+ else if (skyboxSource?.kind === "panorama") {
429
+ validateAssetReference(skyboxSource.assetId, "texture", resources, ["environment", "background", "skybox", "source", "assetId"], diagnostics);
430
+ }
431
+ else if (skyboxSource?.kind === "cubemap" &&
432
+ isJsonObject(skyboxSource.faces)) {
433
+ for (const face of [
434
+ "positiveX",
435
+ "positiveY",
436
+ "positiveZ",
437
+ "negativeX",
438
+ "negativeY",
439
+ "negativeZ",
440
+ ]) {
441
+ validateAssetReference(skyboxSource.faces[face], "texture", resources, ["environment", "background", "skybox", "source", "faces", face], diagnostics);
442
+ }
443
+ }
444
+ const weather = blueprint.weather;
445
+ if (weather?.textureAssetId !== undefined)
446
+ validateAssetReference(weather.textureAssetId, "texture", resources, ["weather", "textureAssetId"], diagnostics);
447
+ if (blueprint.lights !== undefined) {
448
+ const ids = new Set();
449
+ for (const [index, light] of blueprint.lights.entries()) {
450
+ validateId(light.id, ["lights", index, "id"], diagnostics);
451
+ if (ids.has(light.id))
452
+ add(diagnostics, "duplicate-id", ["lights", index, "id"], `Duplicate light id: ${light.id}`);
453
+ ids.add(light.id);
454
+ }
455
+ }
456
+ }
457
+ function validateCapabilities(requirements, context, diagnostics) {
458
+ const seen = new Set();
459
+ for (const [index, requirement] of requirements.entries()) {
460
+ const path = ["requires", index];
461
+ validateId(requirement.id, [...path, "id"], diagnostics);
462
+ if (typeof requirement.version !== "string" || !requirement.version.trim())
463
+ add(diagnostics, "invalid-capability", [...path, "version"], "Capability version must be a non-empty string");
464
+ if (typeof requirement.required !== "boolean")
465
+ add(diagnostics, "invalid-capability", [...path, "required"], "Capability required must be boolean");
466
+ if (seen.has(requirement.id))
467
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate capability requirement: ${requirement.id}`);
468
+ seen.add(requirement.id);
469
+ const observed = context.capabilityAvailability?.find((candidate) => candidate.id === requirement.id);
470
+ const descriptor = context.capabilityAvailability
471
+ ? observed?.installed &&
472
+ observed.enabled &&
473
+ observed.compatibility === "supported"
474
+ ? observed
475
+ : undefined
476
+ : context.capabilities?.find((candidate) => candidate.id === requirement.id);
477
+ if (!descriptor) {
478
+ add(diagnostics, requirement.required ? "missing-capability" : "unsupported-capability", path, `Capability is not available: ${requirement.id}`, { recoverable: !requirement.required });
479
+ }
480
+ else if (descriptor.version !== requirement.version) {
481
+ add(diagnostics, requirement.required ? "missing-capability" : "unsupported-capability", [...path, "version"], `Capability ${requirement.id} requires ${requirement.version}, received ${descriptor.version}`, { recoverable: !requirement.required });
482
+ }
483
+ }
484
+ }
485
+ function validateExtensions(extensions, context, diagnostics) {
486
+ const seen = new Set();
487
+ for (const [index, extension] of extensions.entries()) {
488
+ const path = ["extensions", index];
489
+ if (!EXTENSION_NAMESPACE_PATTERN.test(extension.namespace))
490
+ add(diagnostics, "invalid-extension", [...path, "namespace"], "Extension namespace is invalid");
491
+ if (!Number.isSafeInteger(extension.version) || extension.version < 1)
492
+ add(diagnostics, "invalid-extension", [...path, "version"], "Extension version must be a positive integer");
493
+ if (!isNonEmptyString(extension.capability))
494
+ add(diagnostics, "invalid-extension", [...path, "capability"], "Extension capability must be a non-empty string");
495
+ if (typeof extension.required !== "boolean")
496
+ add(diagnostics, "invalid-extension", [...path, "required"], "Extension required must be boolean");
497
+ if (!isJsonValue(extension.data))
498
+ add(diagnostics, "invalid-json", [...path, "data"], "Extension data must contain only JSON values");
499
+ const key = `${extension.namespace}@${extension.version}`;
500
+ if (seen.has(key))
501
+ add(diagnostics, "duplicate-id", [...path, "namespace"], `Duplicate extension namespace/version: ${key}`);
502
+ seen.add(key);
503
+ const capability = context.extensionCapabilities?.find((candidate) => candidate.namespace === extension.namespace &&
504
+ candidate.version === extension.version &&
505
+ candidate.capability === extension.capability);
506
+ if (!capability)
507
+ add(diagnostics, extension.required ? "unsupported-extension" : "unsupported-extension", path, `Extension capability is not registered: ${extension.namespace}`, { recoverable: !extension.required });
508
+ }
509
+ }
510
+ function validateFlows(flows, context, entityIds, materialIds, cameraPathIds, textureAnimationIds, materialAnimationIds, resources, diagnostics) {
511
+ const ids = new Set();
512
+ for (const [index, flow] of flows.entries()) {
513
+ const path = ["flows", index];
514
+ if (!isJsonObject(flow)) {
515
+ add(diagnostics, "invalid-value", path, "Flow must be a JSON object");
516
+ continue;
517
+ }
518
+ validateId(flow.id, [...path, "id"], diagnostics);
519
+ if (ids.has(flow.id))
520
+ add(diagnostics, "duplicate-id", [...path, "id"], `Duplicate flow id: ${flow.id}`);
521
+ ids.add(flow.id);
522
+ validateName(flow.name, [...path, "name"], diagnostics);
523
+ if (flow.schema !== "skenora.flow" || flow.version !== 1)
524
+ add(diagnostics, "invalid-schema", path, `Flow ${flow.id} must use skenora.flow version 1`);
525
+ if (typeof flow.enabled !== "boolean")
526
+ add(diagnostics, "invalid-value", [...path, "enabled"], "Flow enabled must be boolean");
527
+ const flowValidator = context.flowValidator;
528
+ const result = flowValidator
529
+ ? { issues: flowValidator.validate(flow) }
530
+ : validateFlowGraph(flow, context.flowRegistry ?? createBuiltInFlowNodeRegistry());
531
+ for (const issue of result.issues) {
532
+ const nodeIndex = issue.nodeId === undefined
533
+ ? -1
534
+ : flow.nodes.findIndex((node) => node.id === issue.nodeId);
535
+ const edgeIndex = issue.edgeId === undefined
536
+ ? -1
537
+ : flow.edges.findIndex((edge) => edge.id === issue.edgeId);
538
+ const issuePath = nodeIndex >= 0
539
+ ? [...path, "nodes", nodeIndex]
540
+ : edgeIndex >= 0
541
+ ? [...path, "edges", edgeIndex]
542
+ : path;
543
+ add(diagnostics, "flow-invalid", issuePath, issue.message, {
544
+ recoverable: false,
545
+ details: {
546
+ code: issue.code,
547
+ ...(issue.nodeId === undefined ? {} : { nodeId: issue.nodeId }),
548
+ ...(issue.edgeId === undefined ? {} : { edgeId: issue.edgeId }),
549
+ },
550
+ });
551
+ }
552
+ for (const [nodeIndex, node] of flow.nodes.entries()) {
553
+ validateFlowTargets(node.config, [...path, "nodes", nodeIndex, "config"], entityIds, materialIds, cameraPathIds, node.type.startsWith("action.materialAnimation.")
554
+ ? (materialAnimationIds.get(String(node.config.materialId)) ??
555
+ new Set())
556
+ : textureAnimationIds, resources, diagnostics);
557
+ }
558
+ }
559
+ }
560
+ function validateFlowTargets(value, path, entityIds, materialIds, cameraPathIds, textureAnimationIds, resources, diagnostics) {
561
+ if (Array.isArray(value)) {
562
+ for (const [index, child] of value.entries())
563
+ validateFlowTargets(child, [...path, index], entityIds, materialIds, cameraPathIds, textureAnimationIds, resources, diagnostics);
564
+ return;
565
+ }
566
+ if (!isJsonObject(value))
567
+ return;
568
+ for (const [key, child] of Object.entries(value)) {
569
+ if (typeof child === "string") {
570
+ const reference = key === "entityId"
571
+ ? entityIds
572
+ : key === "materialId"
573
+ ? materialIds
574
+ : key === "pathId"
575
+ ? cameraPathIds
576
+ : key === "animationId"
577
+ ? textureAnimationIds
578
+ : undefined;
579
+ if (reference && !reference.has(child))
580
+ add(diagnostics, "flow-target-missing", [...path, key], `Flow target not found: ${child}`);
581
+ if (key === "textureAssetId") {
582
+ const resource = resources.get(child);
583
+ if (!resource || resource.kind !== "texture")
584
+ add(diagnostics, "flow-target-missing", [...path, key], `Flow texture asset not found or not a texture: ${child}`, { assetId: child });
585
+ }
586
+ }
587
+ validateFlowTargets(child, [...path, key], entityIds, materialIds, cameraPathIds, textureAnimationIds, resources, diagnostics);
588
+ }
589
+ }
590
+ function validateAssetReference(reference, kind, resources, path, diagnostics, entityId) {
591
+ if (typeof reference !== "string" || !isStableScenePlanId(reference)) {
592
+ add(diagnostics, "invalid-id", path, "Asset reference must be a stable id", { entityId });
593
+ return;
594
+ }
595
+ const resource = resources.get(reference);
596
+ if (!resource) {
597
+ add(diagnostics, "missing-reference", path, `Resource not found: ${reference}`, { entityId, assetId: reference });
598
+ }
599
+ else if (resource.kind !== kind) {
600
+ add(diagnostics, "resource-kind-mismatch", path, `Resource ${reference} must be a ${kind} resource`, { entityId, assetId: reference });
601
+ }
602
+ }
603
+ function validateTransform(transform, path, diagnostics, entityId) {
604
+ if (!isJsonObject(transform)) {
605
+ add(diagnostics, "invalid-transform", path, "Entity transform must be a JSON object", { entityId });
606
+ return;
607
+ }
608
+ if (transform.coordinateSpace !== "local")
609
+ add(diagnostics, "invalid-transform", [...path, "coordinateSpace"], "Only local entity transforms are supported", { entityId });
610
+ if (transform.units !== "meters")
611
+ add(diagnostics, "invalid-transform", [...path, "units"], "Entity transforms must use meters", { entityId });
612
+ validateVector3(transform.position, [...path, "position"], diagnostics, entityId);
613
+ validateVector3(transform.scaling, [...path, "scaling"], diagnostics, entityId);
614
+ const hasEuler = transform.rotationRadians !== undefined;
615
+ const hasQuaternion = transform.rotationQuaternion !== undefined;
616
+ if (hasEuler === hasQuaternion)
617
+ add(diagnostics, "invalid-transform", path, "Provide exactly one rotationRadians or rotationQuaternion representation", { entityId });
618
+ if (hasEuler)
619
+ validateVector3(transform.rotationRadians, [...path, "rotationRadians"], diagnostics, entityId);
620
+ if (hasQuaternion)
621
+ validateQuaternion(transform.rotationQuaternion, [...path, "rotationQuaternion"], diagnostics, entityId);
622
+ }
623
+ function validateQuaternion(value, path, diagnostics, entityId) {
624
+ if (!isFiniteObjectNumbers(value, ["x", "y", "z", "w"])) {
625
+ add(diagnostics, "invalid-quaternion", path, "Quaternion must contain finite x, y, z, and w values", { entityId });
626
+ return;
627
+ }
628
+ const quaternion = value;
629
+ const length = Math.hypot(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
630
+ if (Math.abs(length - 1) > QUATERNION_TOLERANCE)
631
+ add(diagnostics, "invalid-quaternion", path, "Quaternion must be normalized", { entityId });
632
+ }
633
+ function validateVector3(value, path, diagnostics, entityId) {
634
+ if (!isFiniteObjectNumbers(value, ["x", "y", "z"]))
635
+ add(diagnostics, "invalid-value", path, "Vector3 must contain finite x, y, and z values", { entityId });
636
+ }
637
+ function validateColor(value, path, diagnostics, entityId) {
638
+ if (!isFiniteObjectNumbers(value, ["r", "g", "b"]))
639
+ add(diagnostics, "invalid-value", path, "Color must contain finite r, g, and b values", { entityId });
640
+ }
641
+ function isFiniteObjectNumbers(value, keys) {
642
+ if (!isJsonObject(value))
643
+ return false;
644
+ return keys.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]));
645
+ }
646
+ function validatePositiveNumber(value, path, diagnostics, entityId) {
647
+ if (value !== undefined &&
648
+ (typeof value !== "number" || !Number.isFinite(value) || value <= 0))
649
+ add(diagnostics, "invalid-value", path, "Value must be a positive finite number", { entityId });
650
+ }
651
+ function validateIntegrity(value, path, diagnostics) {
652
+ if (value === undefined)
653
+ return;
654
+ if (!isJsonObject(value) ||
655
+ value.algorithm !== "sha256" ||
656
+ typeof value.value !== "string" ||
657
+ !/^[a-f0-9]{64}$/.test(value.value))
658
+ add(diagnostics, "invalid-value", path, "Integrity must be a lowercase SHA-256 digest");
659
+ }
660
+ function validateId(value, path, diagnostics) {
661
+ if (!isStableScenePlanId(value))
662
+ add(diagnostics, "invalid-id", path, "ID must start with a letter, contain only stable ID characters, and be at most 128 characters");
663
+ }
664
+ function validateName(value, path, diagnostics) {
665
+ if (!isNonEmptyString(value))
666
+ add(diagnostics, "invalid-value", path, "Name must be a non-empty string");
667
+ }
668
+ function isNonEmptyString(value) {
669
+ return typeof value === "string" && value.trim().length > 0;
670
+ }
671
+ function add(diagnostics, code, path, message, options = {}) {
672
+ diagnostics.push(createDiagnostic({
673
+ code,
674
+ stage: "validate",
675
+ path,
676
+ message,
677
+ ...(options.recoverable === undefined
678
+ ? {}
679
+ : { recoverable: options.recoverable }),
680
+ ...(options.entityId === undefined ? {} : { entityId: options.entityId }),
681
+ ...(options.assetId === undefined ? {} : { assetId: options.assetId }),
682
+ ...(options.materialId === undefined
683
+ ? {}
684
+ : { materialId: options.materialId }),
685
+ ...(options.details === undefined ? {} : { details: options.details }),
686
+ }));
687
+ }
688
+ function nativeDiagnostic(issue) {
689
+ return createDiagnostic({
690
+ code: issue.code === "material-effect-pass-unsupported"
691
+ ? issue.code
692
+ : "native-document-invalid",
693
+ stage: "compile",
694
+ path: issue.path,
695
+ message: issue.message,
696
+ recoverable: false,
697
+ details: { nativeCode: issue.code },
698
+ });
699
+ }
700
+ export function validateNativeDocumentShape(document) {
701
+ const diagnostics = validateSceneDocumentCandidate(document);
702
+ return { valid: !hasErrors(diagnostics), diagnostics };
703
+ }
704
+ //# sourceMappingURL=validate.js.map