@hraness/slopcamera 3.2.7 → 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.
- package/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +65 -20
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +6 -9
- package/apps/desktop/application/default-registry.ts +26 -10
- package/apps/desktop/application/operation.ts +14 -5
- package/apps/desktop/application/operations/index.ts +4 -1
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +528 -97
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +31 -2
- package/apps/desktop/cli/commands.ts +1215 -70
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +183 -23
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +26 -50
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +18 -5
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +24 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +737 -291
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +32 -434
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -240
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +1 -1
- package/package.json +11 -24
- package/skills/slopcamera/SKILL.md +14 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +250 -102
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -23
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-mcy8z0br.js +0 -192
- package/dist/index-p63wavx0.js +0 -2029
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-rvghzt7b.js +0 -2013
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-z7239b4h.js +0 -2363
- package/dist/skill-install-dh0ntqba.js +0 -10
- package/src/desktop.ts +0 -253
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
...
|
|
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([
|
|
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?:
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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 (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
else
|
|
496
|
-
|
|
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
|
|
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
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
}
|