@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -0,0 +1,663 @@
1
+ /**
2
+ * The procedural humanoid BODY: entirely procedural three.js geometry
3
+ * (capsules + one pelvis box via `CapsuleGeometry`/`BoxGeometry`, merged with
4
+ * `BufferGeometryUtils.mergeGeometries` into ONE `SkinnedMesh`). No imported
5
+ * mesh anywhere. Promoted from the accepted Phase-1 PoC
6
+ * (`packages/engine/e2e/humanoid-skin-proof/procedural-body.ts`) and
7
+ * parameterized by body dimensions — with all-default dims the output is
8
+ * bit-identical to the PoC body.
9
+ *
10
+ * Skin binding is **analytic per-segment with smooth 2-bone joint blending**:
11
+ * every body part is generated along one bone segment (proximal joint →
12
+ * distal joint, both known a priori from the rest pose), so each vertex's
13
+ * normalized position `t` along the bone axis is exact — no nearest-bone
14
+ * search, no heuristic falloff radius. Vertices in the interior of a segment
15
+ * bind 100% to that segment's bone; vertices inside a blend zone at either
16
+ * end split weight smoothstep-wise with the adjacent bone, reaching 50/50 at
17
+ * (and past) the joint itself. This is the standard cure for the two classic
18
+ * rigid-binding artifacts: gaps/interpenetration at elbows/knees (fixed by
19
+ * the overshoot + 50/50 blend across the joint) and hard shading creases
20
+ * (fixed by the smoothstep ramp). Because the blend zone is derived from the
21
+ * ACTUAL joint positions of the (possibly rescaled) skeleton, weights stay
22
+ * correct as body proportions change. Two influences per vertex is enough
23
+ * for a capsule-limb body; the vertex format still uses the standard 4-wide
24
+ * skinIndex/skinWeight attributes, zero-padded, exactly as GLTF skins do.
25
+ */
26
+
27
+ import * as THREE from 'three';
28
+ import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
29
+ import type { BuiltSkeleton } from './skeleton';
30
+
31
+ /** Which dimension multiplier a part's radius follows. `foot` parts scale by
32
+ * heightFactor ONLY, so ground clearance (set by the skeleton re-ground
33
+ * step, which knows nothing about radii) is never violated by a thick body. */
34
+ type RadiusGroup = 'torso' | 'limb' | 'head' | 'foot';
35
+
36
+ /** One procedural body part, generated along the bone segment `from`→`to`. */
37
+ interface SegmentSpec {
38
+ /** The bone this part binds to (owns the segment; usually === `from`). */
39
+ bone: string;
40
+ /** Proximal joint (t = 0). */
41
+ from: string;
42
+ /** Distal joint (t = 1) — an existing child bone name. */
43
+ to: string;
44
+ /** Capsule radius, metres at reference scale (multiplied by the part's
45
+ * radius-group dim before use). */
46
+ radius: number;
47
+ /** Radius-group multiplier this part follows. */
48
+ group: RadiusGroup;
49
+ /** Blend partner near t=0 (typically the parent bone). Omit = rigid end. */
50
+ proximalBlend?: string;
51
+ /** Blend partner near t=1 (typically the `to` bone). Omit = rigid end. */
52
+ distalBlend?: string;
53
+ /** Extend the capsule past the proximal / distal joint, metres at reference
54
+ * scale — overlaps the neighbouring part so blended joints never show gaps. */
55
+ overshootFrom?: number;
56
+ overshootTo?: number;
57
+ /** Per-part vertex color (visualizes the segment map + blend zones). */
58
+ color: number;
59
+ }
60
+
61
+ const C = {
62
+ torso: 0x4f8fba,
63
+ pelvis: 0x3a6ea5,
64
+ head: 0xd9a066,
65
+ neck: 0xc98b5e,
66
+ armL: 0xe07b39,
67
+ foreL: 0xf0a860,
68
+ armR: 0x4fae6a,
69
+ foreR: 0x7fd196,
70
+ hand: 0xd9c8b4,
71
+ thigh: 0x8a5fb0,
72
+ shin: 0xb08ad0,
73
+ foot: 0x555f6e,
74
+ toe: 0x6d7885,
75
+ } as const;
76
+
77
+ /** The full body map. Radii/overshoots are metres at reference scale, tuned
78
+ * to the reference rig's T-pose proportions (~1.83 m tall). */
79
+ const SEGMENTS: readonly SegmentSpec[] = [
80
+ // Torso column — three short, fat, overlapping capsules following the
81
+ // spine chain, each blending into its neighbours.
82
+ {
83
+ bone: 'mixamorigSpine',
84
+ from: 'mixamorigSpine',
85
+ to: 'mixamorigSpine1',
86
+ radius: 0.135,
87
+ group: 'torso',
88
+ proximalBlend: 'mixamorigHips',
89
+ distalBlend: 'mixamorigSpine1',
90
+ overshootFrom: 0.03,
91
+ overshootTo: 0.02,
92
+ color: C.torso,
93
+ },
94
+ {
95
+ bone: 'mixamorigSpine1',
96
+ from: 'mixamorigSpine1',
97
+ to: 'mixamorigSpine2',
98
+ radius: 0.145,
99
+ group: 'torso',
100
+ proximalBlend: 'mixamorigSpine',
101
+ distalBlend: 'mixamorigSpine2',
102
+ overshootFrom: 0.02,
103
+ overshootTo: 0.02,
104
+ color: C.torso,
105
+ },
106
+ {
107
+ bone: 'mixamorigSpine2',
108
+ from: 'mixamorigSpine2',
109
+ to: 'mixamorigNeck',
110
+ radius: 0.14,
111
+ group: 'torso',
112
+ proximalBlend: 'mixamorigSpine1',
113
+ distalBlend: 'mixamorigNeck',
114
+ overshootFrom: 0.02,
115
+ overshootTo: 0.01,
116
+ color: C.torso,
117
+ },
118
+ // Neck + head.
119
+ {
120
+ bone: 'mixamorigNeck',
121
+ from: 'mixamorigNeck',
122
+ to: 'mixamorigHead',
123
+ radius: 0.048,
124
+ group: 'head',
125
+ proximalBlend: 'mixamorigSpine2',
126
+ distalBlend: 'mixamorigHead',
127
+ overshootFrom: 0.02,
128
+ overshootTo: 0.02,
129
+ color: C.neck,
130
+ },
131
+ {
132
+ bone: 'mixamorigHead',
133
+ from: 'mixamorigHead',
134
+ to: 'mixamorigHeadTop_End',
135
+ radius: 0.105,
136
+ group: 'head',
137
+ proximalBlend: 'mixamorigNeck',
138
+ color: C.head,
139
+ },
140
+ // Left arm chain (shoulder → upper arm → forearm → hand).
141
+ {
142
+ bone: 'mixamorigLeftShoulder',
143
+ from: 'mixamorigLeftShoulder',
144
+ to: 'mixamorigLeftArm',
145
+ radius: 0.062,
146
+ group: 'torso',
147
+ proximalBlend: 'mixamorigSpine2',
148
+ distalBlend: 'mixamorigLeftArm',
149
+ overshootFrom: 0.02,
150
+ color: C.torso,
151
+ },
152
+ {
153
+ bone: 'mixamorigLeftArm',
154
+ from: 'mixamorigLeftArm',
155
+ to: 'mixamorigLeftForeArm',
156
+ radius: 0.052,
157
+ group: 'limb',
158
+ proximalBlend: 'mixamorigLeftShoulder',
159
+ distalBlend: 'mixamorigLeftForeArm',
160
+ overshootFrom: 0.015,
161
+ overshootTo: 0.01,
162
+ color: C.armL,
163
+ },
164
+ {
165
+ bone: 'mixamorigLeftForeArm',
166
+ from: 'mixamorigLeftForeArm',
167
+ to: 'mixamorigLeftHand',
168
+ radius: 0.044,
169
+ group: 'limb',
170
+ proximalBlend: 'mixamorigLeftArm',
171
+ distalBlend: 'mixamorigLeftHand',
172
+ overshootFrom: 0.01,
173
+ overshootTo: 0.01,
174
+ color: C.foreL,
175
+ },
176
+ {
177
+ bone: 'mixamorigLeftHand',
178
+ from: 'mixamorigLeftHand',
179
+ to: 'mixamorigLeftHandMiddle1',
180
+ radius: 0.04,
181
+ group: 'limb',
182
+ proximalBlend: 'mixamorigLeftForeArm',
183
+ overshootTo: 0.05,
184
+ color: C.hand,
185
+ },
186
+ // Right arm chain.
187
+ {
188
+ bone: 'mixamorigRightShoulder',
189
+ from: 'mixamorigRightShoulder',
190
+ to: 'mixamorigRightArm',
191
+ radius: 0.062,
192
+ group: 'torso',
193
+ proximalBlend: 'mixamorigSpine2',
194
+ distalBlend: 'mixamorigRightArm',
195
+ overshootFrom: 0.02,
196
+ color: C.torso,
197
+ },
198
+ {
199
+ bone: 'mixamorigRightArm',
200
+ from: 'mixamorigRightArm',
201
+ to: 'mixamorigRightForeArm',
202
+ radius: 0.052,
203
+ group: 'limb',
204
+ proximalBlend: 'mixamorigRightShoulder',
205
+ distalBlend: 'mixamorigRightForeArm',
206
+ overshootFrom: 0.015,
207
+ overshootTo: 0.01,
208
+ color: C.armR,
209
+ },
210
+ {
211
+ bone: 'mixamorigRightForeArm',
212
+ from: 'mixamorigRightForeArm',
213
+ to: 'mixamorigRightHand',
214
+ radius: 0.044,
215
+ group: 'limb',
216
+ proximalBlend: 'mixamorigRightArm',
217
+ distalBlend: 'mixamorigRightHand',
218
+ overshootFrom: 0.01,
219
+ overshootTo: 0.01,
220
+ color: C.foreR,
221
+ },
222
+ {
223
+ bone: 'mixamorigRightHand',
224
+ from: 'mixamorigRightHand',
225
+ to: 'mixamorigRightHandMiddle1',
226
+ radius: 0.04,
227
+ group: 'limb',
228
+ proximalBlend: 'mixamorigRightForeArm',
229
+ overshootTo: 0.05,
230
+ color: C.hand,
231
+ },
232
+ // Left leg chain (thigh → shin → foot → toes).
233
+ {
234
+ bone: 'mixamorigLeftUpLeg',
235
+ from: 'mixamorigLeftUpLeg',
236
+ to: 'mixamorigLeftLeg',
237
+ radius: 0.078,
238
+ group: 'limb',
239
+ proximalBlend: 'mixamorigHips',
240
+ distalBlend: 'mixamorigLeftLeg',
241
+ overshootFrom: 0.035,
242
+ overshootTo: 0.01,
243
+ color: C.thigh,
244
+ },
245
+ {
246
+ bone: 'mixamorigLeftLeg',
247
+ from: 'mixamorigLeftLeg',
248
+ to: 'mixamorigLeftFoot',
249
+ radius: 0.058,
250
+ group: 'limb',
251
+ proximalBlend: 'mixamorigLeftUpLeg',
252
+ distalBlend: 'mixamorigLeftFoot',
253
+ overshootFrom: 0.01,
254
+ overshootTo: 0.01,
255
+ color: C.shin,
256
+ },
257
+ {
258
+ bone: 'mixamorigLeftFoot',
259
+ from: 'mixamorigLeftFoot',
260
+ to: 'mixamorigLeftToeBase',
261
+ radius: 0.05,
262
+ group: 'foot',
263
+ proximalBlend: 'mixamorigLeftLeg',
264
+ distalBlend: 'mixamorigLeftToeBase',
265
+ overshootFrom: 0.01,
266
+ color: C.foot,
267
+ },
268
+ {
269
+ bone: 'mixamorigLeftToeBase',
270
+ from: 'mixamorigLeftToeBase',
271
+ to: 'mixamorigLeftToe_End',
272
+ radius: 0.042,
273
+ group: 'foot',
274
+ proximalBlend: 'mixamorigLeftFoot',
275
+ color: C.toe,
276
+ },
277
+ // Right leg chain.
278
+ {
279
+ bone: 'mixamorigRightUpLeg',
280
+ from: 'mixamorigRightUpLeg',
281
+ to: 'mixamorigRightLeg',
282
+ radius: 0.078,
283
+ group: 'limb',
284
+ proximalBlend: 'mixamorigHips',
285
+ distalBlend: 'mixamorigRightLeg',
286
+ overshootFrom: 0.035,
287
+ overshootTo: 0.01,
288
+ color: C.thigh,
289
+ },
290
+ {
291
+ bone: 'mixamorigRightLeg',
292
+ from: 'mixamorigRightLeg',
293
+ to: 'mixamorigRightFoot',
294
+ radius: 0.058,
295
+ group: 'limb',
296
+ proximalBlend: 'mixamorigRightUpLeg',
297
+ distalBlend: 'mixamorigRightFoot',
298
+ overshootFrom: 0.01,
299
+ overshootTo: 0.01,
300
+ color: C.shin,
301
+ },
302
+ {
303
+ bone: 'mixamorigRightFoot',
304
+ from: 'mixamorigRightFoot',
305
+ to: 'mixamorigRightToeBase',
306
+ radius: 0.05,
307
+ group: 'foot',
308
+ proximalBlend: 'mixamorigRightLeg',
309
+ distalBlend: 'mixamorigRightToeBase',
310
+ overshootFrom: 0.01,
311
+ color: C.foot,
312
+ },
313
+ {
314
+ bone: 'mixamorigRightToeBase',
315
+ from: 'mixamorigRightToeBase',
316
+ to: 'mixamorigRightToe_End',
317
+ radius: 0.042,
318
+ group: 'foot',
319
+ proximalBlend: 'mixamorigRightFoot',
320
+ color: C.toe,
321
+ },
322
+ ];
323
+
324
+ /** The head capsule's reference radius — `generate.ts` feeds this (× headSize)
325
+ * to the skeleton height normalization as the crown padding. */
326
+ export const HEAD_CAPSULE_RADIUS =
327
+ SEGMENTS.find((s) => s.bone === 'mixamorigHead')?.radius ?? 0.105;
328
+
329
+ /** Body dimension multipliers, resolved by `generate.ts` from the params. */
330
+ export interface HumanoidBodyDims {
331
+ /** The skeleton's uniform height factor — every base metre scales by this. */
332
+ heightFactor: number;
333
+ limbThickness: number;
334
+ torsoGirth: number;
335
+ headSize: number;
336
+ hipWidth: number;
337
+ }
338
+
339
+ export const smoothstep = (x: number): number => {
340
+ const t = Math.min(1, Math.max(0, x));
341
+ return t * t * (3 - 2 * t);
342
+ };
343
+
344
+ /** Blend-zone length as a fraction of a segment (≈10 cm at reference scale,
345
+ * clamped to 20–50% of the segment) — shared verbatim by the body generator
346
+ * and `skinToHumanoid` so clothing gets the SAME joint blend as the body. */
347
+ export const blendZone = (len: number, heightFactor: number): number =>
348
+ Math.min(0.5, Math.max(0.2, (0.1 * heightFactor) / len));
349
+
350
+ /** World rest position of a named bone (scene must have updated matrices). */
351
+ function jointPos(rig: BuiltSkeleton, name: string): THREE.Vector3 {
352
+ const bone = rig.byName.get(name);
353
+ if (!bone) throw new Error(`humanoid: unknown bone '${name}'`);
354
+ return new THREE.Vector3().setFromMatrixPosition(bone.matrixWorld);
355
+ }
356
+
357
+ /** name → skinIndex row, cached per rig (reviewer note: the former O(n)
358
+ * findIndex per call was fine for 22 parts, not for per-prop/per-vertex use). */
359
+ const boneIndexCache = new WeakMap<BuiltSkeleton, Map<string, number>>();
360
+
361
+ function boneIndex(rig: BuiltSkeleton, name: string): number {
362
+ let map = boneIndexCache.get(rig);
363
+ if (!map) {
364
+ map = new Map(rig.bones.map((b, i) => [b.name, i]));
365
+ boneIndexCache.set(rig, map);
366
+ }
367
+ const i = map.get(name);
368
+ if (i === undefined) throw new Error(`humanoid: unknown bone '${name}'`);
369
+ return i;
370
+ }
371
+
372
+ /** Fill vertex colors with a flat per-part color. */
373
+ function paint(geo: THREE.BufferGeometry, color: number): void {
374
+ const n = geo.attributes['position']!.count;
375
+ const c = new THREE.Color(color);
376
+ const arr = new Float32Array(n * 3);
377
+ for (let i = 0; i < n; i++) arr.set([c.r, c.g, c.b], i * 3);
378
+ geo.setAttribute('color', new THREE.BufferAttribute(arr, 3));
379
+ }
380
+
381
+ /**
382
+ * Compute skinIndex/skinWeight for a segment geometry: per-vertex projection
383
+ * onto the joint axis → smoothstep 2-bone blend inside the end zones. The
384
+ * blend zone length adapts to the segment (≈10 cm at reference scale,
385
+ * clamped to 20–50% of the segment) so short torso links still blend and
386
+ * long thighs stay mostly rigid.
387
+ */
388
+ function skinSegment(
389
+ geo: THREE.BufferGeometry,
390
+ rig: BuiltSkeleton,
391
+ spec: SegmentSpec,
392
+ p0: THREE.Vector3,
393
+ p1: THREE.Vector3,
394
+ heightFactor: number,
395
+ ): void {
396
+ const primary = boneIndex(rig, spec.bone);
397
+ const proximal = spec.proximalBlend ? boneIndex(rig, spec.proximalBlend) : -1;
398
+ const distal = spec.distalBlend ? boneIndex(rig, spec.distalBlend) : -1;
399
+ const axis = p1.clone().sub(p0);
400
+ const len = axis.length();
401
+ const axisN = axis.clone().divideScalar(len);
402
+ const blend = blendZone(len, heightFactor);
403
+
404
+ const pos = geo.attributes['position']!;
405
+ const n = pos.count;
406
+ const skinIndex = new Uint16Array(n * 4);
407
+ const skinWeight = new Float32Array(n * 4);
408
+ const v = new THREE.Vector3();
409
+ for (let i = 0; i < n; i++) {
410
+ v.fromBufferAttribute(pos as THREE.BufferAttribute, i);
411
+ const t = v.sub(p0).dot(axisN) / len; // may exceed [0,1] via overshoot
412
+ let other = primary;
413
+ let w2 = 0;
414
+ if (proximal >= 0 && t < blend) {
415
+ // 50/50 exactly at (and beyond) the joint, ramping to 100% primary.
416
+ other = proximal;
417
+ w2 = 0.5 * (1 - smoothstep(t / blend));
418
+ } else if (distal >= 0 && t > 1 - blend) {
419
+ other = distal;
420
+ w2 = 0.5 * smoothstep((t - (1 - blend)) / blend);
421
+ }
422
+ skinIndex.set([primary, other, 0, 0], i * 4);
423
+ skinWeight.set([1 - w2, w2, 0, 0], i * 4);
424
+ }
425
+ geo.setAttribute('skinIndex', new THREE.BufferAttribute(skinIndex, 4));
426
+ geo.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeight, 4));
427
+ }
428
+
429
+ /** Capsule spanning exactly `p0 - dir*overshootFrom` → `p1 + dir*overshootTo`. */
430
+ function capsuleBetween(
431
+ p0: THREE.Vector3,
432
+ p1: THREE.Vector3,
433
+ radius: number,
434
+ overshootFrom: number,
435
+ overshootTo: number,
436
+ ): THREE.BufferGeometry {
437
+ const dir = p1.clone().sub(p0).normalize();
438
+ const a = p0.clone().addScaledVector(dir, -overshootFrom);
439
+ const b = p1.clone().addScaledVector(dir, overshootTo);
440
+ const span = a.distanceTo(b);
441
+ const cyl = Math.max(0.01, span - 2 * radius);
442
+ const geo = new THREE.CapsuleGeometry(radius, cyl, 6, 16);
443
+ geo.deleteAttribute('uv'); // untextured; keeps merged attribute sets identical
444
+ const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
445
+ const mid = a.clone().add(b).multiplyScalar(0.5);
446
+ geo.applyMatrix4(new THREE.Matrix4().compose(mid, quat, new THREE.Vector3(1, 1, 1)));
447
+ return geo;
448
+ }
449
+
450
+ /** The pelvis is the one non-capsule part: a box centred between the hips
451
+ * and the upper-leg roots, rigidly bound to `mixamorigHips` (the thigh
452
+ * capsules overshoot up into it and carry the blending). */
453
+ function buildPelvis(rig: BuiltSkeleton, dims: HumanoidBodyDims): THREE.BufferGeometry {
454
+ const hips = jointPos(rig, 'mixamorigHips');
455
+ const upL = jointPos(rig, 'mixamorigLeftUpLeg');
456
+ const upR = jointPos(rig, 'mixamorigRightUpLeg');
457
+ const centerY = (hips.y + (upL.y + upR.y) / 2) / 2;
458
+ const f = dims.heightFactor;
459
+ const geo = new THREE.BoxGeometry(
460
+ 0.3 * f * dims.hipWidth,
461
+ 0.17 * f,
462
+ 0.19 * f * dims.torsoGirth,
463
+ 2,
464
+ 2,
465
+ 2,
466
+ );
467
+ geo.deleteAttribute('uv');
468
+ geo.translate(hips.x, centerY, hips.z);
469
+ paint(geo, C.pelvis);
470
+ const hipsIndex = boneIndex(rig, 'mixamorigHips');
471
+ const n = geo.attributes['position']!.count;
472
+ const skinIndex = new Uint16Array(n * 4);
473
+ const skinWeight = new Float32Array(n * 4);
474
+ for (let i = 0; i < n; i++) {
475
+ skinIndex[i * 4] = hipsIndex;
476
+ skinWeight[i * 4] = 1;
477
+ }
478
+ geo.setAttribute('skinIndex', new THREE.BufferAttribute(skinIndex, 4));
479
+ geo.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeight, 4));
480
+ return geo;
481
+ }
482
+
483
+ /** One merged-body part's vertex range + the bones its weights may reference —
484
+ * the segment-adjacency contract the unit tests verify. */
485
+ export interface HumanoidBodyPart {
486
+ name: string;
487
+ /** skinIndex values every non-zero-weight influence in the range must use. */
488
+ boneIndices: readonly number[];
489
+ vertexStart: number;
490
+ vertexCount: number;
491
+ }
492
+
493
+ export interface HumanoidBody {
494
+ mesh: THREE.SkinnedMesh;
495
+ skeleton: THREE.Skeleton;
496
+ vertexCount: number;
497
+ parts: readonly HumanoidBodyPart[];
498
+ }
499
+
500
+ function radiusMultiplier(group: RadiusGroup, dims: HumanoidBodyDims): number {
501
+ switch (group) {
502
+ case 'torso':
503
+ return dims.torsoGirth;
504
+ case 'limb':
505
+ return dims.limbThickness;
506
+ case 'head':
507
+ return dims.headSize;
508
+ case 'foot':
509
+ return 1;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Build the whole body as ONE merged `SkinnedMesh` in the rig root's space
515
+ * (the skeleton's rest pose), and bind it. Callers must have called
516
+ * `updateMatrixWorld(true)` on the armature's parent after adding the bone
517
+ * hierarchy, so every bone's `matrixWorld` is its rest pose —
518
+ * `new THREE.Skeleton(bones)` captures the inverse bind matrices from
519
+ * exactly those.
520
+ */
521
+ export function buildHumanoidBody(
522
+ rig: BuiltSkeleton,
523
+ dims: HumanoidBodyDims,
524
+ material?: THREE.Material,
525
+ ): HumanoidBody {
526
+ const geos: THREE.BufferGeometry[] = [buildPelvis(rig, dims)];
527
+ const parts: HumanoidBodyPart[] = [];
528
+ let vertexCursor = geos[0]!.attributes['position']!.count;
529
+ parts.push({
530
+ name: 'pelvis',
531
+ boneIndices: [boneIndex(rig, 'mixamorigHips')],
532
+ vertexStart: 0,
533
+ vertexCount: vertexCursor,
534
+ });
535
+ const f = dims.heightFactor;
536
+ for (const spec of SEGMENTS) {
537
+ const p0 = jointPos(rig, spec.from);
538
+ const p1 = jointPos(rig, spec.to);
539
+ const radius = spec.radius * f * radiusMultiplier(spec.group, dims);
540
+ const geo = capsuleBetween(
541
+ p0,
542
+ p1,
543
+ radius,
544
+ (spec.overshootFrom ?? 0) * f,
545
+ (spec.overshootTo ?? 0) * f,
546
+ );
547
+ paint(geo, spec.color);
548
+ skinSegment(geo, rig, spec, p0, p1, f);
549
+ const count = geo.attributes['position']!.count;
550
+ const boneIndices = [boneIndex(rig, spec.bone)];
551
+ if (spec.proximalBlend) boneIndices.push(boneIndex(rig, spec.proximalBlend));
552
+ if (spec.distalBlend) boneIndices.push(boneIndex(rig, spec.distalBlend));
553
+ parts.push({ name: spec.bone, boneIndices, vertexStart: vertexCursor, vertexCount: count });
554
+ vertexCursor += count;
555
+ geos.push(geo);
556
+ }
557
+ const merged = mergeGeometries(geos, false);
558
+ if (!merged) throw new Error('humanoid: mergeGeometries failed');
559
+ for (const g of geos) g.dispose();
560
+
561
+ const mat =
562
+ material ??
563
+ new THREE.MeshStandardMaterial({
564
+ vertexColors: true,
565
+ roughness: 0.75,
566
+ metalness: 0.05,
567
+ });
568
+ const mesh = new THREE.SkinnedMesh(merged, mat);
569
+ mesh.name = 'ProceduralHumanoidBody';
570
+ mesh.castShadow = true;
571
+ // An animated skinned mesh moves far outside its static rest-pose bounds.
572
+ mesh.frustumCulled = false;
573
+
574
+ const skeleton = new THREE.Skeleton(rig.bones);
575
+ mesh.updateMatrixWorld(true);
576
+ mesh.bind(skeleton, mesh.matrixWorld.clone());
577
+ return {
578
+ mesh,
579
+ skeleton,
580
+ vertexCount: merged.attributes['position']!.count,
581
+ parts,
582
+ };
583
+ }
584
+
585
+ // --- Segment-binding exposure (package-internal, consumed by compose.ts) ----
586
+
587
+ /** One skin-binding segment of the standard body, resolved against a concrete
588
+ * rest pose: the analytic per-segment data `skinToHumanoid` shares with the
589
+ * body generator so arbitrary clothing geometry deforms EXACTLY like the
590
+ * body part underneath it. Not part of the public module surface. */
591
+ export interface HumanoidBindingSegment {
592
+ /** Primary bone row (the skinIndex value). */
593
+ bone: number;
594
+ /** Blend-partner rows near t=0 / t=1 (-1 = rigid end). */
595
+ proximal: number;
596
+ distal: number;
597
+ /** Proximal joint rest position, model-space metres. */
598
+ p0: THREE.Vector3;
599
+ /** Unit axis p0→p1 (p1 = distal joint) and the joint-to-joint length. */
600
+ axis: THREE.Vector3;
601
+ len: number;
602
+ /** Normalized blend-zone length (the body's own `blendZone` value). */
603
+ blend: number;
604
+ /** The body part's surface radius, metres — nearest-segment selection
605
+ * measures distance to the SURFACE so loose clothing picks the part it
606
+ * visually covers, not merely the closest bone axis. */
607
+ radius: number;
608
+ }
609
+
610
+ /**
611
+ * Resolve the body's segment map (plus one synthetic rigid pelvis segment —
612
+ * the pelvis box binds 100% to Hips, and belts/skirts must get the same
613
+ * anchor to stay congruent with it) against a concrete rest pose.
614
+ * Parameterized on lookups rather than `BuiltSkeleton` because the compose
615
+ * path recovers rest joints from `Skeleton.boneInverses` (bones may be
616
+ * mid-animation by then), while the generator reads live rest matrices.
617
+ * `jointAt` must return a FRESH Vector3 per call (results are mutated here).
618
+ */
619
+ export function computeBindingSegments(
620
+ jointAt: (name: string) => THREE.Vector3,
621
+ indexOf: (name: string) => number,
622
+ dims: HumanoidBodyDims,
623
+ ): HumanoidBindingSegment[] {
624
+ const f = dims.heightFactor;
625
+ const segments: HumanoidBindingSegment[] = [];
626
+
627
+ // Axis LATERALLY along the hip-to-hip line (not hips→legs): a capsule
628
+ // there is the closest round proxy for the pelvis box, and — unlike a
629
+ // short vertical axis with a wide radius — it does not bulge spherically
630
+ // down between the thighs and steal inner-thigh vertices from the legs.
631
+ const upLegL = jointAt('mixamorigLeftUpLeg');
632
+ const upLegR = jointAt('mixamorigRightUpLeg');
633
+ const pelvisAxis = upLegR.clone().sub(upLegL);
634
+ const pelvisLen = Math.max(pelvisAxis.length(), 1e-6);
635
+ segments.push({
636
+ bone: indexOf('mixamorigHips'),
637
+ proximal: -1,
638
+ distal: -1,
639
+ p0: upLegL,
640
+ axis: pelvisAxis.divideScalar(pelvisLen),
641
+ len: pelvisLen,
642
+ blend: 0,
643
+ radius: 0.1 * f * dims.hipWidth,
644
+ });
645
+
646
+ for (const spec of SEGMENTS) {
647
+ const p0 = jointAt(spec.from);
648
+ const p1 = jointAt(spec.to);
649
+ const axis = p1.sub(p0);
650
+ const len = Math.max(axis.length(), 1e-6);
651
+ segments.push({
652
+ bone: indexOf(spec.bone),
653
+ proximal: spec.proximalBlend ? indexOf(spec.proximalBlend) : -1,
654
+ distal: spec.distalBlend ? indexOf(spec.distalBlend) : -1,
655
+ p0,
656
+ axis: axis.divideScalar(len),
657
+ len,
658
+ blend: blendZone(len, f),
659
+ radius: spec.radius * f * radiusMultiplier(spec.group, dims),
660
+ });
661
+ }
662
+ return segments;
663
+ }