@hraness/slopcamera 3.2.8 → 3.3.1

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 (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -3,8 +3,17 @@ import { createBoundedJsonValueSnapshot, deepFreezeJson } from "../code/json-sna
3
3
  import { SpatialSceneError } from "./identity.js"
4
4
  import { composeTransform, invertTransform, multiplyTransforms, normalizeQuaternion, slerpQuaternion, transformPoint, type Bounds, type Mat4, type Quaternion, type Vec3 } from "./math.js"
5
5
 
6
- /** Deliberately closed GLB 2.0 profile. It is not a general glTF loader. */
7
- export const SPATIAL_GLB_PROFILE = "slopcamera.glb-triangles-trs-pbr-basecolor-v1"
6
+ /**
7
+ * Deliberately closed GLB 2.0 profile. It is not a general glTF loader.
8
+ * The profile admits metallic-roughness PBR with base-color, metallic-roughness
9
+ * (ORM layout), normal, occlusion and emissive textures plus emissive factors,
10
+ * all on TEXCOORD_0 with embedded PNG/JPEG images only.
11
+ */
12
+ export const SPATIAL_GLB_PROFILE = "slopcamera.glb-triangles-trs-pbr-fullmaps-v1"
13
+ /** Facts documents admitted before the texture-map extension remain valid under the new profile. */
14
+ export const SPATIAL_GLB_PROFILE_V1 = "slopcamera.glb-triangles-trs-pbr-basecolor-v1"
15
+ /** Explicit additive profile for bounded rigged GLB assets with skinning and morph targets. */
16
+ export const SPATIAL_GLB_RIGGED_PROFILE = "slopcamera.glb-rigged-morph-skin-v1"
8
17
  export const SPATIAL_GLB_LIMITS = Object.freeze({
9
18
  bytes: 134_217_728, jsonBytes: 2_097_152, jsonValues: 200_000, jsonDepth: 32,
10
19
  nodes: 4096, meshes: 256, primitives: 256, verticesPerPrimitive: 65_536,
@@ -12,6 +21,7 @@ export const SPATIAL_GLB_LIMITS = Object.freeze({
12
21
  accessors: 4096, bufferViews: 4096, materials: 256, images: 128,
13
22
  imageBytes: 16_777_216, imageTotalBytes: 33_554_432, imagePixels: 67_108_864,
14
23
  clips: 256, channels: 4096, animationKeys: 4096, durationSeconds: 3600,
24
+ skins: 64, jointsPerSkin: 256, morphTargetsPerPrimitive: 16, morphTargetDeltas: 2_000_000,
15
25
  })
16
26
 
17
27
  const finite = z.number().finite().min(-1e6).max(1e6)
@@ -21,8 +31,22 @@ const vec3 = z.tuple([finite, finite, finite])
21
31
  const signedUnit = z.number().finite().min(-1).max(1)
22
32
  const quaternion = z.tuple([signedUnit, signedUnit, signedUnit, signedUnit])
23
33
  const metadata = { name: z.string().max(1024).optional(), extras: z.unknown().optional(), extensions: z.never().optional() }
34
+ const metadataWithoutExtensions = { name: z.string().max(1024).optional(), extras: z.unknown().optional() }
24
35
  const byteOffset = z.number().int().min(0).max(SPATIAL_GLB_LIMITS.bytes)
25
- const textureInfo = z.strictObject({ ...metadata, index, texCoord: z.literal(0).optional() })
36
+ const textureTransform = z.strictObject({ offset: z.tuple([finite, finite]).default([0, 0]), rotation: z.number().finite().min(-Math.PI).max(Math.PI).default(0), scale: z.tuple([finite, finite]).default([1, 1]), texCoord: z.literal(0).optional() })
37
+ const textureExtensions = z.strictObject({ KHR_texture_transform: textureTransform.optional() })
38
+ const textureInfo = z.strictObject({ ...metadataWithoutExtensions, index, texCoord: z.literal(0).optional(), extensions: textureExtensions.optional() })
39
+ const normalTextureInfo = z.strictObject({ ...metadataWithoutExtensions, index, texCoord: z.literal(0).optional(), scale: finite.optional(), extensions: textureExtensions.optional() })
40
+ const occlusionTextureInfo = z.strictObject({ ...metadataWithoutExtensions, index, texCoord: z.literal(0).optional(), strength: unit.optional(), extensions: textureExtensions.optional() })
41
+ const supportedExtension = z.enum(["KHR_texture_transform", "KHR_materials_clearcoat", "KHR_materials_transmission", "KHR_materials_sheen", "KHR_materials_anisotropy", "KHR_materials_ior", "KHR_materials_emissive_strength"])
42
+ const materialExtensions = z.strictObject({
43
+ KHR_materials_clearcoat: z.strictObject({ clearcoatFactor: unit.default(0), clearcoatTexture: textureInfo.optional(), clearcoatRoughnessFactor: unit.default(0), clearcoatRoughnessTexture: textureInfo.optional(), clearcoatNormalTexture: normalTextureInfo.optional() }).optional(),
44
+ KHR_materials_transmission: z.strictObject({ transmissionFactor: unit.default(0), transmissionTexture: textureInfo.optional() }).optional(),
45
+ KHR_materials_sheen: z.strictObject({ sheenColorFactor: z.tuple([unit, unit, unit]).default([0, 0, 0]), sheenColorTexture: textureInfo.optional(), sheenRoughnessFactor: unit.default(0), sheenRoughnessTexture: textureInfo.optional() }).optional(),
46
+ KHR_materials_anisotropy: z.strictObject({ anisotropyStrength: unit.default(0), anisotropyRotation: z.number().finite().min(0).max(2 * Math.PI).default(0), anisotropyTexture: textureInfo.optional() }).optional(),
47
+ KHR_materials_ior: z.strictObject({ ior: z.number().finite().min(1).max(5).default(1.5) }).optional(),
48
+ KHR_materials_emissive_strength: z.strictObject({ emissiveStrength: z.number().finite().min(0).max(100_000).default(1) }).optional(),
49
+ })
26
50
  const samplerSchema = z.strictObject({
27
51
  ...metadata, magFilter: z.union([z.literal(9728), z.literal(9729)]).optional(),
28
52
  minFilter: z.union([z.literal(9728), z.literal(9729), z.literal(9984), z.literal(9985), z.literal(9986), z.literal(9987)]).optional(),
@@ -30,7 +54,7 @@ const samplerSchema = z.strictObject({
30
54
  wrapT: z.union([z.literal(33071), z.literal(33648), z.literal(10497)]).default(10497),
31
55
  })
32
56
  const nodeSchema = z.strictObject({
33
- ...metadata, children: z.array(index).max(SPATIAL_GLB_LIMITS.nodes).default([]), mesh: index.optional(),
57
+ ...metadata, children: z.array(index).max(SPATIAL_GLB_LIMITS.nodes).default([]), mesh: index.optional(), skin: index.optional(),
34
58
  translation: vec3.optional(), rotation: quaternion.optional(), scale: vec3.optional(),
35
59
  matrix: z.array(finite).length(16).optional(),
36
60
  })
@@ -38,32 +62,37 @@ const accessorSchema = z.strictObject({
38
62
  ...metadata, bufferView: index, byteOffset: byteOffset.default(0),
39
63
  componentType: z.union([z.literal(5121), z.literal(5123), z.literal(5125), z.literal(5126)]),
40
64
  normalized: z.boolean().default(false), count: z.number().int().min(1).max(SPATIAL_GLB_LIMITS.triangles * 3),
41
- type: z.enum(["SCALAR", "VEC2", "VEC3", "VEC4"]), min: z.array(finite).min(1).max(4).optional(), max: z.array(finite).min(1).max(4).optional(),
65
+ type: z.enum(["SCALAR", "VEC2", "VEC3", "VEC4", "MAT4"]), min: z.array(finite).min(1).max(4).optional(), max: z.array(finite).min(1).max(4).optional(),
42
66
  })
43
67
  const gltfSchema = z.strictObject({
44
68
  ...metadata,
45
69
  asset: z.strictObject({ version: z.literal("2.0"), minVersion: z.literal("2.0").optional(), generator: z.string().max(1024).optional(), copyright: z.string().max(4096).optional(), extras: z.unknown().optional(), extensions: z.never().optional() }),
46
- extensionsUsed: z.array(z.never()).optional(), extensionsRequired: z.array(z.never()).optional(),
70
+ extensionsUsed: z.array(supportedExtension).max(7).optional(), extensionsRequired: z.array(supportedExtension).max(7).optional(),
47
71
  buffers: z.array(z.strictObject({ ...metadata, byteLength: z.number().int().min(1).max(SPATIAL_GLB_LIMITS.bytes) })).length(1),
48
72
  bufferViews: z.array(z.strictObject({ ...metadata, buffer: z.literal(0), byteOffset: byteOffset.default(0), byteLength: z.number().int().min(1).max(SPATIAL_GLB_LIMITS.bytes), byteStride: z.number().int().min(4).max(252).optional(), target: z.union([z.literal(34962), z.literal(34963)]).optional() })).max(SPATIAL_GLB_LIMITS.bufferViews),
49
73
  accessors: z.array(accessorSchema).max(SPATIAL_GLB_LIMITS.accessors),
50
74
  scene: index.optional(), scenes: z.array(z.strictObject({ ...metadata, nodes: z.array(index).min(1).max(SPATIAL_GLB_LIMITS.nodes) })).min(1).max(128),
51
75
  nodes: z.array(nodeSchema).min(1).max(SPATIAL_GLB_LIMITS.nodes),
52
76
  meshes: z.array(z.strictObject({ ...metadata, primitives: z.array(z.strictObject({
53
- ...metadata, attributes: z.strictObject({ POSITION: index, NORMAL: index.optional(), TEXCOORD_0: index.optional() }),
77
+ ...metadata, attributes: z.strictObject({ POSITION: index, NORMAL: index.optional(), TEXCOORD_0: index.optional(), JOINTS_0: index.optional(), WEIGHTS_0: index.optional() }),
54
78
  indices: index.optional(), material: index.optional(), mode: z.literal(4).default(4),
55
- })).min(1).max(SPATIAL_GLB_LIMITS.primitives) })).min(1).max(SPATIAL_GLB_LIMITS.meshes),
79
+ targets: z.array(z.strictObject({ ...metadata, POSITION: index.optional(), NORMAL: index.optional() })).max(SPATIAL_GLB_LIMITS.morphTargetsPerPrimitive).optional(),
80
+ })).min(1).max(SPATIAL_GLB_LIMITS.primitives), weights: z.array(unit).max(SPATIAL_GLB_LIMITS.morphTargetsPerPrimitive).optional() })).min(1).max(SPATIAL_GLB_LIMITS.meshes),
81
+ skins: z.array(z.strictObject({ ...metadata, inverseBindMatrices: index, joints: z.array(index).min(1).max(SPATIAL_GLB_LIMITS.jointsPerSkin), skeleton: index.optional() })).max(SPATIAL_GLB_LIMITS.skins).optional(),
56
82
  materials: z.array(z.strictObject({
57
- ...metadata, pbrMetallicRoughness: z.strictObject({ ...metadata, baseColorFactor: z.tuple([unit, unit, unit, unit]).default([1, 1, 1, 1]), metallicFactor: unit.default(1), roughnessFactor: unit.default(1), baseColorTexture: textureInfo.optional() }).optional(),
83
+ ...metadataWithoutExtensions, extensions: materialExtensions.optional(), pbrMetallicRoughness: z.strictObject({ ...metadata, baseColorFactor: z.tuple([unit, unit, unit, unit]).default([1, 1, 1, 1]), metallicFactor: unit.default(1), roughnessFactor: unit.default(1), baseColorTexture: textureInfo.optional(), metallicRoughnessTexture: textureInfo.optional() }).optional(),
84
+ normalTexture: normalTextureInfo.optional(),
85
+ occlusionTexture: occlusionTextureInfo.optional(),
86
+ emissiveTexture: textureInfo.optional(),
58
87
  alphaMode: z.enum(["OPAQUE", "MASK", "BLEND"]).default("OPAQUE"), alphaCutoff: unit.default(0.5), doubleSided: z.boolean().default(false),
59
- emissiveFactor: z.tuple([z.literal(0), z.literal(0), z.literal(0)]).optional(),
88
+ emissiveFactor: z.tuple([unit, unit, unit]).optional(),
60
89
  })).max(SPATIAL_GLB_LIMITS.materials).default([]),
61
90
  images: z.array(z.strictObject({ ...metadata, bufferView: index, mimeType: z.enum(["image/png", "image/jpeg"]) })).max(SPATIAL_GLB_LIMITS.images).default([]),
62
91
  textures: z.array(z.strictObject({ ...metadata, source: index, sampler: index.optional() })).max(SPATIAL_GLB_LIMITS.images).default([]),
63
92
  samplers: z.array(samplerSchema).max(SPATIAL_GLB_LIMITS.images).default([]),
64
93
  animations: z.array(z.strictObject({ ...metadata,
65
94
  samplers: z.array(z.strictObject({ ...metadata, input: index, output: index, interpolation: z.enum(["STEP", "LINEAR"]).default("LINEAR") })).min(1).max(SPATIAL_GLB_LIMITS.channels),
66
- channels: z.array(z.strictObject({ ...metadata, sampler: index, target: z.strictObject({ ...metadata, node: index, path: z.enum(["translation", "rotation", "scale"]) }) })).min(1).max(SPATIAL_GLB_LIMITS.channels),
95
+ channels: z.array(z.strictObject({ ...metadata, sampler: index, target: z.strictObject({ ...metadata, node: index, path: z.enum(["translation", "rotation", "scale", "weights"]) }) })).min(1).max(SPATIAL_GLB_LIMITS.channels),
67
96
  })).max(SPATIAL_GLB_LIMITS.clips).default([]),
68
97
  })
