@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.
- package/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- 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 +514 -11
- 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 +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- 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/semantic-builder.ts +24 -0
- 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/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- 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 +25 -429
- 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 -226
- 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/package.json +7 -5
- package/skills/slopcamera/SKILL.md +6 -1
- 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 +1 -1
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -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 +222 -4
- 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 -0
- 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/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- package/dist/skill-install-dh0ntqba.js +0 -10
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { canonicalJsonSha256 } from "../code/canonical-json.js"
|
|
3
|
+
import { deepFreezeJson } from "../code/json-snapshot.js"
|
|
4
|
+
import { HUMANOID_BONE_NAMES, type SpatialHumanoidMapping } from "./character.js"
|
|
5
|
+
import {
|
|
6
|
+
SPATIAL_SCENE_LIMITS, SpatialDigestSchema, SpatialFrameRateSchema, SpatialQuaternionSchema,
|
|
7
|
+
SpatialTimeUsSchema, SpatialTransformSchema, SpatialVec3Schema,
|
|
8
|
+
type SpatialFrameRate,
|
|
9
|
+
} from "./contracts.js"
|
|
10
|
+
import { parseSpatialValue, sortSpatialBy, SpatialSceneError } from "./identity.js"
|
|
11
|
+
import {
|
|
12
|
+
composeTransform, multiplyTransforms, normalizeQuaternion, slerpQuaternion,
|
|
13
|
+
type Quaternion, type Vec3,
|
|
14
|
+
} from "./math.js"
|
|
15
|
+
import { spatialFrameCount, spatialFrameSample } from "./time.js"
|
|
16
|
+
|
|
17
|
+
export const SPATIAL_PERFORMANCE_LIMITS = Object.freeze({
|
|
18
|
+
directives: 256,
|
|
19
|
+
clips: 128,
|
|
20
|
+
props: 128,
|
|
21
|
+
samples: 1_024,
|
|
22
|
+
channels: 512,
|
|
23
|
+
keysPerChannel: SPATIAL_SCENE_LIMITS.keysPerChannel,
|
|
24
|
+
bones: 128,
|
|
25
|
+
morphTargets: 16,
|
|
26
|
+
findings: 1_024,
|
|
27
|
+
attachments: 64,
|
|
28
|
+
cameras: 64,
|
|
29
|
+
galleryCandidates: 64,
|
|
30
|
+
bakeProfiles: 32,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** Canonical deterministic body masks. Each maps to an ordered bone set. */
|
|
34
|
+
export const SPATIAL_PERFORMANCE_BODY_MASKS = Object.freeze({
|
|
35
|
+
fullBody: HUMANOID_BONE_NAMES,
|
|
36
|
+
upperBody: [
|
|
37
|
+
"hips", "spine", "chest", "upperChest",
|
|
38
|
+
"leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand",
|
|
39
|
+
"rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand",
|
|
40
|
+
"neck", "head", "leftEye", "rightEye", "jaw",
|
|
41
|
+
"leftThumbProximal", "leftThumbIntermediate", "leftThumbDistal",
|
|
42
|
+
"leftIndexProximal", "leftIndexIntermediate", "leftIndexDistal",
|
|
43
|
+
"leftMiddleProximal", "leftMiddleIntermediate", "leftMiddleDistal",
|
|
44
|
+
"leftRingProximal", "leftRingIntermediate", "leftRingDistal",
|
|
45
|
+
"leftLittleProximal", "leftLittleIntermediate", "leftLittleDistal",
|
|
46
|
+
"rightThumbProximal", "rightThumbIntermediate", "rightThumbDistal",
|
|
47
|
+
"rightIndexProximal", "rightIndexIntermediate", "rightIndexDistal",
|
|
48
|
+
"rightMiddleProximal", "rightMiddleIntermediate", "rightMiddleDistal",
|
|
49
|
+
"rightRingProximal", "rightRingIntermediate", "rightRingDistal",
|
|
50
|
+
"rightLittleProximal", "rightLittleIntermediate", "rightLittleDistal",
|
|
51
|
+
],
|
|
52
|
+
lowerBody: [
|
|
53
|
+
"hips", "spine",
|
|
54
|
+
"leftUpperLeg", "leftLowerLeg", "leftFoot", "leftToes",
|
|
55
|
+
"rightUpperLeg", "rightLowerLeg", "rightFoot", "rightToes",
|
|
56
|
+
],
|
|
57
|
+
head: ["neck", "head", "leftEye", "rightEye", "jaw"],
|
|
58
|
+
spine: ["hips", "spine", "chest", "upperChest", "neck"],
|
|
59
|
+
leftArm: ["leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand"],
|
|
60
|
+
rightArm: ["rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand"],
|
|
61
|
+
leftArmFull: ["leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand",
|
|
62
|
+
"leftThumbProximal", "leftThumbIntermediate", "leftThumbDistal",
|
|
63
|
+
"leftIndexProximal", "leftIndexIntermediate", "leftIndexDistal",
|
|
64
|
+
"leftMiddleProximal", "leftMiddleIntermediate", "leftMiddleDistal",
|
|
65
|
+
"leftRingProximal", "leftRingIntermediate", "leftRingDistal",
|
|
66
|
+
"leftLittleProximal", "leftLittleIntermediate", "leftLittleDistal"],
|
|
67
|
+
rightArmFull: ["rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand",
|
|
68
|
+
"rightThumbProximal", "rightThumbIntermediate", "rightThumbDistal",
|
|
69
|
+
"rightIndexProximal", "rightIndexIntermediate", "rightIndexDistal",
|
|
70
|
+
"rightMiddleProximal", "rightMiddleIntermediate", "rightMiddleDistal",
|
|
71
|
+
"rightRingProximal", "rightRingIntermediate", "rightRingDistal",
|
|
72
|
+
"rightLittleProximal", "rightLittleIntermediate", "rightLittleDistal"],
|
|
73
|
+
leftLeg: ["leftUpperLeg", "leftLowerLeg", "leftFoot", "leftToes"],
|
|
74
|
+
rightLeg: ["rightUpperLeg", "rightLowerLeg", "rightFoot", "rightToes"],
|
|
75
|
+
} as const)
|
|
76
|
+
|
|
77
|
+
export type SpatialPerformanceBodyMaskName = keyof typeof SPATIAL_PERFORMANCE_BODY_MASKS
|
|
78
|
+
|
|
79
|
+
export const SPATIAL_PERFORMANCE_COMPILER_ID = "slopcamera.spatial-performance-compiler@v2"
|
|
80
|
+
|
|
81
|
+
const humanoidBoneName = z.enum(HUMANOID_BONE_NAMES)
|
|
82
|
+
const unit = z.number().finite().min(0).max(1)
|
|
83
|
+
const finiteCoord = z.number().finite().min(-1_000_000).max(1_000_000)
|
|
84
|
+
const positiveTimeScale = z.number().finite().min(0.001).max(1_000)
|
|
85
|
+
const directiveId = z.string().min(1).max(128)
|
|
86
|
+
const bodyMaskName = z.enum(Object.keys(SPATIAL_PERFORMANCE_BODY_MASKS) as [string, ...string[]])
|
|
87
|
+
const bodyMaskBones = z.array(humanoidBoneName).min(1).max(HUMANOID_BONE_NAMES.length)
|
|
88
|
+
const bodyMask = z.union([bodyMaskName, bodyMaskBones])
|
|
89
|
+
|
|
90
|
+
const clipKey = z.strictObject({
|
|
91
|
+
timeUs: SpatialTimeUsSchema,
|
|
92
|
+
position: SpatialVec3Schema,
|
|
93
|
+
rotation: SpatialQuaternionSchema,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const SpatialPerformanceClipSchema = z.strictObject({
|
|
97
|
+
kind: z.literal("slopcamera.spatial-performance-clip"),
|
|
98
|
+
schemaVersion: z.literal(1),
|
|
99
|
+
durationUs: SpatialTimeUsSchema.refine((value) => value > 0, "Clip duration must be positive."),
|
|
100
|
+
channels: z.array(z.strictObject({
|
|
101
|
+
bone: humanoidBoneName,
|
|
102
|
+
keys: z.array(clipKey).min(1).max(SPATIAL_PERFORMANCE_LIMITS.keysPerChannel),
|
|
103
|
+
})).min(1).max(SPATIAL_PERFORMANCE_LIMITS.channels),
|
|
104
|
+
}).superRefine((clip, context) => {
|
|
105
|
+
for (const [index, channel] of clip.channels.entries()) {
|
|
106
|
+
const times = channel.keys.map((key) => key.timeUs)
|
|
107
|
+
for (let i = 1; i < times.length; i += 1) {
|
|
108
|
+
if (times[i]! <= times[i - 1]!) {
|
|
109
|
+
context.addIssue({ code: "custom", path: ["channels", index, "keys"], message: "Clip keys must be sorted by time." })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (channel.keys[0]!.timeUs > 0 || channel.keys[channel.keys.length - 1]!.timeUs < clip.durationUs) {
|
|
113
|
+
context.addIssue({ code: "custom", path: ["channels", index], message: "Clip channel keys must cover [0, durationUs]." })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export const SpatialPerformancePropSchema = z.strictObject({
|
|
119
|
+
propId: z.string().min(1).max(128),
|
|
120
|
+
localOffset: SpatialTransformSchema,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const clipDirective = z.strictObject({
|
|
124
|
+
directiveId,
|
|
125
|
+
kind: z.literal("clip"),
|
|
126
|
+
clipDigest: SpatialDigestSchema,
|
|
127
|
+
startUs: SpatialTimeUsSchema,
|
|
128
|
+
endUs: SpatialTimeUsSchema,
|
|
129
|
+
trimStartUs: SpatialTimeUsSchema,
|
|
130
|
+
trimEndUs: SpatialTimeUsSchema,
|
|
131
|
+
loop: z.union([z.number().int().min(1).max(1_024), z.literal("once")]),
|
|
132
|
+
timeScale: positiveTimeScale,
|
|
133
|
+
mode: z.enum(["override", "additive"]).optional().default("override"),
|
|
134
|
+
mask: bodyMask.optional(),
|
|
135
|
+
}).superRefine((directive, context) => {
|
|
136
|
+
if (directive.endUs <= directive.startUs) context.addIssue({ code: "custom", path: ["endUs"], message: "Clip output end must exceed start." })
|
|
137
|
+
if (directive.trimEndUs <= directive.trimStartUs) context.addIssue({ code: "custom", path: ["trimEndUs"], message: "Clip trim end must exceed start." })
|
|
138
|
+
if (directive.mode === "additive" && directive.loop !== "once") {
|
|
139
|
+
context.addIssue({ code: "custom", path: ["loop"], message: "Additive clips must loop once." })
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const crossfadeDirective = z.strictObject({
|
|
144
|
+
directiveId,
|
|
145
|
+
kind: z.literal("crossfade"),
|
|
146
|
+
fromClipId: directiveId,
|
|
147
|
+
toClipId: directiveId,
|
|
148
|
+
startUs: SpatialTimeUsSchema,
|
|
149
|
+
durationUs: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.durationUs),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const lookAtDirective = z.strictObject({
|
|
153
|
+
directiveId,
|
|
154
|
+
kind: z.literal("look-at"),
|
|
155
|
+
bone: humanoidBoneName,
|
|
156
|
+
target: SpatialVec3Schema,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const twoBoneIkDirective = z.strictObject({
|
|
160
|
+
directiveId,
|
|
161
|
+
kind: z.literal("two-bone-ik"),
|
|
162
|
+
endBone: humanoidBoneName,
|
|
163
|
+
target: SpatialVec3Schema,
|
|
164
|
+
pole: SpatialVec3Schema,
|
|
165
|
+
policy: z.enum(["stretch", "preserve"]).default("stretch"),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const footPlantDirective = z.strictObject({
|
|
169
|
+
directiveId,
|
|
170
|
+
kind: z.literal("foot-plant"),
|
|
171
|
+
bone: humanoidBoneName,
|
|
172
|
+
groundY: finiteCoord,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const morphDirective = z.strictObject({
|
|
176
|
+
directiveId,
|
|
177
|
+
kind: z.literal("morph"),
|
|
178
|
+
index: z.number().int().min(0).max(SPATIAL_PERFORMANCE_LIMITS.morphTargets - 1),
|
|
179
|
+
weight: unit,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const attachDirective = z.strictObject({
|
|
183
|
+
directiveId,
|
|
184
|
+
kind: z.literal("attach"),
|
|
185
|
+
propId: z.string().min(1).max(128),
|
|
186
|
+
bone: humanoidBoneName,
|
|
187
|
+
localOffset: SpatialTransformSchema,
|
|
188
|
+
startUs: SpatialTimeUsSchema,
|
|
189
|
+
endUs: SpatialTimeUsSchema,
|
|
190
|
+
}).superRefine((directive, context) => {
|
|
191
|
+
if (directive.endUs <= directive.startUs) context.addIssue({ code: "custom", path: ["endUs"], message: "Attach end must exceed start." })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const releaseDirective = z.strictObject({
|
|
195
|
+
directiveId,
|
|
196
|
+
kind: z.literal("release"),
|
|
197
|
+
propId: z.string().min(1).max(128),
|
|
198
|
+
startUs: SpatialTimeUsSchema,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const rootTrajectoryDirective = z.strictObject({
|
|
202
|
+
directiveId,
|
|
203
|
+
kind: z.literal("root-trajectory"),
|
|
204
|
+
waypoints: z.array(z.strictObject({
|
|
205
|
+
timeUs: SpatialTimeUsSchema,
|
|
206
|
+
position: SpatialVec3Schema,
|
|
207
|
+
rotation: SpatialQuaternionSchema,
|
|
208
|
+
})).min(2).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
209
|
+
}).superRefine((directive, context) => {
|
|
210
|
+
for (let i = 1; i < directive.waypoints.length; i += 1) {
|
|
211
|
+
if (directive.waypoints[i]!.timeUs <= directive.waypoints[i - 1]!.timeUs) {
|
|
212
|
+
context.addIssue({ code: "custom", path: ["waypoints"], message: "Trajectory waypoints must be sorted by time." })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const springDirective = z.strictObject({
|
|
218
|
+
directiveId,
|
|
219
|
+
kind: z.literal("spring"),
|
|
220
|
+
bone: humanoidBoneName,
|
|
221
|
+
amplitude: z.number().finite().min(0).max(1),
|
|
222
|
+
frequency: z.number().finite().min(0).max(100),
|
|
223
|
+
seed: z.number().int().min(0).max(0xffff_ffff),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
export const SpatialPerformanceDirectiveSchema = z.discriminatedUnion("kind", [
|
|
227
|
+
clipDirective,
|
|
228
|
+
crossfadeDirective,
|
|
229
|
+
lookAtDirective,
|
|
230
|
+
twoBoneIkDirective,
|
|
231
|
+
footPlantDirective,
|
|
232
|
+
morphDirective,
|
|
233
|
+
attachDirective,
|
|
234
|
+
releaseDirective,
|
|
235
|
+
rootTrajectoryDirective,
|
|
236
|
+
springDirective,
|
|
237
|
+
])
|
|
238
|
+
export type SpatialPerformanceDirective = DeepReadonly<z.infer<typeof SpatialPerformanceDirectiveSchema>>
|
|
239
|
+
|
|
240
|
+
export const SpatialPerformancePlanSchema = z.strictObject({
|
|
241
|
+
kind: z.literal("slopcamera.spatial-performance-plan"),
|
|
242
|
+
schemaVersion: z.literal(1),
|
|
243
|
+
sceneSha256: SpatialDigestSchema,
|
|
244
|
+
rigSha256: SpatialDigestSchema,
|
|
245
|
+
mappingSha256: SpatialDigestSchema,
|
|
246
|
+
compilerVersion: z.literal(SPATIAL_PERFORMANCE_COMPILER_ID),
|
|
247
|
+
seed: z.number().int().min(0).max(0xffff_ffff),
|
|
248
|
+
durationUs: SpatialTimeUsSchema.refine((value) => value > 0, "Duration must be positive."),
|
|
249
|
+
frameRate: SpatialFrameRateSchema,
|
|
250
|
+
directives: z.array(SpatialPerformanceDirectiveSchema).min(1).max(SPATIAL_PERFORMANCE_LIMITS.directives),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
export const SpatialPerformanceSourcesSchema = z.strictObject({
|
|
254
|
+
sceneSha256: SpatialDigestSchema,
|
|
255
|
+
rigSha256: SpatialDigestSchema,
|
|
256
|
+
mapping: z.custom<SpatialHumanoidMapping>((value) => value !== null && typeof value === "object"),
|
|
257
|
+
clips: z.record(SpatialDigestSchema, SpatialPerformanceClipSchema).refine(
|
|
258
|
+
(record) => Object.keys(record).length <= SPATIAL_PERFORMANCE_LIMITS.clips,
|
|
259
|
+
"Too many source clips.",
|
|
260
|
+
),
|
|
261
|
+
props: z.record(z.string().min(1).max(128), SpatialPerformancePropSchema).refine(
|
|
262
|
+
(record) => Object.keys(record).length <= SPATIAL_PERFORMANCE_LIMITS.props,
|
|
263
|
+
"Too many props.",
|
|
264
|
+
),
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
export const SpatialPerformanceBoneChannelSchema = z.strictObject({
|
|
268
|
+
kind: z.literal("bone-pose"),
|
|
269
|
+
bone: humanoidBoneName,
|
|
270
|
+
keys: z.array(z.strictObject({
|
|
271
|
+
timeUs: SpatialTimeUsSchema,
|
|
272
|
+
position: SpatialVec3Schema,
|
|
273
|
+
rotation: SpatialQuaternionSchema,
|
|
274
|
+
})).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export const SpatialPerformanceMorphChannelSchema = z.strictObject({
|
|
278
|
+
kind: z.literal("morph"),
|
|
279
|
+
index: z.number().int().min(0).max(SPATIAL_PERFORMANCE_LIMITS.morphTargets - 1),
|
|
280
|
+
keys: z.array(z.strictObject({
|
|
281
|
+
timeUs: SpatialTimeUsSchema,
|
|
282
|
+
value: unit,
|
|
283
|
+
})).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
export const SpatialPerformanceAttachmentChannelSchema = z.strictObject({
|
|
287
|
+
kind: z.literal("attachment"),
|
|
288
|
+
propId: z.string().min(1).max(128),
|
|
289
|
+
keys: z.array(z.strictObject({
|
|
290
|
+
timeUs: SpatialTimeUsSchema,
|
|
291
|
+
attached: z.boolean(),
|
|
292
|
+
parentBone: humanoidBoneName,
|
|
293
|
+
localOffset: SpatialTransformSchema,
|
|
294
|
+
})).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
export const SpatialPerformanceChannelSchema = z.discriminatedUnion("kind", [
|
|
298
|
+
SpatialPerformanceBoneChannelSchema,
|
|
299
|
+
SpatialPerformanceMorphChannelSchema,
|
|
300
|
+
SpatialPerformanceAttachmentChannelSchema,
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
const attachmentSample = z.strictObject({
|
|
304
|
+
attached: z.boolean(),
|
|
305
|
+
parentBone: humanoidBoneName.optional(),
|
|
306
|
+
localOffset: SpatialTransformSchema.optional(),
|
|
307
|
+
worldPosition: SpatialVec3Schema,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
export const SpatialPerformanceSampleSchema = z.strictObject({
|
|
311
|
+
timeUs: SpatialTimeUsSchema,
|
|
312
|
+
boneWorld: z.record(humanoidBoneName, z.strictObject({
|
|
313
|
+
position: SpatialVec3Schema,
|
|
314
|
+
rotation: SpatialQuaternionSchema,
|
|
315
|
+
})),
|
|
316
|
+
attachments: z.record(z.string().min(1).max(128), attachmentSample).refine(
|
|
317
|
+
(record) => Object.keys(record).length <= SPATIAL_PERFORMANCE_LIMITS.attachments,
|
|
318
|
+
"Too many attachments.",
|
|
319
|
+
),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
export const SpatialPerformanceReceiptSchema = z.strictObject({
|
|
323
|
+
kind: z.literal("slopcamera.spatial-performance-receipt"),
|
|
324
|
+
schemaVersion: z.literal(1),
|
|
325
|
+
compiler: z.literal(SPATIAL_PERFORMANCE_COMPILER_ID),
|
|
326
|
+
compilerSha256: SpatialDigestSchema,
|
|
327
|
+
sceneSha256: SpatialDigestSchema,
|
|
328
|
+
rigSha256: SpatialDigestSchema,
|
|
329
|
+
mappingSha256: SpatialDigestSchema,
|
|
330
|
+
directivesSha256: SpatialDigestSchema,
|
|
331
|
+
seed: z.number().int().min(0).max(0xffff_ffff),
|
|
332
|
+
outputSha256: SpatialDigestSchema,
|
|
333
|
+
durationUs: SpatialTimeUsSchema,
|
|
334
|
+
sampleCount: z.number().int().min(1).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
export const SpatialPerformanceTakeSchema = z.strictObject({
|
|
338
|
+
kind: z.literal("slopcamera.spatial-performance-take"),
|
|
339
|
+
schemaVersion: z.literal(1),
|
|
340
|
+
planSha256: SpatialDigestSchema,
|
|
341
|
+
durationUs: SpatialTimeUsSchema,
|
|
342
|
+
samples: z.array(SpatialPerformanceSampleSchema).max(SPATIAL_PERFORMANCE_LIMITS.samples),
|
|
343
|
+
channels: z.array(SpatialPerformanceChannelSchema).max(SPATIAL_PERFORMANCE_LIMITS.channels),
|
|
344
|
+
receipt: SpatialPerformanceReceiptSchema,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
export const SpatialPerformanceFindingSchema = z.strictObject({
|
|
348
|
+
kind: z.enum([
|
|
349
|
+
"joint-limit",
|
|
350
|
+
"foot-slide",
|
|
351
|
+
"ground-penetration",
|
|
352
|
+
"gaze-error",
|
|
353
|
+
"attachment-drift",
|
|
354
|
+
"clip-discontinuity",
|
|
355
|
+
"character-camera-collision",
|
|
356
|
+
"character-character-collision",
|
|
357
|
+
]),
|
|
358
|
+
severity: z.enum(["info", "warning"]),
|
|
359
|
+
entityId: z.string().min(1).max(128).optional(),
|
|
360
|
+
bone: humanoidBoneName.optional(),
|
|
361
|
+
timeUs: SpatialTimeUsSchema.optional(),
|
|
362
|
+
detail: z.string().min(1).max(1_024),
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
export const SpatialPerformanceAuditReportSchema = z.strictObject({
|
|
366
|
+
kind: z.literal("slopcamera.spatial-performance-audit"),
|
|
367
|
+
schemaVersion: z.literal(1),
|
|
368
|
+
takeSha256: SpatialDigestSchema,
|
|
369
|
+
findings: z.array(SpatialPerformanceFindingSchema).max(SPATIAL_PERFORMANCE_LIMITS.findings),
|
|
370
|
+
omittedFindings: z.number().int().min(0),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
export const SpatialPerformanceAuditOptionsSchema = z.strictObject({
|
|
374
|
+
characterId: z.string().min(1).max(128).default("character"),
|
|
375
|
+
groundY: finiteCoord.optional(),
|
|
376
|
+
gazeTarget: SpatialVec3Schema.optional(),
|
|
377
|
+
cameras: z.array(z.strictObject({
|
|
378
|
+
cameraId: z.string().min(1).max(128),
|
|
379
|
+
position: SpatialVec3Schema,
|
|
380
|
+
near: z.number().finite().min(0.000001).max(1_000_000),
|
|
381
|
+
})).max(SPATIAL_PERFORMANCE_LIMITS.cameras).optional(),
|
|
382
|
+
otherCharacters: z.array(z.strictObject({
|
|
383
|
+
characterId: z.string().min(1).max(128),
|
|
384
|
+
position: SpatialVec3Schema,
|
|
385
|
+
radius: z.number().finite().min(0.000001).max(100),
|
|
386
|
+
})).max(16).optional(),
|
|
387
|
+
clipBoundaries: z.array(SpatialTimeUsSchema).max(SPATIAL_PERFORMANCE_LIMITS.directives).optional(),
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
export const SpatialPerformanceGalleryCandidateSchema = z.strictObject({
|
|
391
|
+
candidateId: z.string().min(1).max(128),
|
|
392
|
+
takeSha256: SpatialDigestSchema,
|
|
393
|
+
receiptOutputSha256: SpatialDigestSchema,
|
|
394
|
+
label: z.string().min(1).max(256),
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
export const SpatialPerformanceGalleryPlanSchema = z.strictObject({
|
|
398
|
+
kind: z.literal("slopcamera.spatial-performance-gallery-plan"),
|
|
399
|
+
schemaVersion: z.literal(1),
|
|
400
|
+
planSha256: SpatialDigestSchema,
|
|
401
|
+
candidates: z.array(SpatialPerformanceGalleryCandidateSchema)
|
|
402
|
+
.min(1)
|
|
403
|
+
.max(SPATIAL_PERFORMANCE_LIMITS.galleryCandidates),
|
|
404
|
+
}).superRefine((plan, context) => {
|
|
405
|
+
const ids = new Set<string>()
|
|
406
|
+
for (const [index, candidate] of plan.candidates.entries()) {
|
|
407
|
+
if (ids.has(candidate.candidateId)) {
|
|
408
|
+
context.addIssue({ code: "custom", path: ["candidates", index, "candidateId"], message: "Candidate id must be unique." })
|
|
409
|
+
}
|
|
410
|
+
ids.add(candidate.candidateId)
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
export const SpatialPerformanceGallerySelectionSchema = z.strictObject({
|
|
415
|
+
kind: z.literal("slopcamera.spatial-performance-gallery-selection"),
|
|
416
|
+
schemaVersion: z.literal(1),
|
|
417
|
+
galleryPlanSha256: SpatialDigestSchema,
|
|
418
|
+
selectedCandidateId: z.string().min(1).max(128),
|
|
419
|
+
takeSha256: SpatialDigestSchema,
|
|
420
|
+
receiptOutputSha256: SpatialDigestSchema,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
export const SpatialPerformanceBakeEngineSchema = z.strictObject({
|
|
424
|
+
engineId: z.string().min(1).max(128),
|
|
425
|
+
profile: z.string().min(1).max(256),
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
export const SpatialPerformanceBakeInputSchema = z.strictObject({
|
|
429
|
+
inputSha256: SpatialDigestSchema,
|
|
430
|
+
inputProfile: z.string().min(1).max(256),
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
export const SpatialPerformanceBakeOutputSchema = z.strictObject({
|
|
434
|
+
outputProfile: z.string().min(1).max(256),
|
|
435
|
+
outputSha256: SpatialDigestSchema.optional(),
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
export const SpatialPerformanceBakeRequestSchema = z.strictObject({
|
|
439
|
+
kind: z.literal("slopcamera.spatial-performance-bake-request"),
|
|
440
|
+
schemaVersion: z.literal(1),
|
|
441
|
+
engine: SpatialPerformanceBakeEngineSchema,
|
|
442
|
+
input: SpatialPerformanceBakeInputSchema,
|
|
443
|
+
outputs: z.array(SpatialPerformanceBakeOutputSchema).min(1).max(SPATIAL_PERFORMANCE_LIMITS.bakeProfiles),
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
export const SpatialPerformanceBakeReceiptSchema = z.strictObject({
|
|
447
|
+
kind: z.literal("slopcamera.spatial-performance-bake-receipt"),
|
|
448
|
+
schemaVersion: z.literal(1),
|
|
449
|
+
requestSha256: SpatialDigestSchema,
|
|
450
|
+
engine: SpatialPerformanceBakeEngineSchema,
|
|
451
|
+
input: SpatialPerformanceBakeInputSchema,
|
|
452
|
+
outputs: z.array(z.strictObject({
|
|
453
|
+
outputProfile: z.string().min(1).max(256),
|
|
454
|
+
outputSha256: SpatialDigestSchema,
|
|
455
|
+
})).min(1).max(SPATIAL_PERFORMANCE_LIMITS.bakeProfiles),
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T
|
|
459
|
+
|
|
460
|
+
export type SpatialPerformancePlan = DeepReadonly<z.infer<typeof SpatialPerformancePlanSchema>>
|
|
461
|
+
export type SpatialPerformanceClip = DeepReadonly<z.infer<typeof SpatialPerformanceClipSchema>>
|
|
462
|
+
export type SpatialPerformanceProp = DeepReadonly<z.infer<typeof SpatialPerformancePropSchema>>
|
|
463
|
+
export type SpatialPerformanceSources = DeepReadonly<z.infer<typeof SpatialPerformanceSourcesSchema>>
|
|
464
|
+
export type SpatialPerformanceTake = DeepReadonly<z.infer<typeof SpatialPerformanceTakeSchema>>
|
|
465
|
+
export type SpatialPerformanceBoneChannel = DeepReadonly<z.infer<typeof SpatialPerformanceBoneChannelSchema>>
|
|
466
|
+
export type SpatialPerformanceMorphChannel = DeepReadonly<z.infer<typeof SpatialPerformanceMorphChannelSchema>>
|
|
467
|
+
export type SpatialPerformanceAttachmentChannel = DeepReadonly<z.infer<typeof SpatialPerformanceAttachmentChannelSchema>>
|
|
468
|
+
export type SpatialPerformanceReceipt = DeepReadonly<z.infer<typeof SpatialPerformanceReceiptSchema>>
|
|
469
|
+
export type SpatialPerformanceFinding = DeepReadonly<z.infer<typeof SpatialPerformanceFindingSchema>>
|
|
470
|
+
export type SpatialPerformanceAuditReport = DeepReadonly<z.infer<typeof SpatialPerformanceAuditReportSchema>>
|
|
471
|
+
export type SpatialPerformanceAuditOptions = DeepReadonly<z.infer<typeof SpatialPerformanceAuditOptionsSchema>>
|
|
472
|
+
export type SpatialPerformanceGalleryPlan = DeepReadonly<z.infer<typeof SpatialPerformanceGalleryPlanSchema>>
|
|
473
|
+
export type SpatialPerformanceGalleryCandidate = DeepReadonly<z.infer<typeof SpatialPerformanceGalleryCandidateSchema>>
|
|
474
|
+
export type SpatialPerformanceGallerySelection = DeepReadonly<z.infer<typeof SpatialPerformanceGallerySelectionSchema>>
|
|
475
|
+
export type SpatialPerformanceBakeRequest = DeepReadonly<z.infer<typeof SpatialPerformanceBakeRequestSchema>>
|
|
476
|
+
export type SpatialPerformanceBakeReceipt = DeepReadonly<z.infer<typeof SpatialPerformanceBakeReceiptSchema>>
|
|
477
|
+
|
|
478
|
+
const BONE_PARENTS: Readonly<Record<string, string | null>> = Object.freeze({
|
|
479
|
+
hips: null,
|
|
480
|
+
spine: "hips",
|
|
481
|
+
chest: "spine",
|
|
482
|
+
upperChest: "chest",
|
|
483
|
+
neck: "upperChest",
|
|
484
|
+
head: "neck",
|
|
485
|
+
leftEye: "head",
|
|
486
|
+
rightEye: "head",
|
|
487
|
+
jaw: "head",
|
|
488
|
+
leftShoulder: "upperChest",
|
|
489
|
+
leftUpperArm: "leftShoulder",
|
|
490
|
+
leftLowerArm: "leftUpperArm",
|
|
491
|
+
leftHand: "leftLowerArm",
|
|
492
|
+
leftUpperLeg: "hips",
|
|
493
|
+
leftLowerLeg: "leftUpperLeg",
|
|
494
|
+
leftFoot: "leftLowerLeg",
|
|
495
|
+
leftToes: "leftFoot",
|
|
496
|
+
rightShoulder: "upperChest",
|
|
497
|
+
rightUpperArm: "rightShoulder",
|
|
498
|
+
rightLowerArm: "rightUpperArm",
|
|
499
|
+
rightHand: "rightLowerArm",
|
|
500
|
+
rightUpperLeg: "hips",
|
|
501
|
+
rightLowerLeg: "rightUpperLeg",
|
|
502
|
+
rightFoot: "rightLowerLeg",
|
|
503
|
+
rightToes: "rightFoot",
|
|
504
|
+
leftThumbProximal: "leftHand",
|
|
505
|
+
leftThumbIntermediate: "leftThumbProximal",
|
|
506
|
+
leftThumbDistal: "leftThumbIntermediate",
|
|
507
|
+
leftIndexProximal: "leftHand",
|
|
508
|
+
leftIndexIntermediate: "leftIndexProximal",
|
|
509
|
+
leftIndexDistal: "leftIndexIntermediate",
|
|
510
|
+
leftMiddleProximal: "leftHand",
|
|
511
|
+
leftMiddleIntermediate: "leftMiddleProximal",
|
|
512
|
+
leftMiddleDistal: "leftMiddleIntermediate",
|
|
513
|
+
leftRingProximal: "leftHand",
|
|
514
|
+
leftRingIntermediate: "leftRingProximal",
|
|
515
|
+
leftRingDistal: "leftRingIntermediate",
|
|
516
|
+
leftLittleProximal: "leftHand",
|
|
517
|
+
leftLittleIntermediate: "leftLittleProximal",
|
|
518
|
+
leftLittleDistal: "leftLittleIntermediate",
|
|
519
|
+
rightThumbProximal: "rightHand",
|
|
520
|
+
rightThumbIntermediate: "rightThumbProximal",
|
|
521
|
+
rightThumbDistal: "rightThumbIntermediate",
|
|
522
|
+
rightIndexProximal: "rightHand",
|
|
523
|
+
rightIndexIntermediate: "rightIndexProximal",
|
|
524
|
+
rightIndexDistal: "rightIndexIntermediate",
|
|
525
|
+
rightMiddleProximal: "rightHand",
|
|
526
|
+
rightMiddleIntermediate: "rightMiddleProximal",
|
|
527
|
+
rightMiddleDistal: "rightMiddleIntermediate",
|
|
528
|
+
rightRingProximal: "rightHand",
|
|
529
|
+
rightRingIntermediate: "rightRingProximal",
|
|
530
|
+
rightRingDistal: "rightRingIntermediate",
|
|
531
|
+
rightLittleProximal: "rightHand",
|
|
532
|
+
rightLittleIntermediate: "rightLittleProximal",
|
|
533
|
+
rightLittleDistal: "rightLittleIntermediate",
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
function assertBoneExists(mapping: SpatialHumanoidMapping, bone: string, path: string): void {
|
|
537
|
+
if (mapping.bones.find((b) => b.canonicalName === bone) === undefined) {
|
|
538
|
+
throw new SpatialSceneError("not-found", `Canonical bone ${bone} is not mapped.`, path)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function assertPropExists(sources: SpatialPerformanceSources, propId: string, path: string): void {
|
|
543
|
+
if (sources.props[propId] === undefined) {
|
|
544
|
+
throw new SpatialSceneError("not-found", `Prop ${propId} is not defined.`, path)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function validatePerformancePlan(plan: SpatialPerformancePlan, sources: SpatialPerformanceSources): void {
|
|
549
|
+
const directiveIds = new Set<string>()
|
|
550
|
+
const clipDirectives = plan.directives.filter((d): d is Extract<SpatialPerformancePlan["directives"][number], { kind: "clip" }> => d.kind === "clip")
|
|
551
|
+
const clipById = new Map(clipDirectives.map((d) => [d.directiveId, d] as const))
|
|
552
|
+
const mappedBones = new Set<string>(sources.mapping.bones.map((bone) => bone.canonicalName))
|
|
553
|
+
|
|
554
|
+
const usedBones = new Set<string>()
|
|
555
|
+
for (const directive of plan.directives) {
|
|
556
|
+
if ("bone" in directive && directive.bone !== undefined) usedBones.add(directive.bone)
|
|
557
|
+
if ("endBone" in directive && directive.endBone !== undefined) usedBones.add(directive.endBone)
|
|
558
|
+
}
|
|
559
|
+
for (const clip of Object.values(sources.clips)) {
|
|
560
|
+
for (const channel of clip.channels) usedBones.add(channel.bone)
|
|
561
|
+
}
|
|
562
|
+
for (const bone of usedBones) {
|
|
563
|
+
if (!mappedBones.has(bone)) continue
|
|
564
|
+
const parent = BONE_PARENTS[bone]
|
|
565
|
+
if (parent !== null && parent !== undefined && !mappedBones.has(parent)) {
|
|
566
|
+
throw new SpatialSceneError("invalid-data", `Mapped bone ${bone} has unmapped parent ${parent}.`, "performance.mapping")
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (const directive of plan.directives) {
|
|
571
|
+
if (directiveIds.has(directive.directiveId)) {
|
|
572
|
+
throw new SpatialSceneError("invalid-data", `Duplicate directiveId ${directive.directiveId}.`, "performance.directives")
|
|
573
|
+
}
|
|
574
|
+
directiveIds.add(directive.directiveId)
|
|
575
|
+
|
|
576
|
+
if (directive.kind === "clip") {
|
|
577
|
+
if (directive.endUs > plan.durationUs) {
|
|
578
|
+
throw new SpatialSceneError("invalid-data", `Clip ${directive.directiveId} ends after plan duration.`, `performance.directive.${directive.directiveId}`)
|
|
579
|
+
}
|
|
580
|
+
const clip = sources.clips[directive.clipDigest]
|
|
581
|
+
if (clip === undefined) {
|
|
582
|
+
throw new SpatialSceneError("not-found", `Clip ${directive.clipDigest} is missing.`, `performance.directive.${directive.directiveId}`)
|
|
583
|
+
}
|
|
584
|
+
if (directive.trimStartUs > clip.durationUs || directive.trimEndUs > clip.durationUs) {
|
|
585
|
+
throw new SpatialSceneError("invalid-data", `Clip ${directive.directiveId} trim bounds exceed clip duration.`, `performance.directive.${directive.directiveId}`)
|
|
586
|
+
}
|
|
587
|
+
for (const channel of clip.channels) {
|
|
588
|
+
if (!mappedBones.has(channel.bone)) {
|
|
589
|
+
throw new SpatialSceneError("invalid-data", `Clip ${directive.clipDigest} references unmapped bone ${channel.bone}.`, `performance.directive.${directive.directiveId}`)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (directive.mode === "additive" && directive.mask === undefined) {
|
|
593
|
+
throw new SpatialSceneError("invalid-data", `Additive clip ${directive.directiveId} must declare a body mask.`, `performance.directive.${directive.directiveId}`)
|
|
594
|
+
}
|
|
595
|
+
} else if (directive.kind === "crossfade") {
|
|
596
|
+
const from = clipById.get(directive.fromClipId)
|
|
597
|
+
const to = clipById.get(directive.toClipId)
|
|
598
|
+
if (from === undefined || from.kind !== "clip") {
|
|
599
|
+
throw new SpatialSceneError("not-found", `Crossfade ${directive.directiveId} references unknown fromClipId ${directive.fromClipId}.`, `performance.directive.${directive.directiveId}`)
|
|
600
|
+
}
|
|
601
|
+
if (to === undefined || to.kind !== "clip") {
|
|
602
|
+
throw new SpatialSceneError("not-found", `Crossfade ${directive.directiveId} references unknown toClipId ${directive.toClipId}.`, `performance.directive.${directive.directiveId}`)
|
|
603
|
+
}
|
|
604
|
+
if (from.mode === "additive" || to.mode === "additive") {
|
|
605
|
+
throw new SpatialSceneError("invalid-data", `Crossfade ${directive.directiveId} cannot reference additive clips.`, `performance.directive.${directive.directiveId}`)
|
|
606
|
+
}
|
|
607
|
+
if (directive.startUs < from.startUs || directive.startUs < to.startUs) {
|
|
608
|
+
throw new SpatialSceneError("invalid-data", `Crossfade ${directive.directiveId} starts before one of its clips.`, `performance.directive.${directive.directiveId}`)
|
|
609
|
+
}
|
|
610
|
+
const fadeEnd = directive.startUs + directive.durationUs
|
|
611
|
+
if (fadeEnd > plan.durationUs) {
|
|
612
|
+
throw new SpatialSceneError("invalid-data", `Crossfade ${directive.directiveId} ends after plan duration.`, `performance.directive.${directive.directiveId}`)
|
|
613
|
+
}
|
|
614
|
+
if (fadeEnd > from.endUs || fadeEnd > to.endUs) {
|
|
615
|
+
throw new SpatialSceneError("invalid-data", `Crossfade ${directive.directiveId} extends beyond one of its clips.`, `performance.directive.${directive.directiveId}`)
|
|
616
|
+
}
|
|
617
|
+
} else if (directive.kind === "attach") {
|
|
618
|
+
if (directive.endUs > plan.durationUs) {
|
|
619
|
+
throw new SpatialSceneError("invalid-data", `Attach ${directive.directiveId} ends after plan duration.`, `performance.directive.${directive.directiveId}`)
|
|
620
|
+
}
|
|
621
|
+
} else if (directive.kind === "release") {
|
|
622
|
+
if (directive.startUs > plan.durationUs) {
|
|
623
|
+
throw new SpatialSceneError("invalid-data", `Release ${directive.directiveId} starts after plan duration.`, `performance.directive.${directive.directiveId}`)
|
|
624
|
+
}
|
|
625
|
+
} else if (directive.kind === "two-bone-ik") {
|
|
626
|
+
const endBone = directive.endBone
|
|
627
|
+
const lowerBone = BONE_PARENTS[endBone]
|
|
628
|
+
const upperBone = lowerBone !== null && lowerBone !== undefined ? BONE_PARENTS[lowerBone] : undefined
|
|
629
|
+
if (lowerBone === null || lowerBone === undefined || upperBone === null || upperBone === undefined) {
|
|
630
|
+
throw new SpatialSceneError("invalid-data", `Two-bone IK ${directive.directiveId} requires at least two parent bones above ${endBone}.`, `performance.directive.${directive.directiveId}`)
|
|
631
|
+
}
|
|
632
|
+
for (const bone of [endBone, lowerBone, upperBone]) {
|
|
633
|
+
if (!mappedBones.has(bone)) {
|
|
634
|
+
throw new SpatialSceneError("invalid-data", `Two-bone IK ${directive.directiveId} chain bone ${bone} is not mapped.`, `performance.directive.${directive.directiveId}`)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
} else if (directive.kind === "root-trajectory") {
|
|
638
|
+
for (const waypoint of directive.waypoints) {
|
|
639
|
+
if (waypoint.timeUs > plan.durationUs) {
|
|
640
|
+
throw new SpatialSceneError("invalid-data", `Root trajectory waypoint time ${waypoint.timeUs} exceeds plan duration.`, `performance.directive.${directive.directiveId}`)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function parseSpatialPerformancePlan(input: unknown): SpatialPerformancePlan {
|
|
648
|
+
const value = parseSpatialValue(SpatialPerformancePlanSchema, input, "performance plan")
|
|
649
|
+
return deepFreezeJson(value) as unknown as SpatialPerformancePlan
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function parseSpatialPerformanceSources(input: unknown): SpatialPerformanceSources {
|
|
653
|
+
const value = parseSpatialValue(SpatialPerformanceSourcesSchema, input, "performance sources")
|
|
654
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceSources
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function parseSpatialPerformanceAuditOptions(input: unknown): SpatialPerformanceAuditOptions {
|
|
658
|
+
const value = parseSpatialValue(SpatialPerformanceAuditOptionsSchema, input, "performance audit options")
|
|
659
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceAuditOptions
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function parseSpatialPerformanceGalleryPlan(input: unknown): SpatialPerformanceGalleryPlan {
|
|
663
|
+
const value = parseSpatialValue(SpatialPerformanceGalleryPlanSchema, input, "performance gallery plan")
|
|
664
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceGalleryPlan
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function parseSpatialPerformanceGallerySelection(input: unknown): SpatialPerformanceGallerySelection {
|
|
668
|
+
const value = parseSpatialValue(SpatialPerformanceGallerySelectionSchema, input, "performance gallery selection")
|
|
669
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceGallerySelection
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function parseSpatialPerformanceBakeRequest(input: unknown): SpatialPerformanceBakeRequest {
|
|
673
|
+
const value = parseSpatialValue(SpatialPerformanceBakeRequestSchema, input, "performance bake request")
|
|
674
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceBakeRequest
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function parseSpatialPerformanceBakeReceipt(input: unknown): SpatialPerformanceBakeReceipt {
|
|
678
|
+
const value = parseSpatialValue(SpatialPerformanceBakeReceiptSchema, input, "performance bake receipt")
|
|
679
|
+
return deepFreezeJson(value) as unknown as SpatialPerformanceBakeReceipt
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function validatePerformanceGallerySelection(
|
|
683
|
+
selection: SpatialPerformanceGallerySelection,
|
|
684
|
+
plan: SpatialPerformanceGalleryPlan,
|
|
685
|
+
take: SpatialPerformanceTake,
|
|
686
|
+
): void {
|
|
687
|
+
const planSha = canonicalJsonSha256(plan)
|
|
688
|
+
if (planSha !== selection.galleryPlanSha256) {
|
|
689
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched gallery plan digest.", "performance.gallery.plan")
|
|
690
|
+
}
|
|
691
|
+
const candidate = plan.candidates.find((c) => c.candidateId === selection.selectedCandidateId)
|
|
692
|
+
if (candidate === undefined) {
|
|
693
|
+
throw new SpatialSceneError("not-found", `Candidate ${selection.selectedCandidateId} is not in the gallery plan.`, "performance.gallery.selection")
|
|
694
|
+
}
|
|
695
|
+
const takeSha = canonicalJsonSha256(take)
|
|
696
|
+
if (takeSha !== selection.takeSha256) {
|
|
697
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched take digest.", "performance.gallery.take")
|
|
698
|
+
}
|
|
699
|
+
if (take.receipt.outputSha256 !== selection.receiptOutputSha256) {
|
|
700
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched receipt output digest.", "performance.gallery.receipt")
|
|
701
|
+
}
|
|
702
|
+
if (candidate.takeSha256 !== selection.takeSha256 || candidate.receiptOutputSha256 !== selection.receiptOutputSha256) {
|
|
703
|
+
throw new SpatialSceneError("invalid-data", "Selection does not match the gallery candidate's bound digests.", "performance.gallery.selection")
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function validatePerformanceBakeReceipt(request: SpatialPerformanceBakeRequest, receipt: SpatialPerformanceBakeReceipt): void {
|
|
708
|
+
const requestSha = canonicalJsonSha256(request)
|
|
709
|
+
if (requestSha !== receipt.requestSha256) {
|
|
710
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched bake request digest.", "performance.bake.request")
|
|
711
|
+
}
|
|
712
|
+
if (request.engine.engineId !== receipt.engine.engineId || request.engine.profile !== receipt.engine.profile) {
|
|
713
|
+
throw new SpatialSceneError("invalid-data", "Bake engine/profile identity mismatch.", "performance.bake.engine")
|
|
714
|
+
}
|
|
715
|
+
if (request.input.inputSha256 !== receipt.input.inputSha256 || request.input.inputProfile !== receipt.input.inputProfile) {
|
|
716
|
+
throw new SpatialSceneError("invalid-data", "Bake input identity mismatch.", "performance.bake.input")
|
|
717
|
+
}
|
|
718
|
+
if (receipt.outputs.length !== request.outputs.length) {
|
|
719
|
+
throw new SpatialSceneError("invalid-data", "Bake output count mismatch.", "performance.bake.outputs")
|
|
720
|
+
}
|
|
721
|
+
const requestProfiles = new Set(request.outputs.map((output) => output.outputProfile))
|
|
722
|
+
for (const output of receipt.outputs) {
|
|
723
|
+
if (!requestProfiles.has(output.outputProfile)) {
|
|
724
|
+
throw new SpatialSceneError("invalid-data", `Bake output profile ${output.outputProfile} is not in the request.`, "performance.bake.outputs")
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function vec3Add(a: Vec3, b: Vec3): Vec3 {
|
|
730
|
+
return Object.freeze([a[0] + b[0], a[1] + b[1], a[2] + b[2]])
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function vec3Sub(a: Vec3, b: Vec3): Vec3 {
|
|
734
|
+
return Object.freeze([a[0] - b[0], a[1] - b[1], a[2] - b[2]])
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function vec3Length(a: Vec3): number {
|
|
738
|
+
return Math.hypot(a[0], a[1], a[2])
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function vec3Normalize(a: Vec3): Vec3 {
|
|
742
|
+
const length = vec3Length(a)
|
|
743
|
+
if (length === 0) return Object.freeze([0, 0, 1])
|
|
744
|
+
return Object.freeze([a[0] / length, a[1] / length, a[2] / length])
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function vec3Dot(a: Vec3, b: Vec3): number {
|
|
748
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function vec3Cross(a: Vec3, b: Vec3): Vec3 {
|
|
752
|
+
return Object.freeze([
|
|
753
|
+
a[1] * b[2] - a[2] * b[1],
|
|
754
|
+
a[2] * b[0] - a[0] * b[2],
|
|
755
|
+
a[0] * b[1] - a[1] * b[0],
|
|
756
|
+
])
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function multiplyQuaternionUnnormalized(a: Quaternion, b: Quaternion): Quaternion {
|
|
760
|
+
return Object.freeze([
|
|
761
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
762
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
763
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
764
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
765
|
+
]) as unknown as Quaternion
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function multiplyQuaternion(a: Quaternion, b: Quaternion): Quaternion {
|
|
769
|
+
return normalizeQuaternion(multiplyQuaternionUnnormalized(a, b))
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function conjugateQuaternion(q: Quaternion): Quaternion {
|
|
773
|
+
return Object.freeze([-q[0], -q[1], -q[2], q[3]])
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function rotateVectorByQuaternion(q: Quaternion, v: Vec3): Vec3 {
|
|
777
|
+
if (vec3Length(v) === 0) return Object.freeze([0, 0, 0])
|
|
778
|
+
const pure = multiplyQuaternionUnnormalized(q, [v[0], v[1], v[2], 0])
|
|
779
|
+
const rotated = multiplyQuaternionUnnormalized(pure, conjugateQuaternion(q))
|
|
780
|
+
return Object.freeze([rotated[0], rotated[1], rotated[2]])
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function quaternionFromVectors(from: Vec3, to: Vec3): Quaternion {
|
|
784
|
+
const f = vec3Normalize(from)
|
|
785
|
+
const t = vec3Normalize(to)
|
|
786
|
+
const d = vec3Dot(f, t)
|
|
787
|
+
if (d > 0.999_999) return Object.freeze([0, 0, 0, 1])
|
|
788
|
+
if (d < -0.999_999) {
|
|
789
|
+
const axis = vec3Cross([0, 1, 0], f)
|
|
790
|
+
const n = vec3Length(axis)
|
|
791
|
+
const up = n < 0.000_001 ? ([1, 0, 0] as Vec3) : axis
|
|
792
|
+
return normalizeQuaternion([up[0], up[1], up[2], 0])
|
|
793
|
+
}
|
|
794
|
+
const cross = vec3Cross(f, t)
|
|
795
|
+
return normalizeQuaternion([cross[0], cross[1], cross[2], 1 + d])
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function worldFromLocal(parentPosition: Vec3, parentRotation: Quaternion, localPosition: Vec3, localRotation: Quaternion): { position: Vec3; rotation: Quaternion } {
|
|
799
|
+
return {
|
|
800
|
+
position: vec3Add(parentPosition, rotateVectorByQuaternion(parentRotation, localPosition)),
|
|
801
|
+
rotation: multiplyQuaternion(parentRotation, localRotation),
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function solveTwoBoneIk(
|
|
806
|
+
baseWorld: { position: Vec3; rotation: Quaternion },
|
|
807
|
+
upperLocal: { position: Vec3; rotation: Quaternion },
|
|
808
|
+
lowerLocal: { position: Vec3; rotation: Quaternion },
|
|
809
|
+
endLocal: { position: Vec3; rotation: Quaternion },
|
|
810
|
+
target: Vec3,
|
|
811
|
+
pole: Vec3,
|
|
812
|
+
policy: "stretch" | "preserve",
|
|
813
|
+
): { upperRotation: Quaternion; lowerRotation: Quaternion } | null {
|
|
814
|
+
const a = vec3Length(lowerLocal.position)
|
|
815
|
+
const b = vec3Length(endLocal.position)
|
|
816
|
+
if (a <= 1e-7) return null
|
|
817
|
+
const targetOffset = vec3Sub(target, baseWorld.position)
|
|
818
|
+
let c = vec3Length(targetOffset)
|
|
819
|
+
const minReach = Math.abs(a - b)
|
|
820
|
+
const maxReach = a + b
|
|
821
|
+
if (c < minReach || c > maxReach) {
|
|
822
|
+
if (policy === "preserve") return null
|
|
823
|
+
c = Math.max(minReach, Math.min(maxReach, c))
|
|
824
|
+
}
|
|
825
|
+
if (c <= 1e-7) return null
|
|
826
|
+
const targetDir = vec3Normalize(targetOffset)
|
|
827
|
+
const poleDir = vec3Normalize(pole)
|
|
828
|
+
let normal = vec3Cross(poleDir, targetDir)
|
|
829
|
+
if (vec3Length(normal) < 1e-7) {
|
|
830
|
+
normal = vec3Cross([0, 1, 0], targetDir)
|
|
831
|
+
if (vec3Length(normal) < 1e-7) normal = vec3Cross([1, 0, 0], targetDir)
|
|
832
|
+
if (vec3Length(normal) < 1e-7) return null
|
|
833
|
+
}
|
|
834
|
+
normal = vec3Normalize(normal)
|
|
835
|
+
const perp = vec3Normalize(vec3Cross(targetDir, normal))
|
|
836
|
+
const cosAlpha = Math.max(-1, Math.min(1, (a * a + c * c - b * b) / (2 * a * c)))
|
|
837
|
+
const sinAlpha = Math.sqrt(Math.max(0, 1 - cosAlpha * cosAlpha))
|
|
838
|
+
const elbowOffset = vec3Add(vec3Scale(targetDir, a * cosAlpha), vec3Scale(perp, a * sinAlpha))
|
|
839
|
+
const elbowWorld = vec3Add(baseWorld.position, elbowOffset)
|
|
840
|
+
const upperRestDir = rotateVectorByQuaternion(upperLocal.rotation, vec3Normalize(lowerLocal.position))
|
|
841
|
+
const upperTargetDir = rotateVectorByQuaternion(conjugateQuaternion(baseWorld.rotation), vec3Normalize(vec3Sub(elbowWorld, baseWorld.position)))
|
|
842
|
+
const qUpper = quaternionFromVectors(upperRestDir, upperTargetDir)
|
|
843
|
+
const solvedUpperWorldRot = multiplyQuaternion(multiplyQuaternion(baseWorld.rotation, qUpper), upperLocal.rotation)
|
|
844
|
+
const lowerRestDir = rotateVectorByQuaternion(lowerLocal.rotation, vec3Normalize(endLocal.position))
|
|
845
|
+
const lowerTargetDir = rotateVectorByQuaternion(conjugateQuaternion(solvedUpperWorldRot), vec3Normalize(vec3Sub(target, elbowWorld)))
|
|
846
|
+
const qLower = quaternionFromVectors(lowerRestDir, lowerTargetDir)
|
|
847
|
+
return {
|
|
848
|
+
upperRotation: multiplyQuaternion(qUpper, upperLocal.rotation),
|
|
849
|
+
lowerRotation: multiplyQuaternion(qLower, lowerLocal.rotation),
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export function poseFromMatrix(matrix: readonly number[]): { position: Vec3; rotation: Quaternion } {
|
|
854
|
+
const m = matrix
|
|
855
|
+
if (m.length < 16) throw new RangeError("Matrix must have 16 components.")
|
|
856
|
+
const position: Vec3 = Object.freeze([m[12]!, m[13]!, m[14]!])
|
|
857
|
+
const trace = m[0]! + m[5]! + m[10]!
|
|
858
|
+
let rotation: Quaternion
|
|
859
|
+
if (trace > 0) {
|
|
860
|
+
const s = 0.5 / Math.sqrt(trace + 1)
|
|
861
|
+
rotation = [(m[6]! - m[9]!) * s, (m[8]! - m[2]!) * s, (m[1]! - m[4]!) * s, 0.25 / s] as Quaternion
|
|
862
|
+
} else if (m[0]! > m[5]! && m[0]! > m[10]!) {
|
|
863
|
+
const s = 2 * Math.sqrt(1 + m[0]! - m[5]! - m[10]!)
|
|
864
|
+
rotation = [0.25 * s, (m[1]! + m[4]!) / s, (m[8]! + m[2]!) / s, (m[6]! - m[9]!) / s] as Quaternion
|
|
865
|
+
} else if (m[5]! > m[10]!) {
|
|
866
|
+
const s = 2 * Math.sqrt(1 + m[5]! - m[0]! - m[10]!)
|
|
867
|
+
rotation = [(m[1]! + m[4]!) / s, 0.25 * s, (m[6]! + m[9]!) / s, (m[8]! - m[2]!) / s] as Quaternion
|
|
868
|
+
} else {
|
|
869
|
+
const s = 2 * Math.sqrt(1 + m[10]! - m[0]! - m[5]!)
|
|
870
|
+
rotation = [(m[8]! + m[2]!) / s, (m[6]! + m[9]!) / s, 0.25 * s, (m[1]! - m[4]!) / s] as Quaternion
|
|
871
|
+
}
|
|
872
|
+
return { position, rotation: normalizeQuaternion(rotation) }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 {
|
|
876
|
+
return Object.freeze([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t])
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function vec3Scale(a: Vec3, s: number): Vec3 {
|
|
880
|
+
return Object.freeze([a[0] * s, a[1] * s, a[2] * s])
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function seededLcg(seed: number): { value: number; next: number } {
|
|
884
|
+
const next = (seed * 1_664_525 + 1_013_904_223) >>> 0
|
|
885
|
+
return { value: next / 0x1_0000_0000, next }
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function springOffset(timeUs: number, seed: number, amplitude: number, frequency: number): { position: Vec3; rotation: Quaternion } {
|
|
889
|
+
let state = seed
|
|
890
|
+
const phases: number[] = []
|
|
891
|
+
for (let i = 0; i < 6; i += 1) {
|
|
892
|
+
const lcg = seededLcg(state)
|
|
893
|
+
state = lcg.next
|
|
894
|
+
phases.push(lcg.value * Math.PI * 2)
|
|
895
|
+
}
|
|
896
|
+
const t = timeUs / 1_000_000
|
|
897
|
+
const envelope = Math.exp(-t)
|
|
898
|
+
const px = Math.sin(2 * Math.PI * frequency * t + phases[0]!) * amplitude * envelope
|
|
899
|
+
const py = Math.sin(2 * Math.PI * frequency * t + phases[1]!) * amplitude * envelope
|
|
900
|
+
const pz = Math.sin(2 * Math.PI * frequency * t + phases[2]!) * amplitude * envelope
|
|
901
|
+
const rx = Math.sin(2 * Math.PI * frequency * t + phases[3]!) * amplitude * envelope * 0.5
|
|
902
|
+
const ry = Math.sin(2 * Math.PI * frequency * t + phases[4]!) * amplitude * envelope * 0.5
|
|
903
|
+
const rz = Math.sin(2 * Math.PI * frequency * t + phases[5]!) * amplitude * envelope * 0.5
|
|
904
|
+
const rw = Math.sqrt(Math.max(0, 1 - (rx * rx + ry * ry + rz * rz)))
|
|
905
|
+
return {
|
|
906
|
+
position: [px, py, pz] as unknown as Vec3,
|
|
907
|
+
rotation: normalizeQuaternion([rx, ry, rz, rw]),
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function resolveBodyMask(mask: string | readonly string[] | undefined): Set<string> {
|
|
912
|
+
if (mask === undefined) return new Set(HUMANOID_BONE_NAMES)
|
|
913
|
+
if (typeof mask === "string") {
|
|
914
|
+
const bones = SPATIAL_PERFORMANCE_BODY_MASKS[mask as SpatialPerformanceBodyMaskName]
|
|
915
|
+
if (bones === undefined) throw new SpatialSceneError("invalid-data", `Unknown body mask ${mask}.`, "performance.mask")
|
|
916
|
+
return new Set(bones)
|
|
917
|
+
}
|
|
918
|
+
return new Set(mask)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function mappedBoneOrder(mapping: SpatialHumanoidMapping): readonly string[] {
|
|
922
|
+
const mapped = new Set<string>(mapping.bones.map((bone) => bone.canonicalName))
|
|
923
|
+
const result: string[] = []
|
|
924
|
+
const pending = ["hips"]
|
|
925
|
+
while (pending.length > 0) {
|
|
926
|
+
const bone = pending.shift()!
|
|
927
|
+
if (!mapped.has(bone)) continue
|
|
928
|
+
result.push(bone)
|
|
929
|
+
for (const [child, parent] of Object.entries(BONE_PARENTS)) {
|
|
930
|
+
if (parent === bone && mapped.has(child)) pending.push(child)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return Object.freeze(result)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function sampleClipChannel(channel: SpatialPerformanceClip["channels"][number], sourceTimeUs: number): { position: Vec3; rotation: Quaternion } | null {
|
|
937
|
+
const keys = channel.keys
|
|
938
|
+
if (sourceTimeUs <= keys[0]!.timeUs) {
|
|
939
|
+
return { position: keys[0]!.position, rotation: keys[0]!.rotation }
|
|
940
|
+
}
|
|
941
|
+
if (sourceTimeUs >= keys[keys.length - 1]!.timeUs) {
|
|
942
|
+
return { position: keys[keys.length - 1]!.position, rotation: keys[keys.length - 1]!.rotation }
|
|
943
|
+
}
|
|
944
|
+
for (let i = 1; i < keys.length; i += 1) {
|
|
945
|
+
const prev = keys[i - 1]!
|
|
946
|
+
const next = keys[i]!
|
|
947
|
+
if (sourceTimeUs >= prev.timeUs && sourceTimeUs <= next.timeUs) {
|
|
948
|
+
const t = (sourceTimeUs - prev.timeUs) / (next.timeUs - prev.timeUs)
|
|
949
|
+
return {
|
|
950
|
+
position: lerpVec3(prev.position, next.position, t),
|
|
951
|
+
rotation: slerpQuaternion(prev.rotation, next.rotation, t),
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return null
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function collectActiveClips(
|
|
959
|
+
timeUs: number,
|
|
960
|
+
clips: SpatialPerformanceSources["clips"],
|
|
961
|
+
directives: SpatialPerformancePlan["directives"],
|
|
962
|
+
mode?: "override" | "additive",
|
|
963
|
+
): { directive: Extract<SpatialPerformancePlan["directives"][number], { kind: "clip" }>; sourceTimeUs: number }[] {
|
|
964
|
+
const result: { directive: Extract<SpatialPerformancePlan["directives"][number], { kind: "clip" }>; sourceTimeUs: number }[] = []
|
|
965
|
+
for (const directive of directives) {
|
|
966
|
+
if (directive.kind !== "clip") continue
|
|
967
|
+
if (mode !== undefined && directive.mode !== mode) continue
|
|
968
|
+
const clip = clips[directive.clipDigest]
|
|
969
|
+
if (clip === undefined) continue
|
|
970
|
+
if (timeUs < directive.startUs || timeUs >= directive.endUs) continue
|
|
971
|
+
const trimmedDuration = directive.trimEndUs - directive.trimStartUs
|
|
972
|
+
const sourceCycle = trimmedDuration / directive.timeScale
|
|
973
|
+
const loopCount = directive.loop === "once" ? 1 : directive.loop
|
|
974
|
+
const totalSource = sourceCycle * loopCount
|
|
975
|
+
const outputLocal = timeUs - directive.startUs
|
|
976
|
+
if (outputLocal >= totalSource) continue
|
|
977
|
+
const cycleLocal = outputLocal % sourceCycle
|
|
978
|
+
const sourceTimeUs = directive.trimStartUs + cycleLocal * directive.timeScale
|
|
979
|
+
result.push({ directive, sourceTimeUs })
|
|
980
|
+
}
|
|
981
|
+
return result
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function activeCrossfade(
|
|
985
|
+
timeUs: number,
|
|
986
|
+
directives: SpatialPerformancePlan["directives"],
|
|
987
|
+
): { from: string; to: string; alpha: number } | null {
|
|
988
|
+
for (const directive of directives) {
|
|
989
|
+
if (directive.kind !== "crossfade") continue
|
|
990
|
+
if (timeUs >= directive.startUs && timeUs < directive.startUs + directive.durationUs) {
|
|
991
|
+
const alpha = directive.durationUs > 0 ? (timeUs - directive.startUs) / directive.durationUs : 0
|
|
992
|
+
return { from: directive.fromClipId, to: directive.toClipId, alpha }
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return null
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function clipPoseForBone(
|
|
999
|
+
clips: SpatialPerformanceSources["clips"],
|
|
1000
|
+
directive: Extract<SpatialPerformancePlan["directives"][number], { kind: "clip" }>,
|
|
1001
|
+
sourceTimeUs: number,
|
|
1002
|
+
bone: string,
|
|
1003
|
+
boneToRest: ReadonlyMap<string, { position: Vec3; rotation: Quaternion }>,
|
|
1004
|
+
): { position: Vec3; rotation: Quaternion } {
|
|
1005
|
+
const clip = clips[directive.clipDigest]
|
|
1006
|
+
if (clip === undefined) {
|
|
1007
|
+
const rest = boneToRest.get(bone)
|
|
1008
|
+
return rest ?? { position: [0, 0, 0] as unknown as Vec3, rotation: [0, 0, 0, 1] as unknown as Quaternion }
|
|
1009
|
+
}
|
|
1010
|
+
const channel = clip.channels.find((c) => c.bone === bone)
|
|
1011
|
+
if (channel === undefined) {
|
|
1012
|
+
const rest = boneToRest.get(bone)
|
|
1013
|
+
return rest ?? { position: [0, 0, 0] as unknown as Vec3, rotation: [0, 0, 0, 1] as unknown as Quaternion }
|
|
1014
|
+
}
|
|
1015
|
+
const sampled = sampleClipChannel(channel, sourceTimeUs)
|
|
1016
|
+
if (sampled !== null) return sampled
|
|
1017
|
+
return { position: [0, 0, 0] as unknown as Vec3, rotation: [0, 0, 0, 1] as unknown as Quaternion }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function baseBonePose(
|
|
1021
|
+
clips: SpatialPerformanceSources["clips"],
|
|
1022
|
+
directives: SpatialPerformancePlan["directives"],
|
|
1023
|
+
timeUs: number,
|
|
1024
|
+
bone: string,
|
|
1025
|
+
boneToRest: ReadonlyMap<string, { position: Vec3; rotation: Quaternion }>,
|
|
1026
|
+
): { position: Vec3; rotation: Quaternion } {
|
|
1027
|
+
const active = collectActiveClips(timeUs, clips, directives, "override")
|
|
1028
|
+
const maskedActive = active.filter((entry) => resolveBodyMask(entry.directive.mask).has(bone))
|
|
1029
|
+
const crossfade = activeCrossfade(timeUs, directives)
|
|
1030
|
+
if (maskedActive.length === 0) {
|
|
1031
|
+
const rest = boneToRest.get(bone)
|
|
1032
|
+
return rest ?? { position: [0, 0, 0] as unknown as Vec3, rotation: [0, 0, 0, 1] as unknown as Quaternion }
|
|
1033
|
+
}
|
|
1034
|
+
if (crossfade !== null) {
|
|
1035
|
+
const fromDirective = maskedActive.find((a) => a.directive.directiveId === crossfade.from)
|
|
1036
|
+
const toDirective = maskedActive.find((a) => a.directive.directiveId === crossfade.to)
|
|
1037
|
+
if (fromDirective !== undefined && toDirective !== undefined) {
|
|
1038
|
+
const fromPose = clipPoseForBone(clips, fromDirective.directive, fromDirective.sourceTimeUs, bone, boneToRest)
|
|
1039
|
+
const toPose = clipPoseForBone(clips, toDirective.directive, toDirective.sourceTimeUs, bone, boneToRest)
|
|
1040
|
+
return {
|
|
1041
|
+
position: lerpVec3(fromPose.position, toPose.position, crossfade.alpha),
|
|
1042
|
+
rotation: slerpQuaternion(fromPose.rotation, toPose.rotation, crossfade.alpha),
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (maskedActive.length === 1) return clipPoseForBone(clips, maskedActive[0]!.directive, maskedActive[0]!.sourceTimeUs, bone, boneToRest)
|
|
1047
|
+
let pose = clipPoseForBone(clips, maskedActive[0]!.directive, maskedActive[0]!.sourceTimeUs, bone, boneToRest)
|
|
1048
|
+
for (let i = 1; i < maskedActive.length; i += 1) {
|
|
1049
|
+
const other = clipPoseForBone(clips, maskedActive[i]!.directive, maskedActive[i]!.sourceTimeUs, bone, boneToRest)
|
|
1050
|
+
const t = 1 / (i + 1)
|
|
1051
|
+
pose = {
|
|
1052
|
+
position: lerpVec3(pose.position, other.position, t),
|
|
1053
|
+
rotation: slerpQuaternion(pose.rotation, other.rotation, t),
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return pose
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function additiveBoneDelta(
|
|
1060
|
+
clips: SpatialPerformanceSources["clips"],
|
|
1061
|
+
directives: SpatialPerformancePlan["directives"],
|
|
1062
|
+
timeUs: number,
|
|
1063
|
+
bone: string,
|
|
1064
|
+
boneToRest: ReadonlyMap<string, { position: Vec3; rotation: Quaternion }>,
|
|
1065
|
+
): { position: Vec3; rotation: Quaternion } {
|
|
1066
|
+
const active = collectActiveClips(timeUs, clips, directives, "additive")
|
|
1067
|
+
const maskedActive = active.filter((entry) => resolveBodyMask(entry.directive.mask).has(bone))
|
|
1068
|
+
let position: Vec3 = [0, 0, 0] as unknown as Vec3
|
|
1069
|
+
let rotation: Quaternion = [0, 0, 0, 1] as unknown as Quaternion
|
|
1070
|
+
for (const entry of maskedActive) {
|
|
1071
|
+
const pose = clipPoseForBone(clips, entry.directive, entry.sourceTimeUs, bone, boneToRest)
|
|
1072
|
+
position = vec3Add(position, pose.position)
|
|
1073
|
+
rotation = multiplyQuaternion(rotation, pose.rotation)
|
|
1074
|
+
}
|
|
1075
|
+
return { position, rotation }
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function rootWaypointPosition(waypoints: readonly { timeUs: number; position: Vec3; rotation: Quaternion }[], timeUs: number): { position: Vec3; rotation: Quaternion } {
|
|
1079
|
+
if (timeUs <= waypoints[0]!.timeUs) return { position: waypoints[0]!.position, rotation: waypoints[0]!.rotation }
|
|
1080
|
+
const last = waypoints[waypoints.length - 1]!
|
|
1081
|
+
if (timeUs >= last.timeUs) return { position: last.position, rotation: last.rotation }
|
|
1082
|
+
for (let i = 1; i < waypoints.length; i += 1) {
|
|
1083
|
+
const prev = waypoints[i - 1]!
|
|
1084
|
+
const next = waypoints[i]!
|
|
1085
|
+
if (timeUs >= prev.timeUs && timeUs <= next.timeUs) {
|
|
1086
|
+
const t = (timeUs - prev.timeUs) / (next.timeUs - prev.timeUs)
|
|
1087
|
+
return {
|
|
1088
|
+
position: lerpVec3(prev.position, next.position, t),
|
|
1089
|
+
rotation: slerpQuaternion(prev.rotation, next.rotation, t),
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return { position: last.position, rotation: last.rotation }
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function buildBoneToRest(mapping: SpatialHumanoidMapping): ReadonlyMap<string, { position: Vec3; rotation: Quaternion }> {
|
|
1097
|
+
const map = new Map<string, { position: Vec3; rotation: Quaternion }>()
|
|
1098
|
+
for (const bone of mapping.bones) {
|
|
1099
|
+
map.set(bone.canonicalName, { position: bone.restOffset.position, rotation: bone.restOffset.rotation })
|
|
1100
|
+
}
|
|
1101
|
+
return map
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function compileSpatialPerformance(planInput: unknown, sourcesInput: unknown): SpatialPerformanceTake {
|
|
1105
|
+
const plan = parseSpatialPerformancePlan(planInput)
|
|
1106
|
+
const sources = parseSpatialPerformanceSources(sourcesInput)
|
|
1107
|
+
|
|
1108
|
+
if (sources.sceneSha256 !== plan.sceneSha256) {
|
|
1109
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched scene digest.", "performance.scene")
|
|
1110
|
+
}
|
|
1111
|
+
if (sources.rigSha256 !== plan.rigSha256) {
|
|
1112
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched rig digest.", "performance.rig")
|
|
1113
|
+
}
|
|
1114
|
+
const mappingSha = canonicalJsonSha256(sources.mapping)
|
|
1115
|
+
if (mappingSha !== plan.mappingSha256) {
|
|
1116
|
+
throw new SpatialSceneError("invalid-data", "Stale or mismatched mapping digest.", "performance.mapping")
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
validatePerformancePlan(plan, sources)
|
|
1120
|
+
|
|
1121
|
+
for (const directive of plan.directives) {
|
|
1122
|
+
if ("bone" in directive && directive.bone !== undefined) {
|
|
1123
|
+
assertBoneExists(sources.mapping, directive.bone, `performance.directive.${directive.directiveId}`)
|
|
1124
|
+
}
|
|
1125
|
+
if ("endBone" in directive && directive.endBone !== undefined) {
|
|
1126
|
+
assertBoneExists(sources.mapping, directive.endBone, `performance.directive.${directive.directiveId}`)
|
|
1127
|
+
}
|
|
1128
|
+
if (directive.kind === "clip") {
|
|
1129
|
+
const clip = sources.clips[directive.clipDigest]
|
|
1130
|
+
if (clip === undefined) {
|
|
1131
|
+
throw new SpatialSceneError("not-found", `Clip ${directive.clipDigest} is missing.`, `performance.directive.${directive.directiveId}`)
|
|
1132
|
+
}
|
|
1133
|
+
const clipSha = canonicalJsonSha256(clip)
|
|
1134
|
+
if (clipSha !== directive.clipDigest) {
|
|
1135
|
+
throw new SpatialSceneError("invalid-data", `Clip digest mismatch for ${directive.clipDigest}.`, `performance.directive.${directive.directiveId}`)
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if ("propId" in directive && directive.propId !== undefined) {
|
|
1139
|
+
assertPropExists(sources, directive.propId, `performance.directive.${directive.directiveId}`)
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const frameRate: SpatialFrameRate = { numerator: plan.frameRate.numerator, denominator: plan.frameRate.denominator }
|
|
1144
|
+
const sampleCount = spatialFrameCount(plan.durationUs, frameRate)
|
|
1145
|
+
if (sampleCount > SPATIAL_PERFORMANCE_LIMITS.samples) {
|
|
1146
|
+
throw new SpatialSceneError("invalid-data", `Sample count ${sampleCount} exceeds the performance limit.`, "performance.samples")
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const boneToRest = buildBoneToRest(sources.mapping)
|
|
1150
|
+
const order = mappedBoneOrder(sources.mapping)
|
|
1151
|
+
|
|
1152
|
+
const morphDirectives = plan.directives.filter((d) => d.kind === "morph") as Extract<SpatialPerformancePlan["directives"][number], { kind: "morph" }>[]
|
|
1153
|
+
const attachDirectives = plan.directives.filter((d) => d.kind === "attach") as Extract<SpatialPerformancePlan["directives"][number], { kind: "attach" }>[]
|
|
1154
|
+
const releaseDirectives = plan.directives.filter((d) => d.kind === "release") as Extract<SpatialPerformancePlan["directives"][number], { kind: "release" }>[]
|
|
1155
|
+
const springDirectives = plan.directives.filter((d) => d.kind === "spring") as Extract<SpatialPerformancePlan["directives"][number], { kind: "spring" }>[]
|
|
1156
|
+
const lookAtDirectives = plan.directives.filter((d) => d.kind === "look-at") as Extract<SpatialPerformancePlan["directives"][number], { kind: "look-at" }>[]
|
|
1157
|
+
const twoBoneDirectives = plan.directives.filter((d) => d.kind === "two-bone-ik") as Extract<SpatialPerformancePlan["directives"][number], { kind: "two-bone-ik" }>[]
|
|
1158
|
+
const footPlantDirectives = plan.directives.filter((d) => d.kind === "foot-plant") as Extract<SpatialPerformancePlan["directives"][number], { kind: "foot-plant" }>[]
|
|
1159
|
+
const rootTrajectory = plan.directives.find((d) => d.kind === "root-trajectory") as Extract<SpatialPerformancePlan["directives"][number], { kind: "root-trajectory" }> | undefined
|
|
1160
|
+
|
|
1161
|
+
const propIds = new Set<string>([...Object.keys(sources.props), ...attachDirectives.map((d) => d.propId), ...releaseDirectives.map((d) => d.propId)])
|
|
1162
|
+
|
|
1163
|
+
type MutableSample = {
|
|
1164
|
+
timeUs: number
|
|
1165
|
+
boneWorld: Record<string, { position: [number, number, number]; rotation: [number, number, number, number] }>
|
|
1166
|
+
attachments: Record<string, { attached: boolean; parentBone?: string; localOffset?: { position: [number, number, number]; rotation: [number, number, number, number]; scale: [number, number, number] }; worldPosition: [number, number, number] }>
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const samples: MutableSample[] = []
|
|
1170
|
+
const boneKeys: Record<string, { positions: [number, number, number][]; rotations: [number, number, number, number][] }> = {}
|
|
1171
|
+
for (const bone of order) {
|
|
1172
|
+
boneKeys[bone] = { positions: [], rotations: [] }
|
|
1173
|
+
}
|
|
1174
|
+
const morphKeys: Record<number, { timeUs: number; value: number }[]> = {}
|
|
1175
|
+
const attachmentKeys: Record<string, { timeUs: number; attached: boolean; parentBone: string; localOffset: { position: [number, number, number]; rotation: [number, number, number, number]; scale: [number, number, number] } }[]> = {}
|
|
1176
|
+
for (const propId of propIds) {
|
|
1177
|
+
attachmentKeys[propId] = []
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
1181
|
+
const sample = spatialFrameSample(i, plan.durationUs, frameRate)
|
|
1182
|
+
const timeUs = sample.timeUs
|
|
1183
|
+
|
|
1184
|
+
let rootPosition: Vec3 = [0, 0, 0] as unknown as Vec3
|
|
1185
|
+
let rootRotation: Quaternion = [0, 0, 0, 1] as unknown as Quaternion
|
|
1186
|
+
if (rootTrajectory !== undefined) {
|
|
1187
|
+
const waypoint = rootWaypointPosition(rootTrajectory.waypoints, timeUs)
|
|
1188
|
+
rootPosition = waypoint.position
|
|
1189
|
+
rootRotation = waypoint.rotation
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const localPoses = new Map<string, { position: Vec3; rotation: Quaternion }>()
|
|
1193
|
+
const boneRecord: Record<string, { position: [number, number, number]; rotation: [number, number, number, number] }> = {}
|
|
1194
|
+
|
|
1195
|
+
// Pass 1: base local poses from override clips, respecting canonical body masks.
|
|
1196
|
+
for (const bone of order) {
|
|
1197
|
+
localPoses.set(bone, baseBonePose(sources.clips, plan.directives, timeUs, bone, boneToRest))
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Pass 2: forward-kinematics the base pose so lookAt/IK/footPlant see consistent worlds.
|
|
1201
|
+
const baseWorld = new Map<string, { position: Vec3; rotation: Quaternion }>()
|
|
1202
|
+
for (const bone of order) {
|
|
1203
|
+
const parentName = BONE_PARENTS[bone]!
|
|
1204
|
+
const parent = parentName === null ? { position: rootPosition, rotation: rootRotation } : baseWorld.get(parentName)
|
|
1205
|
+
if (parent === undefined) throw new SpatialSceneError("invalid-data", `Missing parent for bone ${bone}.`, "performance.fk")
|
|
1206
|
+
const local = localPoses.get(bone)!
|
|
1207
|
+
baseWorld.set(bone, worldFromLocal(parent.position, parent.rotation, local.position, local.rotation))
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Pass 3: apply deterministic additive clip layers in directive order.
|
|
1211
|
+
for (const bone of order) {
|
|
1212
|
+
const delta = additiveBoneDelta(sources.clips, plan.directives, timeUs, bone, boneToRest)
|
|
1213
|
+
const base = localPoses.get(bone)!
|
|
1214
|
+
localPoses.set(bone, { position: vec3Add(base.position, delta.position), rotation: multiplyQuaternion(base.rotation, delta.rotation) })
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Pass 4: apply deterministic spring offsets.
|
|
1218
|
+
for (const spring of springDirectives) {
|
|
1219
|
+
const offset = springOffset(timeUs, plan.seed + spring.seed, spring.amplitude, spring.frequency)
|
|
1220
|
+
const base = localPoses.get(spring.bone)!
|
|
1221
|
+
localPoses.set(spring.bone, { position: vec3Add(base.position, offset.position), rotation: multiplyQuaternion(base.rotation, offset.rotation) })
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Pass 5: forward-kinematics the working pose for constraint solvers.
|
|
1225
|
+
const workingWorld = new Map<string, { position: Vec3; rotation: Quaternion }>()
|
|
1226
|
+
for (const bone of order) {
|
|
1227
|
+
const parentName = BONE_PARENTS[bone]!
|
|
1228
|
+
const parent = parentName === null ? { position: rootPosition, rotation: rootRotation } : workingWorld.get(parentName)
|
|
1229
|
+
if (parent === undefined) throw new SpatialSceneError("invalid-data", `Missing parent for bone ${bone}.`, "performance.fk")
|
|
1230
|
+
const local = localPoses.get(bone)!
|
|
1231
|
+
workingWorld.set(bone, worldFromLocal(parent.position, parent.rotation, local.position, local.rotation))
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Pass 6: solve lookAt, bounded analytic two-bone IK, and footPlant into local poses.
|
|
1235
|
+
for (const lookAt of lookAtDirectives) {
|
|
1236
|
+
const world = workingWorld.get(lookAt.bone)
|
|
1237
|
+
const parentName = BONE_PARENTS[lookAt.bone]!
|
|
1238
|
+
const parent = parentName === null ? { position: rootPosition, rotation: rootRotation } : workingWorld.get(parentName)
|
|
1239
|
+
if (world === undefined || parent === undefined) continue
|
|
1240
|
+
const parentConj = conjugateQuaternion(parent.rotation)
|
|
1241
|
+
const targetWorldDir = vec3Normalize(vec3Sub(lookAt.target, world.position))
|
|
1242
|
+
const targetParentDir = rotateVectorByQuaternion(parentConj, targetWorldDir)
|
|
1243
|
+
const lookLocal = quaternionFromVectors([0, 0, 1], targetParentDir)
|
|
1244
|
+
const local = localPoses.get(lookAt.bone)!
|
|
1245
|
+
localPoses.set(lookAt.bone, { position: local.position, rotation: lookLocal })
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
for (const ik of twoBoneDirectives) {
|
|
1249
|
+
const endBone = ik.endBone
|
|
1250
|
+
const lowerBone = BONE_PARENTS[endBone]!
|
|
1251
|
+
const upperBone = BONE_PARENTS[lowerBone]!
|
|
1252
|
+
const baseWorld = workingWorld.get(upperBone)
|
|
1253
|
+
const upperLocal = localPoses.get(upperBone)
|
|
1254
|
+
const lowerLocal = localPoses.get(lowerBone)
|
|
1255
|
+
const endLocal = localPoses.get(endBone)
|
|
1256
|
+
if (baseWorld === undefined || upperLocal === undefined || lowerLocal === undefined || endLocal === undefined) continue
|
|
1257
|
+
const solved = solveTwoBoneIk(baseWorld, upperLocal, lowerLocal, endLocal, ik.target, ik.pole, ik.policy)
|
|
1258
|
+
if (solved !== null) {
|
|
1259
|
+
localPoses.set(upperBone, { position: upperLocal.position, rotation: solved.upperRotation })
|
|
1260
|
+
localPoses.set(lowerBone, { position: lowerLocal.position, rotation: solved.lowerRotation })
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
for (const plant of footPlantDirectives) {
|
|
1265
|
+
const world = workingWorld.get(plant.bone)
|
|
1266
|
+
const parentName = BONE_PARENTS[plant.bone]!
|
|
1267
|
+
const parent = parentName === null ? { position: rootPosition, rotation: rootRotation } : workingWorld.get(parentName)
|
|
1268
|
+
if (world === undefined || parent === undefined) continue
|
|
1269
|
+
const parentConj = conjugateQuaternion(parent.rotation)
|
|
1270
|
+
const plantedWorld = { position: [world.position[0], plant.groundY, world.position[2]] as unknown as Vec3, rotation: [0, 0, 0, 1] as unknown as Quaternion }
|
|
1271
|
+
const targetParent = vec3Sub(plantedWorld.position, parent.position)
|
|
1272
|
+
const plantLocalPos = rotateVectorByQuaternion(parentConj, targetParent)
|
|
1273
|
+
const plantLocal = { position: plantLocalPos, rotation: multiplyQuaternion(parentConj, plantedWorld.rotation) }
|
|
1274
|
+
localPoses.set(plant.bone, plantLocal)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Pass 7: final forward-kinematics pass to record world positions.
|
|
1278
|
+
const boneWorld = new Map<string, { position: Vec3; rotation: Quaternion }>()
|
|
1279
|
+
for (const bone of order) {
|
|
1280
|
+
const parentName = BONE_PARENTS[bone]!
|
|
1281
|
+
const parent = parentName === null ? { position: rootPosition, rotation: rootRotation } : boneWorld.get(parentName)
|
|
1282
|
+
if (parent === undefined) throw new SpatialSceneError("invalid-data", `Missing parent for bone ${bone}.`, "performance.fk")
|
|
1283
|
+
const local = localPoses.get(bone)!
|
|
1284
|
+
const world = worldFromLocal(parent.position, parent.rotation, local.position, local.rotation)
|
|
1285
|
+
boneWorld.set(bone, world)
|
|
1286
|
+
boneKeys[bone]!.positions.push([world.position[0], world.position[1], world.position[2]])
|
|
1287
|
+
boneKeys[bone]!.rotations.push([world.rotation[0], world.rotation[1], world.rotation[2], world.rotation[3]])
|
|
1288
|
+
boneRecord[bone] = {
|
|
1289
|
+
position: [world.position[0], world.position[1], world.position[2]],
|
|
1290
|
+
rotation: [world.rotation[0], world.rotation[1], world.rotation[2], world.rotation[3]],
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const morphValues: Record<number, number> = {}
|
|
1295
|
+
for (const directive of morphDirectives) {
|
|
1296
|
+
morphValues[directive.index] = directive.weight
|
|
1297
|
+
}
|
|
1298
|
+
for (const [index, weight] of Object.entries(morphValues)) {
|
|
1299
|
+
const idx = Number(index)
|
|
1300
|
+
const channel = morphKeys[idx] ?? []
|
|
1301
|
+
channel.push({ timeUs, value: weight })
|
|
1302
|
+
morphKeys[idx] = channel
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const attachmentRecord: MutableSample["attachments"] = {}
|
|
1306
|
+
for (const propId of propIds) {
|
|
1307
|
+
let attached = false
|
|
1308
|
+
let parentBone: string | undefined
|
|
1309
|
+
let localOffset: { position: [number, number, number]; rotation: [number, number, number, number]; scale: [number, number, number] } | undefined
|
|
1310
|
+
for (const directive of attachDirectives) {
|
|
1311
|
+
if (directive.propId === propId && timeUs >= directive.startUs && timeUs < directive.endUs) {
|
|
1312
|
+
attached = true
|
|
1313
|
+
parentBone = directive.bone
|
|
1314
|
+
localOffset = {
|
|
1315
|
+
position: [directive.localOffset.position[0], directive.localOffset.position[1], directive.localOffset.position[2]],
|
|
1316
|
+
rotation: [directive.localOffset.rotation[0], directive.localOffset.rotation[1], directive.localOffset.rotation[2], directive.localOffset.rotation[3]],
|
|
1317
|
+
scale: [directive.localOffset.scale[0], directive.localOffset.scale[1], directive.localOffset.scale[2]],
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
for (const directive of releaseDirectives) {
|
|
1322
|
+
if (directive.propId === propId && timeUs >= directive.startUs) {
|
|
1323
|
+
attached = false
|
|
1324
|
+
parentBone = undefined
|
|
1325
|
+
localOffset = undefined
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
let worldPosition: [number, number, number] = [0, 0, 0]
|
|
1329
|
+
if (attached && parentBone !== undefined && localOffset !== undefined) {
|
|
1330
|
+
const parentWorld = boneWorld.get(parentBone)
|
|
1331
|
+
if (parentWorld !== undefined) {
|
|
1332
|
+
const offsetMatrix = multiplyTransforms(
|
|
1333
|
+
composeTransform({ position: parentWorld.position, rotation: parentWorld.rotation, scale: [1, 1, 1] as unknown as Vec3 }),
|
|
1334
|
+
composeTransform({ position: localOffset.position as unknown as Vec3, rotation: localOffset.rotation as unknown as Quaternion, scale: localOffset.scale as unknown as Vec3 }),
|
|
1335
|
+
)
|
|
1336
|
+
const offsetPose = poseFromMatrix(offsetMatrix)
|
|
1337
|
+
worldPosition = [offsetPose.position[0], offsetPose.position[1], offsetPose.position[2]]
|
|
1338
|
+
}
|
|
1339
|
+
} else {
|
|
1340
|
+
const prop = sources.props[propId]
|
|
1341
|
+
if (prop !== undefined) {
|
|
1342
|
+
worldPosition = [prop.localOffset.position[0], prop.localOffset.position[1], prop.localOffset.position[2]]
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
attachmentRecord[propId] = { attached, worldPosition }
|
|
1346
|
+
if (attached && parentBone !== undefined && localOffset !== undefined) {
|
|
1347
|
+
attachmentRecord[propId]!.parentBone = parentBone
|
|
1348
|
+
attachmentRecord[propId]!.localOffset = localOffset
|
|
1349
|
+
}
|
|
1350
|
+
attachmentKeys[propId]!.push({ timeUs, attached, parentBone: parentBone ?? "hips", localOffset: localOffset ?? { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
samples.push({ timeUs, boneWorld: boneRecord, attachments: attachmentRecord })
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const channels: { kind: string }[] = []
|
|
1357
|
+
for (const bone of order) {
|
|
1358
|
+
const data = boneKeys[bone]!
|
|
1359
|
+
const keys: { timeUs: number; position: [number, number, number]; rotation: [number, number, number, number] }[] = []
|
|
1360
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
1361
|
+
const sample = spatialFrameSample(i, plan.durationUs, frameRate)
|
|
1362
|
+
keys.push({ timeUs: sample.timeUs, position: data.positions[i]!, rotation: data.rotations[i]! })
|
|
1363
|
+
}
|
|
1364
|
+
channels.push({ kind: "bone-pose", bone, keys } as unknown as { kind: string })
|
|
1365
|
+
}
|
|
1366
|
+
for (const [index, values] of Object.entries(morphKeys)) {
|
|
1367
|
+
const idx = Number(index)
|
|
1368
|
+
channels.push({ kind: "morph", index: idx, keys: values } as unknown as { kind: string })
|
|
1369
|
+
}
|
|
1370
|
+
for (const [propId, data] of Object.entries(attachmentKeys)) {
|
|
1371
|
+
channels.push({ kind: "attachment", propId, keys: data } as unknown as { kind: string })
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const planSha = canonicalJsonSha256(plan)
|
|
1375
|
+
const directivesSha = canonicalJsonSha256(plan.directives)
|
|
1376
|
+
const compilerSha = canonicalJsonSha256(SPATIAL_PERFORMANCE_COMPILER_ID)
|
|
1377
|
+
const outputSha = canonicalJsonSha256({ planSha256: planSha, durationUs: plan.durationUs, samples, channels })
|
|
1378
|
+
|
|
1379
|
+
const receipt = {
|
|
1380
|
+
kind: "slopcamera.spatial-performance-receipt",
|
|
1381
|
+
schemaVersion: 1,
|
|
1382
|
+
compiler: SPATIAL_PERFORMANCE_COMPILER_ID,
|
|
1383
|
+
compilerSha256: compilerSha,
|
|
1384
|
+
sceneSha256: plan.sceneSha256,
|
|
1385
|
+
rigSha256: plan.rigSha256,
|
|
1386
|
+
mappingSha256: plan.mappingSha256,
|
|
1387
|
+
directivesSha256: directivesSha,
|
|
1388
|
+
seed: plan.seed,
|
|
1389
|
+
outputSha256: outputSha,
|
|
1390
|
+
durationUs: plan.durationUs,
|
|
1391
|
+
sampleCount,
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const take = {
|
|
1395
|
+
kind: "slopcamera.spatial-performance-take",
|
|
1396
|
+
schemaVersion: 1,
|
|
1397
|
+
planSha256: planSha,
|
|
1398
|
+
durationUs: plan.durationUs,
|
|
1399
|
+
samples,
|
|
1400
|
+
channels,
|
|
1401
|
+
receipt,
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
return deepFreezeJson(take) as unknown as SpatialPerformanceTake
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
export function auditSpatialPerformance(take: SpatialPerformanceTake, optionsInput: unknown): SpatialPerformanceAuditReport {
|
|
1408
|
+
const options = parseSpatialPerformanceAuditOptions(optionsInput)
|
|
1409
|
+
const takeSha = canonicalJsonSha256(take)
|
|
1410
|
+
const findings: SpatialPerformanceFinding[] = []
|
|
1411
|
+
|
|
1412
|
+
const getHeadForward = (headPose: { position: Vec3; rotation: Quaternion }): Vec3 => {
|
|
1413
|
+
return rotateVectorByQuaternion(headPose.rotation, [0, 0, 1])
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const typedBoneWorld = (sample: (typeof take.samples)[number]) => {
|
|
1417
|
+
return sample.boneWorld as Record<string, { position: Vec3; rotation: Quaternion }>
|
|
1418
|
+
}
|
|
1419
|
+
const typedAttachments = (sample: (typeof take.samples)[number]) => {
|
|
1420
|
+
return sample.attachments as Record<string, { attached: boolean; parentBone?: string; worldPosition: Vec3 }>
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
for (let i = 0; i < take.samples.length; i += 1) {
|
|
1424
|
+
const sample = take.samples[i]!
|
|
1425
|
+
const timeUs = sample.timeUs
|
|
1426
|
+
const bones = typedBoneWorld(sample)
|
|
1427
|
+
|
|
1428
|
+
for (const [bone, pose] of Object.entries(bones)) {
|
|
1429
|
+
if (options.groundY !== undefined && (bone === "leftFoot" || bone === "rightFoot")) {
|
|
1430
|
+
if (pose.position[1] < options.groundY - 0.01) {
|
|
1431
|
+
findings.push({
|
|
1432
|
+
kind: "ground-penetration",
|
|
1433
|
+
severity: "warning",
|
|
1434
|
+
entityId: options.characterId,
|
|
1435
|
+
bone: bone as unknown as z.infer<typeof humanoidBoneName>,
|
|
1436
|
+
timeUs,
|
|
1437
|
+
detail: `Foot ${bone} penetrates ground at y=${pose.position[1].toFixed(4)} (groundY=${options.groundY}).`,
|
|
1438
|
+
})
|
|
1439
|
+
}
|
|
1440
|
+
if (i > 0) {
|
|
1441
|
+
const prev = take.samples[i - 1]!
|
|
1442
|
+
const prevBones = typedBoneWorld(prev)
|
|
1443
|
+
const prevPose = prevBones[bone]
|
|
1444
|
+
if (prevPose !== undefined && Math.abs(prevPose.position[1] - options.groundY) < 0.02 && Math.abs(pose.position[1] - options.groundY) < 0.02) {
|
|
1445
|
+
const dx = pose.position[0] - prevPose.position[0]
|
|
1446
|
+
const dz = pose.position[2] - prevPose.position[2]
|
|
1447
|
+
const horizontal = Math.hypot(dx, dz)
|
|
1448
|
+
if (horizontal > 0.01) {
|
|
1449
|
+
findings.push({
|
|
1450
|
+
kind: "foot-slide",
|
|
1451
|
+
severity: "warning",
|
|
1452
|
+
entityId: options.characterId,
|
|
1453
|
+
bone: bone as unknown as z.infer<typeof humanoidBoneName>,
|
|
1454
|
+
timeUs,
|
|
1455
|
+
detail: `Planted foot ${bone} slid ${horizontal.toFixed(4)} meters.`,
|
|
1456
|
+
})
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (options.gazeTarget !== undefined) {
|
|
1464
|
+
const head = bones["head"]
|
|
1465
|
+
if (head !== undefined) {
|
|
1466
|
+
const forward = getHeadForward(head)
|
|
1467
|
+
const toTarget = vec3Normalize(vec3Sub(options.gazeTarget, head.position))
|
|
1468
|
+
const dot = Math.max(-1, Math.min(1, vec3Dot(forward, toTarget)))
|
|
1469
|
+
const angle = Math.acos(dot)
|
|
1470
|
+
if (angle > 0.1) {
|
|
1471
|
+
findings.push({
|
|
1472
|
+
kind: "gaze-error",
|
|
1473
|
+
severity: "info",
|
|
1474
|
+
entityId: options.characterId,
|
|
1475
|
+
bone: "head",
|
|
1476
|
+
timeUs,
|
|
1477
|
+
detail: `Head is ${angle.toFixed(4)} radians off gaze target.`,
|
|
1478
|
+
})
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const attachments = typedAttachments(sample)
|
|
1484
|
+
for (const [propId, attachment] of Object.entries(attachments)) {
|
|
1485
|
+
if (attachment.attached && attachment.parentBone !== undefined) {
|
|
1486
|
+
const hand = bones[attachment.parentBone]
|
|
1487
|
+
if (hand !== undefined) {
|
|
1488
|
+
const drift = vec3Length(vec3Sub(attachment.worldPosition, hand.position))
|
|
1489
|
+
if (drift > 0.01) {
|
|
1490
|
+
findings.push({
|
|
1491
|
+
kind: "attachment-drift",
|
|
1492
|
+
severity: "warning",
|
|
1493
|
+
entityId: options.characterId,
|
|
1494
|
+
bone: attachment.parentBone as unknown as z.infer<typeof humanoidBoneName>,
|
|
1495
|
+
timeUs,
|
|
1496
|
+
detail: `Prop ${propId} drifts ${drift.toFixed(4)} meters from ${attachment.parentBone}.`,
|
|
1497
|
+
})
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const upperLegs = ["leftUpperLeg", "rightUpperLeg"] as const
|
|
1504
|
+
const lowerLegs = ["leftLowerLeg", "rightLowerLeg"] as const
|
|
1505
|
+
const feet = ["leftFoot", "rightFoot"] as const
|
|
1506
|
+
for (let side = 0; side < 2; side += 1) {
|
|
1507
|
+
const upper = bones[upperLegs[side]!]
|
|
1508
|
+
const lower = bones[lowerLegs[side]!]
|
|
1509
|
+
const foot = bones[feet[side]!]
|
|
1510
|
+
if (upper !== undefined && lower !== undefined && foot !== undefined) {
|
|
1511
|
+
const thigh = vec3Normalize(vec3Sub(lower.position, upper.position))
|
|
1512
|
+
const calf = vec3Normalize(vec3Sub(foot.position, lower.position))
|
|
1513
|
+
const dot = vec3Dot(thigh, calf)
|
|
1514
|
+
if (dot > 0.95) {
|
|
1515
|
+
findings.push({
|
|
1516
|
+
kind: "joint-limit",
|
|
1517
|
+
severity: "warning",
|
|
1518
|
+
entityId: options.characterId,
|
|
1519
|
+
bone: lowerLegs[side]! as unknown as z.infer<typeof humanoidBoneName>,
|
|
1520
|
+
timeUs,
|
|
1521
|
+
detail: `Knee ${lowerLegs[side]!} overextended (dot=${dot.toFixed(4)}).`,
|
|
1522
|
+
})
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const upperArm = bones[side === 0 ? "leftUpperArm" : "rightUpperArm"]
|
|
1526
|
+
const lowerArm = bones[side === 0 ? "leftLowerArm" : "rightLowerArm"]
|
|
1527
|
+
const hand = bones[side === 0 ? "leftHand" : "rightHand"]
|
|
1528
|
+
if (upperArm !== undefined && lowerArm !== undefined && hand !== undefined) {
|
|
1529
|
+
const upper = vec3Normalize(vec3Sub(lowerArm.position, upperArm.position))
|
|
1530
|
+
const lower = vec3Normalize(vec3Sub(hand.position, lowerArm.position))
|
|
1531
|
+
const dot = vec3Dot(upper, lower)
|
|
1532
|
+
if (dot > 0.95) {
|
|
1533
|
+
findings.push({
|
|
1534
|
+
kind: "joint-limit",
|
|
1535
|
+
severity: "warning",
|
|
1536
|
+
entityId: options.characterId,
|
|
1537
|
+
bone: (side === 0 ? "leftLowerArm" : "rightLowerArm") as unknown as z.infer<typeof humanoidBoneName>,
|
|
1538
|
+
timeUs,
|
|
1539
|
+
detail: `Elbow ${side === 0 ? "leftLowerArm" : "rightLowerArm"} overextended (dot=${dot.toFixed(4)}).`,
|
|
1540
|
+
})
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (i > 0 && options.clipBoundaries !== undefined && options.clipBoundaries.includes(timeUs)) {
|
|
1546
|
+
const prev = take.samples[i - 1]!
|
|
1547
|
+
const prevBones = typedBoneWorld(prev)
|
|
1548
|
+
let jump = 0
|
|
1549
|
+
for (const [bone, pose] of Object.entries(bones)) {
|
|
1550
|
+
const prevPose = prevBones[bone]
|
|
1551
|
+
if (prevPose === undefined) continue
|
|
1552
|
+
const p = vec3Length(vec3Sub(pose.position, prevPose.position))
|
|
1553
|
+
if (p > jump) jump = p
|
|
1554
|
+
}
|
|
1555
|
+
if (jump > 0.1) {
|
|
1556
|
+
findings.push({
|
|
1557
|
+
kind: "clip-discontinuity",
|
|
1558
|
+
severity: "warning",
|
|
1559
|
+
entityId: options.characterId,
|
|
1560
|
+
timeUs,
|
|
1561
|
+
detail: `Transform jump of ${jump.toFixed(4)} meters across clip boundary.`,
|
|
1562
|
+
})
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const hips = bones["hips"]
|
|
1567
|
+
if (hips !== undefined) {
|
|
1568
|
+
if (options.cameras !== undefined) {
|
|
1569
|
+
for (const camera of options.cameras) {
|
|
1570
|
+
const distance = vec3Length(vec3Sub(camera.position, hips.position))
|
|
1571
|
+
if (distance < camera.near) {
|
|
1572
|
+
findings.push({
|
|
1573
|
+
kind: "character-camera-collision",
|
|
1574
|
+
severity: "warning",
|
|
1575
|
+
entityId: options.characterId,
|
|
1576
|
+
timeUs,
|
|
1577
|
+
detail: `Character is ${distance.toFixed(4)} meters inside camera ${camera.cameraId} near plane.`,
|
|
1578
|
+
})
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (options.otherCharacters !== undefined) {
|
|
1583
|
+
for (const other of options.otherCharacters) {
|
|
1584
|
+
const distance = vec3Length(vec3Sub(other.position, hips.position))
|
|
1585
|
+
if (distance < other.radius) {
|
|
1586
|
+
findings.push({
|
|
1587
|
+
kind: "character-character-collision",
|
|
1588
|
+
severity: "warning",
|
|
1589
|
+
entityId: options.characterId,
|
|
1590
|
+
timeUs,
|
|
1591
|
+
detail: `Character collides with ${other.characterId} (distance=${distance.toFixed(4)}).`,
|
|
1592
|
+
})
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const sorted = sortSpatialBy(findings, (f) => {
|
|
1600
|
+
const key = `${String(f.timeUs).padStart(16, "0")}|${f.bone ?? ""}|${f.kind}|${f.severity}|${f.entityId ?? ""}|${f.detail}`
|
|
1601
|
+
return key
|
|
1602
|
+
})
|
|
1603
|
+
const omitted = Math.max(0, sorted.length - SPATIAL_PERFORMANCE_LIMITS.findings)
|
|
1604
|
+
const trimmed = sorted.slice(0, SPATIAL_PERFORMANCE_LIMITS.findings)
|
|
1605
|
+
|
|
1606
|
+
return deepFreezeJson({
|
|
1607
|
+
kind: "slopcamera.spatial-performance-audit",
|
|
1608
|
+
schemaVersion: 1,
|
|
1609
|
+
takeSha256: takeSha,
|
|
1610
|
+
findings: trimmed,
|
|
1611
|
+
omittedFindings: omitted,
|
|
1612
|
+
}) as unknown as SpatialPerformanceAuditReport
|
|
1613
|
+
}
|