69
98
  type Gltf = z.infer<typeof gltfSchema>
@@ -76,6 +105,12 @@ export interface SpatialGlbSampler {
76
105
  readonly magFilter?: 9728 | 9729
77
106
  readonly minFilter?: 9728 | 9729 | 9984 | 9985 | 9986 | 9987
78
107
  }
108
+ /** A resolved texture reference: the image the sampled texel data comes from. */
109
+ export interface SpatialGlbTextureRef {
110
+ readonly imageIndex: number
111
+ readonly sampler: SpatialGlbSampler
112
+ readonly transform?: { readonly offset: readonly [number, number]; readonly rotation: number; readonly scale: readonly [number, number] }
113
+ }
79
114
  export interface SpatialGlbMaterial {
80
115
  readonly baseColorLinear: readonly [number, number, number, number]
81
116
  readonly metalness: number
@@ -83,7 +118,36 @@ export interface SpatialGlbMaterial {
83
118
  readonly alphaMode: "OPAQUE" | "MASK" | "BLEND"
84
119
  readonly alphaCutoff: number
85
120
  readonly doubleSided: boolean
86
- readonly baseColorTexture?: { readonly imageIndex: number; readonly sampler: SpatialGlbSampler }
121
+ readonly baseColorTexture?: SpatialGlbTextureRef
122
+ /** ORM layout: occlusion/metalness/roughness in R/G/B, per glTF 2.0. */
123
+ readonly metallicRoughnessTexture?: SpatialGlbTextureRef
124
+ readonly normalTexture?: SpatialGlbTextureRef & { readonly scale?: number }
125
+ readonly occlusionTexture?: SpatialGlbTextureRef & { readonly strength?: number }
126
+ readonly emissiveTexture?: SpatialGlbTextureRef
127
+ /** Declared emissiveFactor, linear RGB in [0,1]. */
128
+ readonly emissiveLinear?: readonly [number, number, number]
129
+ readonly emissiveStrength?: number
130
+ readonly clearcoat?: { readonly factor: number; readonly roughness: number; readonly texture?: SpatialGlbTextureRef; readonly roughnessTexture?: SpatialGlbTextureRef; readonly normalTexture?: SpatialGlbTextureRef & { readonly scale?: number } }
131
+ readonly transmission?: { readonly factor: number; readonly texture?: SpatialGlbTextureRef }
132
+ readonly sheen?: { readonly colorLinear: readonly [number, number, number]; readonly roughness: number; readonly colorTexture?: SpatialGlbTextureRef; readonly roughnessTexture?: SpatialGlbTextureRef }
133
+ readonly anisotropy?: { readonly strength: number; readonly rotation: number; readonly texture?: SpatialGlbTextureRef }
134
+ readonly ior?: number
135
+ }
136
+ /** One row of the material fact table published into asset facts. */
137
+ export interface SpatialGlbMaterialFact {
138
+ readonly name?: string
139
+ readonly alphaMode: "OPAQUE" | "MASK" | "BLEND"
140
+ readonly doubleSided: boolean
141
+ /** Declared texture slots, in fixed baseColor→emissive order. */
142
+ readonly maps: readonly ("baseColor" | "metallicRoughness" | "normal" | "occlusion" | "emissive" | "clearcoat" | "clearcoatRoughness" | "clearcoatNormal" | "transmission" | "sheenColor" | "sheenRoughness" | "anisotropy")[]
143
+ readonly textureTransforms: readonly { readonly map: string; readonly offset: readonly [number, number]; readonly rotation: number; readonly scale: readonly [number, number] }[]
144
+ readonly emissiveLinear?: readonly [number, number, number]
145
+ readonly emissiveStrength?: number
146
+ readonly clearcoat?: { readonly factor: number; readonly roughness: number }
147
+ readonly transmission?: { readonly factor: number }
148
+ readonly sheen?: { readonly colorLinear: readonly [number, number, number]; readonly roughness: number }
149
+ readonly anisotropy?: { readonly strength: number; readonly rotation: number }
150
+ readonly ior?: number
87
151
  }
88
152
  export interface SpatialGlbPrimitive {
89
153
  readonly positions: readonly number[]
@@ -105,12 +169,33 @@ export interface SpatialGlbImage {
105
169
  readonly bytes: Uint8Array
106
170
  }
107
171
  export interface SpatialGlbGeometry {
108
- readonly profile: typeof SPATIAL_GLB_PROFILE
172
+ readonly profile: typeof SPATIAL_GLB_PROFILE | typeof SPATIAL_GLB_RIGGED_PROFILE
109
173
  readonly primitives: readonly SpatialGlbPrimitive[]
110
174
  readonly images: readonly SpatialGlbImage[]
111
175
  readonly bounds: Bounds
112
176
  readonly sourceTimeSeconds: number | null
113
177
  }
178
+ export interface SpatialGlbMorphTargetFact {
179
+ readonly name: string
180
+ readonly hasPosition: boolean
181
+ readonly hasNormal: boolean
182
+ }
183
+ export interface SpatialGlbSkinFact {
184
+ readonly name?: string
185
+ readonly jointNodeIndices: readonly number[]
186
+ readonly inverseBindMatricesAccessor: number
187
+ }
188
+ export interface SpatialGlbClipFact {
189
+ readonly name?: string
190
+ readonly durationSeconds: number
191
+ readonly channels: readonly { readonly nodeIndex: number; readonly path: "translation" | "rotation" | "scale" | "weights" }[]
192
+ }
193
+ export interface SpatialGlbRigFacts {
194
+ readonly profile: typeof SPATIAL_GLB_RIGGED_PROFILE
195
+ readonly skins: readonly SpatialGlbSkinFact[]
196
+ readonly morphTargets: readonly (readonly SpatialGlbMorphTargetFact[][])[]
197
+ readonly clips: readonly SpatialGlbClipFact[]
198
+ }
114
199
  export interface SpatialGlbEvaluateOptions {
115
200
  readonly metersPerUnit: number
116
201
  readonly sourceUp: "x" | "y" | "z"
@@ -118,11 +203,14 @@ export interface SpatialGlbEvaluateOptions {
118
203
  readonly materialMode?: "source" | "entity"
119
204
  readonly timeUs: number
120
205
  readonly clip?: { readonly index: number; readonly offsetUs: number; readonly playback: "once" | "loop" | "freeze" }
206
+ /** Optional override of morph target weights for every evaluated mesh node. */
207
+ readonly morphWeights?: readonly number[]
121
208
  }
122
209
  const optionsSchema = z.strictObject({
123
210
  metersPerUnit: z.number().finite().min(0.000001).max(1e6), sourceUp: z.enum(["x", "y", "z"]), nodeIndex: index.optional(), materialMode: z.enum(["source", "entity"]).default("entity"),
124
211
  timeUs: z.number().int().min(0).max(3_600_000_000),
125
212
  clip: z.strictObject({ index, offsetUs: z.number().int().min(0).max(3_600_000_000), playback: z.enum(["once", "loop", "freeze"]) }).optional(),
213
+ morphWeights: z.array(unit).max(SPATIAL_GLB_LIMITS.morphTargetsPerPrimitive).optional(),
126
214
  })
127
215
 
128
216
  function fail(message: string, path = "glb"): never { throw new SpatialSceneError("invalid-data", `${SPATIAL_GLB_PROFILE}: ${message}`, path) }
@@ -210,16 +298,22 @@ function imageHeader(bytes: Uint8Array, mimeType: SpatialGlbImage["mimeType"]):
210
298
  }
211
299
 
212
300
  interface DecodedAccessor { readonly source: Accessor; readonly values: readonly number[]; readonly components: number }
213
- interface PrimitiveData { readonly positions: readonly number[]; readonly normals?: readonly number[]; readonly uvs?: readonly number[]; readonly indices?: readonly number[]; readonly material: SpatialGlbMaterial }
301
+ interface MorphTargetData { readonly positionDeltas?: readonly number[]; readonly normalDeltas?: readonly number[] }
302
+ interface SkinData { readonly joints: readonly number[]; readonly inverseBindMatrices: readonly number[]; readonly sourceAccessorIndex: number; readonly matrixIndexByJointNode: ReadonlyMap<number, number> }
303
+ interface PrimitiveData { readonly positions: readonly number[]; readonly normals?: readonly number[]; readonly uvs?: readonly number[]; readonly indices?: readonly number[]; readonly material: SpatialGlbMaterial; readonly morphTargets: readonly MorphTargetData[]; readonly weights: readonly number[]; readonly jointIndices?: readonly number[] | undefined; readonly jointWeights?: readonly number[] | undefined }
214
304
  interface ModelState {
215
305
  readonly document: Gltf
216
306
  readonly accessors: readonly DecodedAccessor[]
217
307
  readonly meshPrimitives: readonly (readonly PrimitiveData[])[]
308
+ readonly materialFacts: readonly SpatialGlbMaterialFact[]
218
309
  readonly images: readonly SpatialGlbImage[]
219
310
  readonly parents: readonly (number | null)[]
220
311
  readonly order: readonly number[]
221
312
  readonly reachable: ReadonlySet<number>
222
313
  readonly clipDurations: readonly number[]
314
+ readonly profile: typeof SPATIAL_GLB_PROFILE | typeof SPATIAL_GLB_RIGGED_PROFILE
315
+ readonly skins: readonly SkinData[]
316
+ readonly rigFacts?: SpatialGlbRigFacts | undefined
223
317
  }
224
318
 
225
319
  function readAccessors(document: Gltf, binary: Uint8Array): readonly DecodedAccessor[] {
@@ -233,7 +327,7 @@ function readAccessors(document: Gltf, binary: Uint8Array): readonly DecodedAcce
233
327
  const path = `accessors.${index}`
234
328
  const view = at(document.bufferViews, accessor.bufferView, path)
235
329
  const bytes = accessor.componentType === 5121 ? 1 : accessor.componentType === 5123 ? 2 : 4
236
- const components = accessor.type === "SCALAR" ? 1 : Number(accessor.type.slice(3))
330
+ const components = accessor.type === "SCALAR" ? 1 : accessor.type === "MAT4" ? 16 : Number(accessor.type.slice(3))
237
331
  const stride = view.byteStride ?? components * bytes
238
332
  if (accessor.byteOffset % bytes !== 0 || (view.byteOffset + accessor.byteOffset) % bytes !== 0 || stride < components * bytes || stride % bytes !== 0) fail("Accessor alignment or stride is invalid.", path)
239
333
  if (accessor.byteOffset + (accessor.count - 1) * stride + components * bytes > view.byteLength) fail("Accessor exceeds its buffer view.", path)
@@ -274,12 +368,34 @@ function validateViewRoles(document: Gltf): void {
274
368
  for (const mesh of document.meshes) for (const primitive of mesh.primitives) {
275
369
  for (const index of Object.values(primitive.attributes)) if (index !== undefined) accessor(index, "vertex")
276
370
  if (primitive.indices !== undefined) accessor(primitive.indices, "index")
371
+ if (primitive.targets !== undefined) for (const target of primitive.targets) {
372
+ if (target.POSITION !== undefined) accessor(target.POSITION, "vertex")
373
+ if (target.NORMAL !== undefined) accessor(target.NORMAL, "vertex")
374
+ }
277
375
  }
376
+ if (document.skins !== undefined) for (const skin of document.skins) accessor(skin.inverseBindMatrices, "skin")
278
377
  for (const clip of document.animations) for (const sampler of clip.samplers) { accessor(sampler.input, "animation"); accessor(sampler.output, "animation") }
279
378
  for (const image of document.images) assign(image.bufferView, "image")
280
379
  for (const [viewIndex, ids] of vertexAccessors) if (ids.size > 1 && at(document.bufferViews, viewIndex, "bufferViews").byteStride === undefined) fail("Shared vertex-attribute views require an explicit stride.", `bufferViews.${viewIndex}`)
281
380
  }
282
381
 
382
+ function validateExtensions(document: Gltf): void {
383
+ const used = document.extensionsUsed ?? [], required = document.extensionsRequired ?? []
384
+ if (new Set(used).size !== used.length || new Set(required).size !== required.length) fail("Extension declarations must be unique.", "extensionsUsed")
385
+ for (const extension of required) if (!used.includes(extension)) fail("Every required extension must also be listed in extensionsUsed.", "extensionsRequired")
386
+ const present = new Set<string>()
387
+ const texture = (info: { readonly extensions?: { readonly KHR_texture_transform?: unknown } | undefined } | undefined) => { if (info?.extensions?.KHR_texture_transform !== undefined) present.add("KHR_texture_transform") }
388
+ for (const material of document.materials) {
389
+ for (const name of Object.keys(material.extensions ?? {})) present.add(name)
390
+ texture(material.pbrMetallicRoughness?.baseColorTexture); texture(material.pbrMetallicRoughness?.metallicRoughnessTexture)
391
+ texture(material.normalTexture); texture(material.occlusionTexture); texture(material.emissiveTexture)
392
+ const extensions = material.extensions
393
+ texture(extensions?.KHR_materials_clearcoat?.clearcoatTexture); texture(extensions?.KHR_materials_clearcoat?.clearcoatRoughnessTexture); texture(extensions?.KHR_materials_clearcoat?.clearcoatNormalTexture)
394
+ texture(extensions?.KHR_materials_transmission?.transmissionTexture); texture(extensions?.KHR_materials_sheen?.sheenColorTexture); texture(extensions?.KHR_materials_sheen?.sheenRoughnessTexture); texture(extensions?.KHR_materials_anisotropy?.anisotropyTexture)
395
+ }
396
+ for (const extension of present) if (!used.includes(extension as typeof used[number])) fail(`Used extension ${extension} is not declared.`, "extensionsUsed")
397
+ }
398
+
283
399
  function cleanSampler(sampler: Sampler | undefined): SpatialGlbSampler {
284
400
  return Object.freeze({ wrapS: sampler?.wrapS ?? 10497, wrapT: sampler?.wrapT ?? 10497,
285
401
  ...(sampler?.magFilter === undefined ? {} : { magFilter: sampler.magFilter }), ...(sampler?.minFilter === undefined ? {} : { minFilter: sampler.minFilter }) })
@@ -289,55 +405,248 @@ function materials(document: Gltf): readonly SpatialGlbMaterial[] {
289
405
  at(document.images, texture.source, "textures.source")
290
406
  if (texture.sampler !== undefined) at(document.samplers, texture.sampler, "textures.sampler")
291
407
  }
408
+ const textureRef = (info: { readonly index: number; readonly extensions?: { readonly KHR_texture_transform?: { readonly offset: readonly [number, number]; readonly rotation: number; readonly scale: readonly [number, number] } | undefined } | undefined } | undefined, path: string): SpatialGlbTextureRef | undefined => {
409
+ if (info === undefined) return undefined
410
+ const texture = at(document.textures, info.index, path), transform = info.extensions?.KHR_texture_transform
411
+ return { imageIndex: texture.source, sampler: cleanSampler(texture.sampler === undefined ? undefined : document.samplers[texture.sampler]), ...(transform === undefined ? {} : { transform: { offset: transform.offset, rotation: transform.rotation, scale: transform.scale } }) }
412
+ }
292
413
  return deepFreezeJson(document.materials.map(material => {
293
- const pbr = material.pbrMetallicRoughness
294
- const texture = pbr?.baseColorTexture === undefined ? undefined : at(document.textures, pbr.baseColorTexture.index, "baseColorTexture")
414
+ const pbr = material.pbrMetallicRoughness, extensions = material.extensions
415
+ const clearcoat = extensions?.KHR_materials_clearcoat, transmission = extensions?.KHR_materials_transmission
416
+ const sheen = extensions?.KHR_materials_sheen, anisotropy = extensions?.KHR_materials_anisotropy
417
+ const baseColor = textureRef(pbr?.baseColorTexture, "baseColorTexture"), metallicRoughness = textureRef(pbr?.metallicRoughnessTexture, "metallicRoughnessTexture")
418
+ const normal = textureRef(material.normalTexture, "normalTexture"), occlusion = textureRef(material.occlusionTexture, "occlusionTexture"), emissive = textureRef(material.emissiveTexture, "emissiveTexture")
419
+ const clearcoatTexture = textureRef(clearcoat?.clearcoatTexture, "clearcoatTexture"), clearcoatRoughnessTexture = textureRef(clearcoat?.clearcoatRoughnessTexture, "clearcoatRoughnessTexture"), clearcoatNormal = textureRef(clearcoat?.clearcoatNormalTexture, "clearcoatNormalTexture")
420
+ const transmissionTexture = textureRef(transmission?.transmissionTexture, "transmissionTexture"), sheenColorTexture = textureRef(sheen?.sheenColorTexture, "sheenColorTexture"), sheenRoughnessTexture = textureRef(sheen?.sheenRoughnessTexture, "sheenRoughnessTexture"), anisotropyTexture = textureRef(anisotropy?.anisotropyTexture, "anisotropyTexture")
295
421
  return {
296
422
  baseColorLinear: pbr?.baseColorFactor ?? [1, 1, 1, 1] as const, metalness: pbr?.metallicFactor ?? 1, roughness: pbr?.roughnessFactor ?? 1,
297
423
  alphaMode: material.alphaMode, alphaCutoff: material.alphaCutoff, doubleSided: material.doubleSided,
298
- ...(texture === undefined ? {} : { baseColorTexture: { imageIndex: texture.source, sampler: cleanSampler(texture.sampler === undefined ? undefined : document.samplers[texture.sampler]) } }),
424
+ ...(baseColor === undefined ? {} : { baseColorTexture: baseColor }),
425
+ ...(metallicRoughness === undefined ? {} : { metallicRoughnessTexture: metallicRoughness }),
426
+ ...(normal === undefined ? {} : { normalTexture: { ...normal, ...(material.normalTexture!.scale === undefined ? {} : { scale: material.normalTexture!.scale }) } }),
427
+ ...(occlusion === undefined ? {} : { occlusionTexture: { ...occlusion, ...(material.occlusionTexture!.strength === undefined ? {} : { strength: material.occlusionTexture!.strength }) } }),
428
+ ...(emissive === undefined ? {} : { emissiveTexture: emissive }),
429
+ ...(material.emissiveFactor === undefined ? {} : { emissiveLinear: material.emissiveFactor }),
430
+ ...(extensions?.KHR_materials_emissive_strength === undefined ? {} : { emissiveStrength: extensions.KHR_materials_emissive_strength.emissiveStrength }),
431
+ ...(clearcoat === undefined ? {} : { clearcoat: { factor: clearcoat.clearcoatFactor, roughness: clearcoat.clearcoatRoughnessFactor, ...(clearcoatTexture === undefined ? {} : { texture: clearcoatTexture }), ...(clearcoatRoughnessTexture === undefined ? {} : { roughnessTexture: clearcoatRoughnessTexture }), ...(clearcoatNormal === undefined ? {} : { normalTexture: { ...clearcoatNormal, ...(clearcoat.clearcoatNormalTexture?.scale === undefined ? {} : { scale: clearcoat.clearcoatNormalTexture.scale }) } }) } }),
432
+ ...(transmission === undefined ? {} : { transmission: { factor: transmission.transmissionFactor, ...(transmissionTexture === undefined ? {} : { texture: transmissionTexture }) } }),
433
+ ...(sheen === undefined ? {} : { sheen: { colorLinear: sheen.sheenColorFactor, roughness: sheen.sheenRoughnessFactor, ...(sheenColorTexture === undefined ? {} : { colorTexture: sheenColorTexture }), ...(sheenRoughnessTexture === undefined ? {} : { roughnessTexture: sheenRoughnessTexture }) } }),
434
+ ...(anisotropy === undefined ? {} : { anisotropy: { strength: anisotropy.anisotropyStrength, rotation: anisotropy.anisotropyRotation, ...(anisotropyTexture === undefined ? {} : { texture: anisotropyTexture }) } }),
435
+ ...(extensions?.KHR_materials_ior === undefined ? {} : { ior: extensions.KHR_materials_ior.ior }),
299
436
  }
300
437
  }))
301
438
  }
302
439
 
303
- function readMeshes(document: Gltf, accessors: readonly DecodedAccessor[]): readonly (readonly PrimitiveData[])[] {
304
- const sources = materials(document)
305
- const defaultMaterial: SpatialGlbMaterial = { baseColorLinear: [1, 1, 1, 1], metalness: 1, roughness: 1, alphaMode: "OPAQUE", alphaCutoff: 0.5, doubleSided: false }
306
- let primitiveCount = 0, triangles = 0
307
- return deepFreezeJson(document.meshes.map((mesh, meshIndex) => mesh.primitives.map((primitive, primitiveIndex) => {
308
- const path = `meshes.${meshIndex}.primitives.${primitiveIndex}`
309
- if (++primitiveCount > SPATIAL_GLB_LIMITS.primitives) fail("Source primitive count exceeds this profile.", path)
310
- const positions = at(accessors, primitive.attributes.POSITION, path)
311
- const normals = primitive.attributes.NORMAL === undefined ? undefined : at(accessors, primitive.attributes.NORMAL, path)
312
- const uvs = primitive.attributes.TEXCOORD_0 === undefined ? undefined : at(accessors, primitive.attributes.TEXCOORD_0, path)
313
- const indices = primitive.indices === undefined ? undefined : at(accessors, primitive.indices, path)
314
- if (positions.source.type !== "VEC3" || positions.source.componentType !== 5126 || positions.source.normalized || positions.source.min === undefined || positions.source.max === undefined || positions.source.count > SPATIAL_GLB_LIMITS.verticesPerPrimitive) fail("POSITION requires bounded float32 VEC3 with declared min/max.", path)
315
- for (const attribute of [positions, normals, uvs]) if (attribute !== undefined) {
316
- const view = document.bufferViews[attribute.source.bufferView]!
317
- if ((view.byteOffset + attribute.source.byteOffset) % 4 !== 0 || attribute.source.byteOffset % 4 !== 0 || (view.target !== undefined && view.target !== 34962)) fail("Vertex attributes require four-byte alignment and ARRAY_BUFFER target.", path)
318
- const componentBytes = attribute.source.componentType === 5121 ? 1 : attribute.source.componentType === 5123 ? 2 : 4
319
- if ((view.byteStride ?? attribute.components * componentBytes) % 4 !== 0) fail("Every vertex attribute element must remain four-byte aligned.", path)
320
- if (attribute.source.count !== positions.source.count || attribute.values.some(value => Math.abs(value) > 1e6)) fail("Vertex attributes require matching counts and bounded coordinates.", path)
440
+ /** Document-level material facts: every declared material, referenced or not. */
441
+ function materialFacts(document: Gltf, sources: readonly SpatialGlbMaterial[]): readonly SpatialGlbMaterialFact[] {
442
+ return deepFreezeJson(document.materials.map((material, index) => {
443
+ const resolved = sources[index]!
444
+ const slots = [
445
+ ["baseColor", resolved.baseColorTexture], ["metallicRoughness", resolved.metallicRoughnessTexture], ["normal", resolved.normalTexture], ["occlusion", resolved.occlusionTexture], ["emissive", resolved.emissiveTexture],
446
+ ["clearcoat", resolved.clearcoat?.texture], ["clearcoatRoughness", resolved.clearcoat?.roughnessTexture], ["clearcoatNormal", resolved.clearcoat?.normalTexture], ["transmission", resolved.transmission?.texture], ["sheenColor", resolved.sheen?.colorTexture], ["sheenRoughness", resolved.sheen?.roughnessTexture], ["anisotropy", resolved.anisotropy?.texture],
447
+ ] as const
448
+ const maps = slots.filter((entry): entry is typeof entry & readonly [string, SpatialGlbTextureRef] => entry[1] !== undefined).map(entry => entry[0])
449
+ const textureTransforms = slots.flatMap(([map, texture]) => texture?.transform === undefined ? [] : [{ map, ...texture.transform }])
450
+ return {
451
+ ...(material.name === undefined ? {} : { name: material.name }),
452
+ alphaMode: resolved.alphaMode, doubleSided: resolved.doubleSided, maps, textureTransforms,
453
+ ...(resolved.emissiveLinear === undefined ? {} : { emissiveLinear: resolved.emissiveLinear }),
454
+ ...(resolved.emissiveStrength === undefined ? {} : { emissiveStrength: resolved.emissiveStrength }),
455
+ ...(resolved.clearcoat === undefined ? {} : { clearcoat: { factor: resolved.clearcoat.factor, roughness: resolved.clearcoat.roughness } }),
456
+ ...(resolved.transmission === undefined ? {} : { transmission: { factor: resolved.transmission.factor } }),
457
+ ...(resolved.sheen === undefined ? {} : { sheen: { colorLinear: resolved.sheen.colorLinear, roughness: resolved.sheen.roughness } }),
458
+ ...(resolved.anisotropy === undefined ? {} : { anisotropy: { strength: resolved.anisotropy.strength, rotation: resolved.anisotropy.rotation } }),
459
+ ...(resolved.ior === undefined ? {} : { ior: resolved.ior }),
321
460
  }
322
- if (normals) {
323
- if (normals.source.type !== "VEC3" || normals.source.componentType !== 5126 || normals.source.normalized) fail("NORMAL requires float32 VEC3.", path)
324
- for (let i = 0; i < normals.values.length; i += 3) if (Math.abs(Math.hypot(...normals.values.slice(i, i + 3)) - 1) > 1e-4) fail("Normals must be unit vectors.", path)
461
+ }))
462
+ }
463
+
464
+ function allMaterialTextures(material: SpatialGlbMaterial): readonly (SpatialGlbTextureRef | undefined)[] {
465
+ return [material.baseColorTexture, material.metallicRoughnessTexture, material.normalTexture, material.occlusionTexture, material.emissiveTexture,
466
+ material.clearcoat?.texture, material.clearcoat?.roughnessTexture, material.clearcoat?.normalTexture, material.transmission?.texture,
467
+ material.sheen?.colorTexture, material.sheen?.roughnessTexture, material.anisotropy?.texture]
468
+ }
469
+
470
+ function readMeshes(document: Gltf, accessors: readonly DecodedAccessor[], sources: readonly SpatialGlbMaterial[]): readonly (readonly PrimitiveData[])[] {
471
+ const defaultMaterial: SpatialGlbMaterial = { baseColorLinear: [1, 1, 1, 1], metalness: 1, roughness: 1, alphaMode: "OPAQUE", alphaCutoff: 0.5, doubleSided: false }
472
+ let primitiveCount = 0, triangles = 0, morphTargetDeltaCount = 0
473
+ return deepFreezeJson(document.meshes.map((mesh, meshIndex) => {
474
+ const targetCount = primitiveTargetCount(mesh, meshIndex)
475
+ if (mesh.weights !== undefined && mesh.weights.length !== targetCount) fail("Mesh weights count must match its morph target count.", `meshes.${meshIndex}.weights`)
476
+ const weights: readonly number[] = mesh.weights ?? (targetCount > 0 ? Object.freeze(new Array(targetCount).fill(0)) : Object.freeze([]))
477
+ return mesh.primitives.map((primitive, primitiveIndex) => {
478
+ const path = `meshes.${meshIndex}.primitives.${primitiveIndex}`
479
+ if (++primitiveCount > SPATIAL_GLB_LIMITS.primitives) fail("Source primitive count exceeds this profile.", path)
480
+ if (primitive.targets !== undefined && primitive.targets.length !== targetCount) fail("Every primitive in a mesh must declare the same number of morph targets.", path)
481
+ const positions = at(accessors, primitive.attributes.POSITION, path)
482
+ const normals = primitive.attributes.NORMAL === undefined ? undefined : at(accessors, primitive.attributes.NORMAL, path)
483
+ const uvs = primitive.attributes.TEXCOORD_0 === undefined ? undefined : at(accessors, primitive.attributes.TEXCOORD_0, path)
484
+ const jointsAttr = primitive.attributes.JOINTS_0 === undefined ? undefined : at(accessors, primitive.attributes.JOINTS_0, path)
485
+ const weightsAttr = primitive.attributes.WEIGHTS_0 === undefined ? undefined : at(accessors, primitive.attributes.WEIGHTS_0, path)
486
+ const indices = primitive.indices === undefined ? undefined : at(accessors, primitive.indices, path)
487
+ if (positions.source.type !== "VEC3" || positions.source.componentType !== 5126 || positions.source.normalized || positions.source.min === undefined || positions.source.max === undefined || positions.source.count > SPATIAL_GLB_LIMITS.verticesPerPrimitive) fail("POSITION requires bounded float32 VEC3 with declared min/max.", path)
488
+ for (const attribute of [positions, normals, uvs]) if (attribute !== undefined) {
489
+ const view = document.bufferViews[attribute.source.bufferView]!
490
+ if ((view.byteOffset + attribute.source.byteOffset) % 4 !== 0 || attribute.source.byteOffset % 4 !== 0 || (view.target !== undefined && view.target !== 34962)) fail("Vertex attributes require four-byte alignment and ARRAY_BUFFER target.", path)
491
+ const componentBytes = attribute.source.componentType === 5121 ? 1 : attribute.source.componentType === 5123 ? 2 : 4
492
+ if ((view.byteStride ?? attribute.components * componentBytes) % 4 !== 0) fail("Every vertex attribute element must remain four-byte aligned.", path)
493
+ if (attribute.source.count !== positions.source.count || attribute.values.some(value => Math.abs(value) > 1e6)) fail("Vertex attributes require matching counts and bounded coordinates.", path)
494
+ }
495
+ if (normals) {
496
+ if (normals.source.type !== "VEC3" || normals.source.componentType !== 5126 || normals.source.normalized) fail("NORMAL requires float32 VEC3.", path)
497
+ for (let i = 0; i < normals.values.length; i += 3) if (Math.abs(Math.hypot(...normals.values.slice(i, i + 3)) - 1) > 1e-4) fail("Normals must be unit vectors.", path)
498
+ }
499
+ if (uvs && (uvs.source.type !== "VEC2" || (uvs.source.componentType !== 5126 && !([5121, 5123].includes(uvs.source.componentType) && uvs.source.normalized)))) fail("TEXCOORD_0 requires float32 or normalized unsigned byte/short VEC2.", path)
500
+ for (const attribute of [jointsAttr, weightsAttr]) {
501
+ if (attribute === undefined) continue
502
+ const view = document.bufferViews[attribute.source.bufferView]!
503
+ if ((view.byteOffset + attribute.source.byteOffset) % 4 !== 0 || attribute.source.byteOffset % 4 !== 0 || (view.target !== undefined && view.target !== 34962)) fail("Skinning attributes require four-byte alignment and ARRAY_BUFFER target.", path)
504
+ const componentBytes = attribute.source.componentType === 5121 ? 1 : attribute.source.componentType === 5123 ? 2 : 4
505
+ if ((view.byteStride ?? attribute.components * componentBytes) % 4 !== 0) fail("Skinning attribute elements must remain four-byte aligned.", path)
506
+ if (attribute.source.count !== positions.source.count || attribute.values.some(value => Math.abs(value) > 1e6)) fail("Skinning attributes require matching counts and bounded values.", path)
507
+ }
508
+ let jointIndices: readonly number[] | undefined
509
+ let jointWeights: readonly number[] | undefined
510
+ if ((jointsAttr !== undefined) !== (weightsAttr !== undefined)) fail("JOINTS_0 and WEIGHTS_0 must appear together.", path)
511
+ if (jointsAttr !== undefined) {
512
+ if (jointsAttr.source.type !== "VEC4" || ![5121, 5123].includes(jointsAttr.source.componentType) || jointsAttr.source.normalized || jointsAttr.source.count !== positions.source.count) fail("JOINTS_0 requires unsigned byte/short VEC4 matching POSITION count.", path)
513
+ if (weightsAttr!.source.type !== "VEC4" || weightsAttr!.source.count !== positions.source.count) fail("WEIGHTS_0 requires VEC4 matching POSITION count.", path)
514
+ const normalized = weightsAttr!.source.normalized
515
+ const weightComponent = weightsAttr!.source.componentType
516
+ if (!(weightComponent === 5126 || ((weightComponent === 5121 || weightComponent === 5123) && normalized))) fail("WEIGHTS_0 requires float32 or normalized unsigned byte/short.", path)
517
+ jointIndices = Object.freeze(jointsAttr.values.map(value => {
518
+ const joint = Math.round(value)
519
+ if (joint < 0 || joint >= SPATIAL_GLB_LIMITS.jointsPerSkin || !Number.isFinite(joint) || Math.abs(joint - value) > 1e-6) fail("JOINTS_0 index must be an integer in skin joint range.", path)
520
+ return joint
521
+ }))
522
+ const normalizedWeights: number[] = []
523
+ for (let vertex = 0; vertex < weightsAttr!.source.count; vertex++) {
524
+ const values = weightsAttr!.values.slice(vertex * 4, vertex * 4 + 4)
525
+ const sum = values.reduce((total, value) => total + value, 0)
526
+ if (values.some(value => value < 0 || value > 1) || Math.abs(sum - 1) > 1e-4) fail("Vertex skinning weights must be in [0,1] and sum to one.", path)
527
+ normalizedWeights.push(...values.map(value => value / sum))
528
+ }
529
+ jointWeights = Object.freeze(normalizedWeights)
530
+ }
531
+ if (indices) {
532
+ const view = document.bufferViews[indices.source.bufferView]!
533
+ if (indices.source.type !== "SCALAR" || ![5121, 5123, 5125].includes(indices.source.componentType) || indices.source.normalized || view.byteStride !== undefined || (view.target !== undefined && view.target !== 34963)) fail("Triangle indices require tightly packed unsigned scalar storage.", path)
534
+ const restart = indices.source.componentType === 5121 ? 255 : indices.source.componentType === 5123 ? 65535 : 0xffffffff
535
+ if (indices.values.some(value => value >= positions.source.count || value === restart)) fail("Triangle index is out of range or reserved for primitive restart.", path)
536
+ }
537
+ const vertices = indices?.values.length ?? positions.source.count
538
+ if (vertices % 3 !== 0) fail("TRIANGLES require complete index or vertex triples.", path)
539
+ triangles += vertices / 3
540
+ if (triangles > SPATIAL_GLB_LIMITS.triangles) fail("Source triangle budget exceeded.", path)
541
+ const material = primitive.material === undefined ? defaultMaterial : at(sources, primitive.material, path)
542
+ if (uvs === undefined && allMaterialTextures(material).some(texture => texture !== undefined)) {
543
+ fail("Material textures require TEXCOORD_0.", path)
544
+ }
545
+ const morphTargets: MorphTargetData[] = []
546
+ if (primitive.targets !== undefined) for (const [targetIndex, target] of primitive.targets.entries()) {
547
+ const targetPath = `${path}.targets.${targetIndex}`
548
+ const positionDeltas = target.POSITION === undefined ? undefined : at(accessors, target.POSITION, targetPath)
549
+ const normalDeltas = target.NORMAL === undefined ? undefined : at(accessors, target.NORMAL, targetPath)
550
+ if (positionDeltas === undefined && normalDeltas === undefined) fail("Morph target must declare POSITION or NORMAL deltas.", targetPath)
551
+ if (positionDeltas !== undefined) {
552
+ if (positionDeltas.source.type !== "VEC3" || positionDeltas.source.componentType !== 5126 || positionDeltas.source.normalized || positionDeltas.source.count !== positions.source.count) fail("Morph POSITION deltas require float32 VEC3 matching POSITION count.", targetPath)
553
+ morphTargetDeltaCount += positionDeltas.values.length
554
+ }
555
+ if (normalDeltas !== undefined) {
556
+ if (normalDeltas.source.type !== "VEC3" || normalDeltas.source.componentType !== 5126 || normalDeltas.source.normalized || normalDeltas.source.count !== positions.source.count) fail("Morph NORMAL deltas require float32 VEC3 matching POSITION count.", targetPath)
557
+ morphTargetDeltaCount += normalDeltas.values.length
558
+ }
559
+ for (const attribute of [positionDeltas, normalDeltas]) {
560
+ if (attribute === undefined) continue
561
+ const view = document.bufferViews[attribute.source.bufferView]!
562
+ if ((view.byteOffset + attribute.source.byteOffset) % 4 !== 0 || attribute.source.byteOffset % 4 !== 0 || (view.target !== undefined && view.target !== 34962)) fail("Morph target attributes require four-byte alignment and ARRAY_BUFFER target.", targetPath)
563
+ if ((view.byteStride ?? attribute.components * 4) % 4 !== 0) fail("Morph target attribute elements must remain four-byte aligned.", targetPath)
564
+ if (attribute.values.some(value => Math.abs(value) > 1e6)) fail("Morph target deltas must be bounded finite values.", targetPath)
565
+ }
566
+ if (morphTargetDeltaCount > SPATIAL_GLB_LIMITS.morphTargetDeltas) fail("Morph target delta budget exceeded.", targetPath)
567
+ morphTargets.push(Object.freeze({ ...(positionDeltas === undefined ? {} : { positionDeltas: positionDeltas.values }), ...(normalDeltas === undefined ? {} : { normalDeltas: normalDeltas.values }) }))
568
+ }
569
+ return Object.freeze({
570
+ positions: positions.values, ...(normals === undefined ? {} : { normals: normals.values }), ...(uvs === undefined ? {} : { uvs: uvs.values }), ...(indices === undefined ? {} : { indices: indices.values }),
571
+ ...(jointIndices === undefined ? {} : { jointIndices, jointWeights }), material, morphTargets: Object.freeze(morphTargets), weights,
572
+ })
573
+ })
574
+ }))
575
+ }
576
+
577
+ function primitiveTargetCount(mesh: { readonly primitives: readonly { readonly targets?: readonly unknown[] | undefined }[]; readonly weights?: readonly number[] | undefined }, meshIndex: number): number {
578
+ const count = mesh.primitives[0]?.targets?.length ?? 0
579
+ for (const [primitiveIndex, primitive] of mesh.primitives.entries()) {
580
+ if ((primitive.targets?.length ?? 0) !== count) fail("Every primitive in a mesh must declare the same number of morph targets.", `meshes.${meshIndex}.primitives.${primitiveIndex}`)
581
+ }
582
+ return count
583
+ }
584
+
585
+ function readSkins(document: Gltf, accessors: readonly DecodedAccessor[]): readonly SkinData[] {
586
+ if (document.skins === undefined) return Object.freeze([])
587
+ return Object.freeze(document.skins.map((skin, skinIndex) => {
588
+ const path = `skins.${skinIndex}`
589
+ const inverseBindMatrices = at(accessors, skin.inverseBindMatrices, path)
590
+ if (inverseBindMatrices.source.type !== "MAT4" || inverseBindMatrices.source.componentType !== 5126 || inverseBindMatrices.source.normalized || inverseBindMatrices.source.count !== skin.joints.length) fail("Inverse bind matrices require a float32 MAT4 accessor with one matrix per joint.", path)
591
+ const view = document.bufferViews[inverseBindMatrices.source.bufferView]!
592
+ if (view.byteStride !== undefined || view.target !== undefined) fail("Skin inverse bind matrices must be tightly packed without a GPU buffer target.", path)
593
+ for (let index = 0; index < skin.joints.length; index++) {
594
+ const matrix = inverseBindMatrices.values.slice(index * 16, (index + 1) * 16) as unknown as Mat4
595
+ safeMatrix(matrix, `${path}.inverseBindMatrices[${index}]`)
325
596
  }
326
- if (uvs && (uvs.source.type !== "VEC2" || (uvs.source.componentType !== 5126 && !([5121, 5123].includes(uvs.source.componentType) && uvs.source.normalized)))) fail("TEXCOORD_0 requires float32 or normalized unsigned byte/short VEC2.", path)
327
- if (indices) {
328
- const view = document.bufferViews[indices.source.bufferView]!
329
- if (indices.source.type !== "SCALAR" || ![5121, 5123, 5125].includes(indices.source.componentType) || indices.source.normalized || view.byteStride !== undefined || (view.target !== undefined && view.target !== 34963)) fail("Triangle indices require tightly packed unsigned scalar storage.", path)
330
- const restart = indices.source.componentType === 5121 ? 255 : indices.source.componentType === 5123 ? 65535 : 0xffffffff
331
- if (indices.values.some(value => value >= positions.source.count || value === restart)) fail("Triangle index is out of range or reserved for primitive restart.", path)
597
+ if (new Set(skin.joints).size !== skin.joints.length) fail("Skin joints must be unique.", `${path}.joints`)
598
+ for (const joint of skin.joints) at(document.nodes, joint, `${path}.joints`)
599
+ if (skin.skeleton !== undefined) at(document.nodes, skin.skeleton, `${path}.skeleton`)
600
+ const matrixIndexByJointNode = new Map<number, number>()
601
+ for (const [matrixIndex, jointNode] of skin.joints.entries()) matrixIndexByJointNode.set(jointNode, matrixIndex)
602
+ return Object.freeze({ joints: Object.freeze([...skin.joints]), inverseBindMatrices: Object.freeze([...inverseBindMatrices.values]), sourceAccessorIndex: skin.inverseBindMatrices, matrixIndexByJointNode: Object.freeze(matrixIndexByJointNode) })
603
+ }))
604
+ }
605
+
606
+ function rigFacts(document: Gltf, skins: readonly SkinData[], clipDurations: readonly number[]): SpatialGlbRigFacts {
607
+ const skinSources = document.skins!
608
+ const morphTargets: SpatialGlbMorphTargetFact[][][] = []
609
+ for (const [meshIndex, mesh] of document.meshes.entries()) {
610
+ const meshFacts: SpatialGlbMorphTargetFact[][] = []
611
+ const targetNames = meshTargetNames(mesh, meshIndex)
612
+ for (const primitive of mesh.primitives) {
613
+ const primitiveFacts: SpatialGlbMorphTargetFact[] = []
614
+ if (primitive.targets !== undefined) for (const [targetIndex, target] of primitive.targets.entries()) {
615
+ primitiveFacts.push({ name: targetNames[targetIndex] ?? `target${targetIndex}`, hasPosition: target.POSITION !== undefined, hasNormal: target.NORMAL !== undefined })
616
+ }
617
+ meshFacts.push(primitiveFacts)
332
618
  }
333
- const vertices = indices?.values.length ?? positions.source.count
334
- if (vertices % 3 !== 0) fail("TRIANGLES require complete index or vertex triples.", path)
335
- triangles += vertices / 3
336
- if (triangles > SPATIAL_GLB_LIMITS.triangles) fail("Source triangle budget exceeded.", path)
337
- const material = primitive.material === undefined ? defaultMaterial : at(sources, primitive.material, path)
338
- if (material.baseColorTexture && uvs === undefined) fail("Base-color textures require TEXCOORD_0.", path)
339
- return { positions: positions.values, ...(normals === undefined ? {} : { normals: normals.values }), ...(uvs === undefined ? {} : { uvs: uvs.values }), ...(indices === undefined ? {} : { indices: indices.values }), material }
340
- })))
619
+ morphTargets.push(meshFacts)
620
+ }
621
+ return deepFreezeJson({
622
+ profile: SPATIAL_GLB_RIGGED_PROFILE,
623
+ skins: skins.map((skin, index) => ({ ...(skinSources[index]!.name === undefined ? {} : { name: skinSources[index]!.name }), jointNodeIndices: skin.joints, inverseBindMatricesAccessor: skin.sourceAccessorIndex })),
624
+ morphTargets,
625
+ clips: document.animations.map((clip, index) => ({ ...(clip.name === undefined ? {} : { name: clip.name }), durationSeconds: clipDurations[index]!, channels: clip.channels.map(channel => ({ nodeIndex: channel.target.node, path: channel.target.path })) })),
626
+ })
627
+ }
628
+
629
+ function meshTargetNames(mesh: { readonly name?: string | undefined; readonly extras?: unknown; readonly weights?: readonly number[] | undefined; readonly primitives?: readonly unknown[] | undefined }, _meshIndex: number): readonly string[] {
630
+ const extras = mesh.extras
631
+ const list = extras !== null && typeof extras === "object" && "targetNames" in extras && Array.isArray((extras as { targetNames?: unknown }).targetNames) ? (extras as { targetNames: unknown[] }).targetNames : undefined
632
+ if (list === undefined) return []
633
+ return Object.freeze(list.map((value, index) => typeof value === "string" && value.length > 0 ? value : `target${index}`))
634
+ }
635
+
636
+ function detectRigProfile(document: Gltf): boolean {
637
+ if (document.skins !== undefined && document.skins.length > 0) return true
638
+ if (document.nodes.some(node => node.skin !== undefined)) return true
639
+ if (document.meshes.some(mesh => mesh.weights !== undefined || mesh.primitives.some(primitive => primitive.targets !== undefined && primitive.targets.length > 0 || primitive.attributes.JOINTS_0 !== undefined || primitive.attributes.WEIGHTS_0 !== undefined))) return true
640
+ if (document.animations.some(clip => clip.channels.some(channel => channel.target.path === "weights"))) return true
641
+ return false
642
+ }
643
+
644
+ function rejectStaticRigFields(document: Gltf, hasRig: boolean): void {
645
+ if (hasRig) return
646
+ if (document.skins !== undefined) fail("Static GLB profile does not support skins.", "skins")
647
+ if (document.nodes.some(node => node.skin !== undefined)) fail("Static GLB profile does not support skinned nodes.", "nodes")
648
+ if (document.meshes.some(mesh => mesh.weights !== undefined || mesh.primitives.some(primitive => primitive.targets !== undefined || primitive.attributes.JOINTS_0 !== undefined || primitive.attributes.WEIGHTS_0 !== undefined))) fail("Static GLB profile does not support morph targets or skinning attributes.", "meshes")
649
+ if (document.animations.some(clip => clip.channels.some(channel => channel.target.path === "weights"))) fail("Static GLB profile does not support weights animation.", "animations")
341
650
  }
342
651
 
343
652
  function hierarchy(document: Gltf) {
@@ -345,6 +654,12 @@ function hierarchy(document: Gltf) {
345
654
  for (const [index, node] of document.nodes.entries()) {
346
655
  nodeTransform(node, `nodes.${index}`)
347
656
  if (node.mesh !== undefined) at(document.meshes, node.mesh, `nodes.${index}.mesh`)
657
+ if (node.skin !== undefined) {
658
+ if (node.mesh === undefined) fail("Skin reference requires a mesh on the same node.", `nodes.${index}.skin`)
659
+ if (document.skins === undefined) fail("Skin reference requires a declared skins array.", `nodes.${index}.skin`)
660
+ const skin = at(document.skins, node.skin, `nodes.${index}.skin`)
661
+ for (const joint of skin.joints) at(document.nodes, joint, `skins.${node.skin}.joints`)
662
+ }
348
663
  const seen = new Set<number>()
349
664
  for (const child of node.children) {
350
665
  at(document.nodes, child, `nodes.${index}.children`)
@@ -375,6 +690,40 @@ function hierarchy(document: Gltf) {
375
690
  return { parents: Object.freeze(parents), order: Object.freeze(order), reachable }
376
691
  }
377
692
 
693
+ function validateSkinHierarchy(document: Gltf, parents: readonly (number | null)[], reachable: ReadonlySet<number>): void {
694
+ for (const [skinIndex, skin] of (document.skins ?? []).entries()) {
695
+ for (const joint of skin.joints) {
696
+ if (!reachable.has(joint)) fail("Every skin joint must belong to the selected default scene.", `skins.${skinIndex}.joints`)
697
+ if (skin.skeleton === undefined) continue
698
+ let current: number | null = joint
699
+ while (current !== null && current !== skin.skeleton) current = parents[current]!
700
+ if (current === null) fail("The declared skeleton must be an ancestor of every skin joint.", `skins.${skinIndex}.skeleton`)
701
+ }
702
+ }
703
+ }
704
+
705
+ function validateSkinBindings(document: Gltf, meshPrimitives: readonly (readonly PrimitiveData[])[], skins: readonly SkinData[]): void {
706
+ if (detectRigProfile(document) && skins.length === 0) fail("The rigged GLB profile requires at least one declared skin.", "skins")
707
+ for (const [nodeIndex, node] of document.nodes.entries()) {
708
+ if (node.mesh === undefined) {
709
+ if (node.skin !== undefined) fail("A skinned node must reference a mesh.", `nodes.${nodeIndex}.skin`)
710
+ continue
711
+ }
712
+ const mesh = meshPrimitives[node.mesh]!
713
+ if (node.skin === undefined) {
714
+ if (mesh.some(primitive => primitive.jointIndices !== undefined)) fail("Skinning attributes require an explicit skin on every mesh instance.", `nodes.${nodeIndex}.mesh`)
715
+ continue
716
+ }
717
+ const skin = skins[node.skin]
718
+ if (skin === undefined) fail("Skinned node references a missing skin.", `nodes.${nodeIndex}.skin`)
719
+ for (const [primitiveIndex, primitive] of mesh.entries()) {
720
+ const path = `nodes.${nodeIndex}.mesh.primitives.${primitiveIndex}`
721
+ if (primitive.jointIndices === undefined || primitive.jointWeights === undefined) fail("Skinned mesh primitives must declare JOINTS_0 and WEIGHTS_0.", path)
722
+ for (const joint of primitive.jointIndices) if (joint < 0 || joint >= skin.joints.length) fail("JOINTS_0 index exceeds the skin joint count.", path)
723
+ }
724
+ }
725
+ }
726
+
378
727
  function animationDurations(document: Gltf, accessors: readonly DecodedAccessor[]): readonly number[] {
379
728
  let totalChannels = 0
380
729
  return Object.freeze(document.animations.map((clip, clipIndex) => {
@@ -384,7 +733,7 @@ function animationDurations(document: Gltf, accessors: readonly DecodedAccessor[
384
733
  for (const sampler of clip.samplers) {
385
734
  const input = at(accessors, sampler.input, "animation input"), output = at(accessors, sampler.output, "animation output")
386
735
  if (input.source.type !== "SCALAR" || input.source.componentType !== 5126 || input.source.normalized || input.source.count > SPATIAL_GLB_LIMITS.animationKeys || input.source.min === undefined || input.source.max === undefined) fail("Animation input requires bounded float32 scalar seconds with min/max.")
387
- if (output.source.componentType !== 5126 || output.source.normalized || output.source.count !== input.source.count) fail("Animation output must be float32 with matching key count.")
736
+ if (output.source.componentType !== 5126 || output.source.normalized) fail("Animation output must be float32.")
388
737
  for (const accessor of [input, output]) {
389
738
  const view = document.bufferViews[accessor.source.bufferView]!
390
739
  if (view.byteStride !== undefined || view.target !== undefined) fail("Animation data must be tightly packed without a GPU buffer target.")
@@ -404,11 +753,19 @@ function animationDurations(document: Gltf, accessors: readonly DecodedAccessor[
404
753
  if (writers.has(key)) fail("Animation has multiple writers for one node property.", `animations.${clipIndex}`)
405
754
  writers.add(key)
406
755
  const sampler = at(clip.samplers, channel.sampler, "animation sampler")
407
- const output = accessors[sampler.output]!
408
- if (output.source.type !== (channel.target.path === "rotation" ? "VEC4" : "VEC3")) fail("Animation output arity does not match its target property.")
409
- if (channel.target.path === "rotation") for (let i = 0; i < output.values.length; i += 4) normalizedRotation(output.values.slice(i, i + 4), "animation rotation")
410
- else if (output.values.some(value => Math.abs(value) > 1e6 || (channel.target.path === "scale" && value === 0))) fail("Animation transform values are unbounded or singular.")
411
- if (channel.target.path === "scale" && sampler.interpolation === "LINEAR") for (let i = 3; i < output.values.length; i++) if (Math.sign(output.values[i]!) !== Math.sign(output.values[i - 3]!)) fail("Linear scale animation crosses a singular transform.")
756
+ const input = accessors[sampler.input]!, output = accessors[sampler.output]!
757
+ if (channel.target.path === "weights") {
758
+ const meshIndex = node.mesh ?? fail("Weights animation requires a mesh node target.", `animations.${clipIndex}.channels`)
759
+ const targetCount = primitiveTargetCount(document.meshes[meshIndex]!, meshIndex)
760
+ if (targetCount === 0) fail("Weights animation requires morph targets.", `animations.${clipIndex}.channels`)
761
+ if (output.source.type !== "SCALAR" || output.source.count !== input.source.count * targetCount) fail("Weights animation output must be scalar with input.count * morphTargetCount values.", `animations.${clipIndex}.channels`)
762
+ if (output.values.some(value => value < 0 || value > 1 || !Number.isFinite(value))) fail("Morph weights must be unit values.", `animations.${clipIndex}.channels`)
763
+ } else {
764
+ if (output.source.type !== (channel.target.path === "rotation" ? "VEC4" : "VEC3") || output.source.count !== input.source.count) fail("Animation output arity does not match its target property.")
765
+ if (channel.target.path === "rotation") for (let i = 0; i < output.values.length; i += 4) normalizedRotation(output.values.slice(i, i + 4), "animation rotation")
766
+ else if (output.values.some(value => Math.abs(value) > 1e6 || (channel.target.path === "scale" && value === 0))) fail("Animation transform values are unbounded or singular.")
767
+ if (channel.target.path === "scale" && sampler.interpolation === "LINEAR") for (let i = 3; i < output.values.length; i++) if (Math.sign(output.values[i]!) !== Math.sign(output.values[i - 3]!)) fail("Linear scale animation crosses a singular transform.")
768
+ }
412
769
  }
413
770
  return duration
414
771
  }))
@@ -416,15 +773,22 @@ function animationDurations(document: Gltf, accessors: readonly DecodedAccessor[
416
773
 
417
774
  /** Opaque model retains decoded authored data; callers cannot mutate its geometry or source images. */
418
775
  export class SpatialGlbModel {
419
- readonly profile = SPATIAL_GLB_PROFILE
776
+ readonly profile: typeof SPATIAL_GLB_PROFILE | typeof SPATIAL_GLB_RIGGED_PROFILE
420
777
  readonly nodeCount: number
421
778
  readonly clipDurationsSeconds: readonly number[]
779
+ /** Fact table over every declared material; the host publishes it into asset facts. */
780
+ readonly materialFacts: readonly SpatialGlbMaterialFact[]
781
+ /** Retained rig facts for downstream integration; present only for rigged assets. */
782
+ readonly rigFacts?: SpatialGlbRigFacts | undefined
422
783
  readonly #state: ModelState
423
784
 
424
785
  private constructor(state: ModelState) {
425
786
  this.#state = state
787
+ this.profile = state.profile
426
788
  this.nodeCount = state.document.nodes.length
427
789
  this.clipDurationsSeconds = state.clipDurations
790
+ this.materialFacts = state.materialFacts
791
+ this.rigFacts = state.rigFacts
428
792
  Object.freeze(this)
429
793
  }
430
794
 
@@ -440,14 +804,21 @@ export class SpatialGlbModel {
440
804
  let json: unknown
441
805
  try { json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(20, binHeader))) } catch { return fail("GLB JSON must be valid UTF-8 JSON.") }
442
806
  const document = schemaValue(gltfSchema, json, "gltf")
807
+ validateExtensions(document)
443
808
  const payloadLength = document.buffers[0]!.byteLength
444
809
  if (payloadLength > binLength || binLength - payloadLength > 3) fail("Declared BIN length does not match its padding.")
445
810
  const binary = bytes.subarray(binHeader + 8)
446
811
  if (binary.subarray(payloadLength).some(byte => byte !== 0)) fail("BIN padding must contain zero bytes.")
812
+ const hasRig = detectRigProfile(document)
813
+ rejectStaticRigFields(document, hasRig)
447
814
  validateViewRoles(document)
448
815
  const accessors = readAccessors(document, binary)
449
- const meshPrimitives = readMeshes(document, accessors)
816
+ const sources = materials(document)
817
+ const meshPrimitives = readMeshes(document, accessors, sources)
818
+ const skins = readSkins(document, accessors)
819
+ validateSkinBindings(document, meshPrimitives, skins)
450
820
  const graph = hierarchy(document)
821
+ validateSkinHierarchy(document, graph.parents, graph.reachable)
451
822
  const clipDurations = animationDurations(document, accessors)
452
823
  let totalImageBytes = 0, totalPixels = 0
453
824
  const images = document.images.map((image, imageIndex) => {
@@ -461,7 +832,9 @@ export class SpatialGlbModel {
461
832
  if (totalPixels > SPATIAL_GLB_LIMITS.imagePixels) fail("Embedded decoded image pixel budget exceeded.")
462
833
  return Object.freeze({ imageIndex, mimeType: image.mimeType, ...dimensions, bytes: imageBytes })
463
834
  })
464
- return new SpatialGlbModel({ document: deepFreezeJson(document), accessors, meshPrimitives, images: Object.freeze(images), ...graph, clipDurations })
835
+ const profile = hasRig ? SPATIAL_GLB_RIGGED_PROFILE : SPATIAL_GLB_PROFILE
836
+ const rigFactsValue = hasRig ? rigFacts(document, skins, clipDurations) : undefined
837
+ return new SpatialGlbModel({ document: deepFreezeJson(document), accessors, meshPrimitives, materialFacts: materialFacts(document, sources), images: Object.freeze(images), ...graph, clipDurations, profile, skins, rigFacts: rigFactsValue })
465
838
  }
466
839
 
467
840
  evaluate(input: SpatialGlbEvaluateOptions): SpatialGlbGeometry {
@@ -470,6 +843,7 @@ export class SpatialGlbModel {
470
843
  if (options.nodeIndex !== undefined && !state.reachable.has(options.nodeIndex)) fail("Selected node is absent from the default scene.", "nodeIndex")
471
844
  let sourceTimeSeconds: number | null = null
472
845
  const animated = new Map<number, { translation?: Vec3; rotation?: Quaternion; scale?: Vec3 }>()
846
+ const animatedWeights = new Map<number, readonly number[]>()
473
847
  if (options.clip !== undefined) {
474
848
  const clip = at(document.animations, options.clip.index, "clip.index")
475
849
  const duration = state.clipDurations[options.clip.index]!
@@ -490,10 +864,14 @@ export class SpatialGlbModel {
490
864
  const t = (sourceTimeSeconds - times[lower]!) / (times[upper]! - times[lower]!)
491
865
  value = channel.target.path === "rotation" ? slerpQuaternion(left as unknown as Quaternion, right as unknown as Quaternion, t) : left.map((part, index) => part + (right[index]! - part) * t)
492
866
  }
493
- const pose = animated.get(channel.target.node) ?? {}
494
- if (channel.target.path === "rotation") pose.rotation = normalizedRotation(value, "evaluated clip rotation")
495
- else pose[channel.target.path] = value as Vec3
496
- animated.set(channel.target.node, pose)
867
+ if (channel.target.path === "weights") {
868
+ animatedWeights.set(channel.target.node, Object.freeze(value.map(value => Math.max(0, Math.min(1, value)))))
869
+ } else {
870
+ const pose = animated.get(channel.target.node) ?? {}
871
+ if (channel.target.path === "rotation") pose.rotation = normalizedRotation(value, "evaluated clip rotation")
872
+ else pose[channel.target.path] = value as Vec3
873
+ animated.set(channel.target.node, pose)
874
+ }
497
875
  }
498
876
  }
499
877
  // Source X-up rotates +90° around Z; source Z-up rotates -90° around X.
@@ -517,25 +895,143 @@ export class SpatialGlbModel {
517
895
  for (const sourceNodeIndex of [...selected].sort((a, b) => a - b)) {
518
896
  const node = document.nodes[sourceNodeIndex]!
519
897
  if (node.mesh === undefined) continue
520
- const matrix = safeMatrix(matrices.get(sourceNodeIndex)!, `nodes.${sourceNodeIndex}`)
898
+ const nodeWorld = safeMatrix(matrices.get(sourceNodeIndex)!, `nodes.${sourceNodeIndex}`)
521
899
  for (const [sourcePrimitiveIndex, primitive] of state.meshPrimitives[node.mesh]!.entries()) {
522
900
  if (primitives.length >= SPATIAL_GLB_LIMITS.primitives) fail("Instanced primitive budget exceeded.")
523
901
  triangles += (primitive.indices?.length ?? primitive.positions.length / 3) / 3
524
902
  if (triangles > SPATIAL_GLB_LIMITS.triangles) fail("Instanced triangle budget exceeded.")
525
- const bounds = vertexBounds(primitive.positions, primitive.indices, matrix)
526
- const { material, ...geometry } = primitive
527
- if (options.materialMode === "source" && material.baseColorTexture) imageIds.add(material.baseColorTexture.imageIndex)
528
- primitives.push({ ...geometry, matrix, bounds, sourceNodeIndex, sourcePrimitiveIndex, ...(options.materialMode === "source" ? { material } : {}) })
903
+ const morphWeights = options.morphWeights ?? animatedWeights.get(sourceNodeIndex) ?? primitive.weights
904
+ const skin = node.skin !== undefined ? state.skins[node.skin] : undefined
905
+ const deformed = deformPrimitive(primitive, morphWeights, skin, nodeWorld, matrices)
906
+ const { material, positions: _positions, normals: _normals, morphTargets: _morphTargets, weights: _weights, jointIndices: _jointIndices, jointWeights: _jointWeights, ...geometry } = primitive
907
+ if (options.materialMode === "source") {
908
+ for (const texture of allMaterialTextures(material)) {
909
+ if (texture !== undefined) imageIds.add(texture.imageIndex)
910
+ }
911
+ }
912
+ primitives.push(Object.freeze({ ...geometry, positions: deformed.positions, ...(deformed.normals === undefined ? {} : { normals: deformed.normals }), matrix: nodeWorld, bounds: deformed.bounds, sourceNodeIndex, sourcePrimitiveIndex, ...(options.materialMode === "source" ? { material } : {}) }) as unknown as SpatialGlbPrimitive)
529
913
  }
530
914
  }
531
915
  if (primitives.length === 0) fail("Selected scene or subtree contains no triangle geometry.")
532
916
  const bounds = combineBounds(primitives.map(primitive => primitive.bounds))
533
917
  const images = [...imageIds].sort((a, b) => a - b).map(index => { const image = state.images[index]!; return Object.freeze({ ...image, bytes: image.bytes.slice() }) })
534
- return Object.freeze({ profile: SPATIAL_GLB_PROFILE, primitives: deepFreezeJson(primitives), images: Object.freeze(images), bounds, sourceTimeSeconds })
918
+ const profile = state.profile
919
+ return Object.freeze({ profile, primitives: deepFreezeJson(primitives), images: Object.freeze(images), bounds, sourceTimeSeconds })
535
920
  }
921
+
922
+ /** Deterministic world matrix of a joint node for prop/bone attachment; output is immutable. */
923
+ jointWorldMatrix(input: SpatialGlbEvaluateOptions, jointNodeIndex: number): Mat4 {
924
+ const options = schemaValue(optionsSchema, input, "glb evaluation")
925
+ if (jointNodeIndex < 0 || jointNodeIndex >= this.nodeCount) fail("Joint node index is out of range.", "jointNodeIndex")
926
+ const state = this.#state, document = state.document
927
+ const animated = new Map<number, { translation?: Vec3; rotation?: Quaternion; scale?: Vec3 }>()
928
+ let sourceTimeSeconds: number | null = null
929
+ if (options.clip !== undefined) {
930
+ const clip = at(document.animations, options.clip.index, "clip.index")
931
+ const duration = state.clipDurations[options.clip.index]!
932
+ const requested = (options.timeUs + options.clip.offsetUs) / 1_000_000
933
+ if (options.clip.offsetUs / 1_000_000 > duration) fail("Clip source offset exceeds its duration.")
934
+ if (options.clip.playback === "once" && requested > duration) fail("Once clip playback exceeds its duration.")
935
+ sourceTimeSeconds = options.clip.playback === "loop" ? duration === 0 ? 0 : requested % duration : Math.min(requested, duration)
936
+ for (const channel of clip.channels) {
937
+ if (channel.target.path === "weights") continue
938
+ const sampler = clip.samplers[channel.sampler]!, times = state.accessors[sampler.input]!.values, output = state.accessors[sampler.output]!
939
+ let lower = 0, upper = times.length - 1
940
+ if (sourceTimeSeconds <= times[0]!) upper = 0
941
+ else if (sourceTimeSeconds >= times[upper]!) lower = upper
942
+ else while (upper - lower > 1) { const middle = Math.floor((lower + upper) / 2); if (times[middle]! <= sourceTimeSeconds) lower = middle; else upper = middle }
943
+ const left = output.values.slice(lower * output.components, (lower + 1) * output.components)
944
+ let value: readonly number[] = left
945
+ if (sampler.interpolation === "LINEAR" && upper !== lower) {
946
+ const right = output.values.slice(upper * output.components, (upper + 1) * output.components)
947
+ const t = (sourceTimeSeconds - times[lower]!) / (times[upper]! - times[lower]!)
948
+ value = channel.target.path === "rotation" ? slerpQuaternion(left as unknown as Quaternion, right as unknown as Quaternion, t) : left.map((part, index) => part + (right[index]! - part) * t)
949
+ }
950
+ const pose = animated.get(channel.target.node) ?? {}
951
+ if (channel.target.path === "rotation") pose.rotation = normalizedRotation(value, "evaluated clip rotation")
952
+ else pose[channel.target.path] = value as Vec3
953
+ animated.set(channel.target.node, pose)
954
+ }
955
+ }
956
+ const rotation: Quaternion = options.sourceUp === "x" ? [0, 0, Math.SQRT1_2, Math.SQRT1_2] : options.sourceUp === "z" ? [-Math.SQRT1_2, 0, 0, Math.SQRT1_2] : [0, 0, 0, 1]
957
+ const conversion = composeTransform({ position: [0, 0, 0], rotation, scale: [options.metersPerUnit, options.metersPerUnit, options.metersPerUnit] })
958
+ const matrices = new Map<number, Mat4>()
959
+ for (const index of state.order) {
960
+ const node = document.nodes[index]!, overrides = animated.get(index)
961
+ const local = overrides ? nodeTransform({ ...node, ...overrides }, `nodes.${index}`) : nodeTransform(node, `nodes.${index}`)
962
+ const parent = state.parents[index]
963
+ matrices.set(index, multiplyTransforms(parent === null ? conversion : matrices.get(parent!)!, local))
964
+ }
965
+ return safeMatrix(matrices.get(jointNodeIndex)!, "jointWorldMatrix")
966
+ }
967
+ }
968
+
969
+ function combineBounds(bounds: readonly Bounds[]): Bounds {
970
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
971
+ for (const bound of bounds) for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis]!, bound.min[axis]!); max[axis] = Math.max(max[axis]!, bound.max[axis]!) }
972
+ return deepFreezeJson({ min: min as unknown as Vec3, max: max as unknown as Vec3 })
973
+ }
974
+
975
+ function deformPrimitive(primitive: PrimitiveData, morphWeights: readonly number[], skin: SkinData | undefined, nodeWorld: Mat4, nodeMatrices: ReadonlyMap<number, Mat4>): { positions: readonly number[]; normals?: readonly number[] | undefined; bounds: Bounds } {
976
+ const positions = applyMorphWeights(primitive.positions, primitive.morphTargets.map(target => target.positionDeltas), morphWeights)
977
+ let normals: readonly number[] | undefined
978
+ if (primitive.normals !== undefined) {
979
+ const morphed = applyMorphWeights(primitive.normals, primitive.morphTargets.map(target => target.normalDeltas), morphWeights)
980
+ normals = normalizeVectors(morphed)
981
+ }
982
+ if (skin === undefined) {
983
+ const bounds = transformVertexBounds(positions, primitive.indices, nodeWorld)
984
+ return normals === undefined ? { positions, bounds } : { positions, normals, bounds }
985
+ }
986
+ const deformedPositions: number[] = [], deformedNormals: number[] = []
987
+ const hasNormals = normals !== undefined, meshInverse = invertTransform(nodeWorld)
988
+ for (let vertex = 0; vertex < positions.length / 3; vertex++) {
989
+ const skinMatrix = computeSkinMatrix(vertex, primitive, skin, nodeMatrices, meshInverse)
990
+ const position = transformPoint(skinMatrix, [positions[vertex * 3]!, positions[vertex * 3 + 1]!, positions[vertex * 3 + 2]!])
991
+ deformedPositions.push(position[0], position[1], position[2])
992
+ if (hasNormals) {
993
+ const n = transformNormal(skinMatrix, [normals![vertex * 3]!, normals![vertex * 3 + 1]!, normals![vertex * 3 + 2]!])
994
+ const len = Math.hypot(n[0], n[1], n[2])
995
+ deformedNormals.push(len === 0 ? 0 : n[0] / len, len === 0 ? 0 : n[1] / len, len === 0 ? 0 : n[2] / len)
996
+ }
997
+ }
998
+ const frozenPositions = Object.freeze(deformedPositions), bounds = transformVertexBounds(frozenPositions, primitive.indices, nodeWorld)
999
+ return hasNormals
1000
+ ? { positions: frozenPositions, normals: Object.freeze(deformedNormals), bounds }
1001
+ : { positions: frozenPositions, bounds }
1002
+ }
1003
+
1004
+ function applyMorphWeights(base: readonly number[], deltas: readonly (readonly number[] | undefined)[], weights: readonly number[]): readonly number[] {
1005
+ if (deltas.length === 0 || weights.length === 0) return base
1006
+ const out = base.slice()
1007
+ for (let target = 0; target < deltas.length; target++) {
1008
+ const weight = weights[target] ?? 0
1009
+ if (weight === 0 || deltas[target] === undefined) continue
1010
+ const delta = deltas[target]!
1011
+ for (let index = 0; index < out.length; index++) out[index] = (out[index]! + weight * (delta[index] ?? 0))
1012
+ }
1013
+ return Object.freeze(out)
1014
+ }
1015
+
1016
+ function normalizeVectors(values: readonly number[]): readonly number[] {
1017
+ const out: number[] = []
1018
+ for (let index = 0; index < values.length; index += 3) {
1019
+ const len = Math.hypot(values[index]!, values[index + 1]!, values[index + 2]!)
1020
+ out.push(len === 0 ? 0 : values[index]! / len, len === 0 ? 0 : values[index + 1]! / len, len === 0 ? 0 : values[index + 2]! / len)
1021
+ }
1022
+ return Object.freeze(out)
1023
+ }
1024
+
1025
+ function transformNormal(matrix: Mat4, normal: Vec3): Vec3 {
1026
+ const inverse = invertTransform(matrix)
1027
+ return [
1028
+ inverse[0] * normal[0] + inverse[1] * normal[1] + inverse[2] * normal[2],
1029
+ inverse[4] * normal[0] + inverse[5] * normal[1] + inverse[6] * normal[2],
1030
+ inverse[8] * normal[0] + inverse[9] * normal[1] + inverse[10] * normal[2],
1031
+ ]
536
1032
  }
537
1033
 
538
- function vertexBounds(positions: readonly number[], indices: readonly number[] | undefined, matrix: Mat4): Bounds {
1034
+ function transformVertexBounds(positions: readonly number[], indices: readonly number[] | undefined, matrix: Mat4): Bounds {
539
1035
  const low = [Infinity, Infinity, Infinity], high = [-Infinity, -Infinity, -Infinity]
540
1036
  const count = indices?.length ?? positions.length / 3
541
1037
  for (let index = 0; index < count; index++) {
@@ -545,10 +1041,25 @@ function vertexBounds(positions: readonly number[], indices: readonly number[] |
545
1041
  }
546
1042
  return deepFreezeJson({ min: low as unknown as Vec3, max: high as unknown as Vec3 })
547
1043
  }
548
- function combineBounds(bounds: readonly Bounds[]): Bounds {
549
- const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
550
- for (const bound of bounds) for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis]!, bound.min[axis]!); max[axis] = Math.max(max[axis]!, bound.max[axis]!) }
551
- return deepFreezeJson({ min: min as unknown as Vec3, max: max as unknown as Vec3 })
1044
+
1045
+ function computeSkinMatrix(vertex: number, primitive: PrimitiveData, skin: SkinData, nodeMatrices: ReadonlyMap<number, Mat4>, meshInverse: Mat4): Mat4 {
1046
+ const jointIndices = primitive.jointIndices!
1047
+ const jointWeights = primitive.jointWeights!
1048
+ const entries = new Array<number>(16).fill(0)
1049
+ for (let influence = 0; influence < 4; influence++) {
1050
+ const weight = jointWeights[vertex * 4 + influence]!
1051
+ if (weight === 0) continue
1052
+ const jointNode = skin.joints[jointIndices[vertex * 4 + influence]!]!
1053
+ const jointWorld = nodeMatrices.get(jointNode)
1054
+ if (jointWorld === undefined) fail(`Joint node ${jointNode} missing from hierarchy.`, "skin")
1055
+ const matrixIndex = skin.matrixIndexByJointNode.get(jointNode)
1056
+ if (matrixIndex === undefined) fail(`Joint node ${jointNode} is not in skin joints.`, "skin")
1057
+ const inverseBindMatrix = skin.inverseBindMatrices.slice(matrixIndex * 16, matrixIndex * 16 + 16) as unknown as Mat4
1058
+ const jointMatrix = multiplyTransforms(meshInverse, multiplyTransforms(jointWorld, inverseBindMatrix))
1059
+ for (let entry = 0; entry < 16; entry++) entries[entry] = (entries[entry]! + weight * jointMatrix[entry]!)
1060
+ }
1061
+ entries[15] = 1
1062
+ return Object.freeze(entries) as unknown as Mat4
552
1063
  }
553
1064
 
554
1065
  export function parseSpatialGlb(bytes: Uint8Array): SpatialGlbModel { return SpatialGlbModel.parse(bytes) }
@@ -556,3 +1067,15 @@ export function evaluateSpatialGlb(model: SpatialGlbModel, options: SpatialGlbEv
556
1067
  if (!(model instanceof SpatialGlbModel)) fail("Evaluation requires a parsed GLB model.")
557
1068
  return model.evaluate(options)
558
1069
  }
1070
+
1071
+ /**
1072
+ * Model-space axis-aligned bounds for the default scene's static pose: the
1073
+ * file's own units and axes, after node transforms, with no unit scaling or
1074
+ * up-axis conversion. Derived from decoded vertex positions (tighter than
1075
+ * transformed accessor min/max corners — the parser already proves declared
1076
+ * min/max equal the decoded values). Evaluate with the manifest's
1077
+ * interpretation for scene-space meters on the declared Y-up frame.
1078
+ */
1079
+ export function spatialGlbBounds(model: SpatialGlbModel): Bounds {
1080
+ return evaluateSpatialGlb(model, { metersPerUnit: 1, sourceUp: "y", timeUs: 0 }).bounds
1081
+ }