@call-me-sensei/toonlab 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,541 @@
1
+ import * as THREE from 'three';
2
+ import { targetBoneNameForRole, targetBoneNamesForRole } from './characterRig.js';
3
+
4
+ // Procedurally generated freestyle (front crawl) swim clip — no FBX source.
5
+ // Motion is authored in the character's rest frame (standing): arms windmill
6
+ // in the sagittal plane with a catch-up glide (hands meet at full front
7
+ // extension), legs flutter-kick, the torso rolls around its long axis, and a
8
+ // prone pitch on the torso roots lays the whole body flat in the water. Each
9
+ // bone's offset is a world-space rotation delta accumulated down the
10
+ // hierarchy (D(bone) = D(parent) * offset(bone), world = D * restWorld), so
11
+ // the result is independent of the bones' local axes — the same world-space
12
+ // solve the Mixamo retargeter relies on. Which bone plays which humanoid role
13
+ // comes from the resolved `rig` (characterRig.js), so the clip bakes onto any
14
+ // supported skeleton (MMD, VRM, Rigify, Mixamo-named).
15
+ //
16
+ // The caller must put the skeleton in its bind pose before calling; the clip
17
+ // is baked against the bone world rotations found at call time.
18
+
19
+ const FPS = 30;
20
+ const STROKE_DURATION = 0.8; // seconds per single arm stroke
21
+ const STROKES_PER_CLIP = 4; // one breath per clip => breathe every 4th stroke
22
+ const DURATION = STROKE_DURATION * STROKES_PER_CLIP;
23
+
24
+ const PRONE_PITCH = 1.28; // torso pitch to horizontal (slightly head-up)
25
+ const BODY_ROLL = 0.3; // roll around the spine axis, synced to arms
26
+ const HANDS_MEET_BIAS = 0.06; // extra convergence past the computed centerline
27
+ // angle so the hands visibly land at the center
28
+ const RECOVERY_OUT = 0.35; // elbow swings wide during the over-air recovery
29
+ const ARM_GLIDE = 0.85; // dwell at full front extension (catch-up feel)
30
+ const ELBOW_PULL_BEND = 0.45; // elbow flex during the underwater pull
31
+ const ELBOW_RECOVERY_BEND = 1.35; // high-elbow flex keeps the hand low over water
32
+ const ENTRY_ELBOW_BEND = 0.15; // slight relaxed bend while extended out front
33
+ const PALM_DOWN_TWIST = Math.PI / 2; // forearm pronation: rigs whose bind palms
34
+ // face the body need a quarter twist to
35
+ // land them palm-down at the water entry
36
+ const FINGER_CURL = 0.28; // gentle joint curl cups the hand into a paddle
37
+ const THUMB_CURL = 0.22; // thumb tucks toward the palm edge
38
+ const KICK_BEATS = 3; // flutter kicks per windmill (6-beat kick)
39
+ const KICK_SWING = 0.26; // thigh swing amplitude
40
+ const KNEE_BEND = 0.5; // knee whip on the downbeat
41
+ const ANKLE_POINT = 0.5; // pointed toes
42
+ const HEAD_TUCK = 0.1; // extra neck+head pitch: face looks straight down
43
+ const BREATH_TURN = 0.55; // head roll to the side for the breath
44
+ const BREATH_LIFT = 0.35; // face pitches out of the water while breathing
45
+ const BREATH_ROLL = 0.18; // extra body roll supporting the breath
46
+ const CENTER_BOB = 0.02; // vertical hip bob, in world meters
47
+
48
+ const SIDES = Object.freeze({
49
+ right: Object.freeze({ key: 'right', mmd: '右', sign: -1 }),
50
+ left: Object.freeze({ key: 'left', mmd: '左', sign: 1 }),
51
+ });
52
+
53
+ const FINGERS = ['Index', 'Middle', 'Ring', 'Little'];
54
+ const FINGER_JOINTS = ['Proximal', 'Intermediate', 'Distal'];
55
+ const THUMB_JOINTS = ['ThumbMetacarpal', 'ThumbProximal', 'ThumbDistal'];
56
+
57
+ function findBone(targetMesh, name) {
58
+ if (!name) return null;
59
+ return targetMesh.skeleton.bones.find((bone) => bone.name === name) || null;
60
+ }
61
+
62
+ function isDescendantOf(bone, ancestor) {
63
+ for (let node = bone.parent; node; node = node.parent) {
64
+ if (node === ancestor) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ export function createFreestyleSwimClip(targetMesh, rig, {
70
+ clipName = 'FreestyleSwim',
71
+ // 'skeleton' emits ".bones[name].prop" tracks for mixers rooted at the
72
+ // skinned mesh (the retarget path); 'node' emits "name.prop" tracks for
73
+ // mixers rooted at the model scene (the native-clip path).
74
+ trackNameStyle = 'skeleton',
75
+ } = {}) {
76
+ targetMesh.updateMatrixWorld(true);
77
+
78
+ const roleBoneName = (role) => targetBoneNameForRole(rig, role);
79
+ const roleBone = (role) => findBone(targetMesh, roleBoneName(role));
80
+ const trackName = (boneName, property) => (trackNameStyle === 'node'
81
+ ? `${boneName}.${property}`
82
+ : `.bones[${boneName}].${property}`);
83
+
84
+ // Character frame at rest: lateral axis from the shoulder line, up from the
85
+ // world, forward completing the basis. The rig's bind pose faces the
86
+ // opposite of the direction ecctrl drives it (the Mixamo world-baked clips
87
+ // set that runtime convention), so the shoulder line is mirrored — without
88
+ // the flip the swimmer travels feet-first on her back.
89
+ const up = new THREE.Vector3(0, 1, 0);
90
+ const leftArm = roleBone('leftUpperArm');
91
+ const rightArm = roleBone('rightUpperArm');
92
+ const right = new THREE.Vector3(1, 0, 0);
93
+ if (leftArm && rightArm) {
94
+ right.subVectors(
95
+ leftArm.getWorldPosition(new THREE.Vector3()),
96
+ rightArm.getWorldPosition(new THREE.Vector3()),
97
+ );
98
+ right.y = 0;
99
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
100
+ right.normalize();
101
+ }
102
+ const forward = new THREE.Vector3().crossVectors(up, right).normalize();
103
+
104
+ const rotX = (angle) => new THREE.Quaternion().setFromAxisAngle(right, angle);
105
+ const rotY = (angle) => new THREE.Quaternion().setFromAxisAngle(up, angle);
106
+ const rotZ = (angle) => new THREE.Quaternion().setFromAxisAngle(forward, angle);
107
+ const compose = (...quats) => quats.reduce((result, q) => result.multiply(q), new THREE.Quaternion());
108
+
109
+ // Many binds are A-poses: the arms hang splayed outward, and a windmill
110
+ // baked on top of that splay traces a wide cone (arms flap out sideways).
111
+ // Align each upper arm to point straight down first so the stroke stays in
112
+ // a plane, then steer that plane per phase (inward at entry, out on recovery).
113
+ // The entry convergence is computed from the rig's own proportions: the hand
114
+ // lands on the centerline when the arm angles in by atan(shoulder offset /
115
+ // arm reach), so it adapts to anime-scale shoulders and arm lengths.
116
+ const armGeometry = (side) => {
117
+ const shoulder = roleBone(`${side.key}UpperArm`);
118
+ const elbow = roleBone(`${side.key}LowerArm`);
119
+ const wrist = roleBone(`${side.key}Hand`);
120
+ const geometry = {
121
+ align: new THREE.Quaternion(),
122
+ inward: 0.3 + HANDS_MEET_BIAS,
123
+ // Bind-frame forearm axis: the pronation twist rotates about it, and the
124
+ // accumulated chain delta carries it to the current forearm direction at
125
+ // every phase, so the twist always stays a twist about the forearm.
126
+ forearmAxis: new THREE.Vector3(0, -1, 0),
127
+ twistCarrier: wrist?.name ?? null,
128
+ };
129
+ if (!shoulder || !elbow) return geometry;
130
+ const shoulderPos = shoulder.getWorldPosition(new THREE.Vector3());
131
+ const elbowPos = elbow.getWorldPosition(new THREE.Vector3());
132
+ const dir = elbowPos.clone().sub(shoulderPos);
133
+ if (dir.lengthSq() < 1e-10) return geometry;
134
+ geometry.align.setFromUnitVectors(dir.clone().normalize(), new THREE.Vector3(0, -1, 0));
135
+ const upperLength = dir.length();
136
+ // Hand adds roughly a third of a forearm to the reach.
137
+ const forearmLength = wrist ? wrist.getWorldPosition(new THREE.Vector3()).distanceTo(elbowPos) : upperLength;
138
+ const reach = upperLength + forearmLength * 1.35;
139
+ const halfShoulderWidth = Math.abs(shoulderPos.dot(right)
140
+ - (leftArm && rightArm
141
+ ? leftArm.getWorldPosition(new THREE.Vector3()).add(rightArm.getWorldPosition(new THREE.Vector3())).multiplyScalar(0.5).dot(right)
142
+ : shoulderPos.dot(right)));
143
+ if (reach > 1e-6 && halfShoulderWidth > 1e-6) {
144
+ geometry.inward = Math.atan2(halfShoulderWidth, reach) + HANDS_MEET_BIAS;
145
+ }
146
+ if (wrist) {
147
+ const forearmDir = wrist.getWorldPosition(new THREE.Vector3()).sub(elbowPos);
148
+ if (forearmDir.lengthSq() > 1e-10) geometry.forearmAxis.copy(forearmDir.normalize());
149
+ // Standard MMD rigs put a dedicated twist bone (手捩) on the forearm with
150
+ // gradient skin weights — palm rotation belongs there when it drives the
151
+ // hand; otherwise twist the wrist bone directly.
152
+ if (rig?.type === 'mmd') {
153
+ const twistBone = findBone(targetMesh, `${side.mmd}手捩`);
154
+ if (twistBone && isDescendantOf(wrist, twistBone)) geometry.twistCarrier = twistBone.name;
155
+ }
156
+ }
157
+ return geometry;
158
+ };
159
+
160
+ // Bind-pose palm normal. MMD rigs bind in an A-pose with the palms facing
161
+ // the body, so the palm normal is the horizontal medial direction — that
162
+ // path is kept verbatim (tuned against the reference rig). Other rigs bind in
163
+ // arbitrary conventions (Rigify/Mixamo T-poses palm-down), so the normal is
164
+ // estimated from the hand's own geometry: fingers × knuckle line.
165
+ const medialPalmNormal = (side) => {
166
+ const wrist = roleBone(`${side.key}Hand`);
167
+ const otherWrist = roleBone(`${side.key === 'right' ? 'left' : 'right'}Hand`);
168
+ if (!wrist || !otherWrist) return null;
169
+ const normal = otherWrist.getWorldPosition(new THREE.Vector3())
170
+ .sub(wrist.getWorldPosition(new THREE.Vector3()));
171
+ normal.y = 0;
172
+ return normal.lengthSq() > 1e-10 ? normal.normalize() : null;
173
+ };
174
+
175
+ const geometricPalmNormal = (side) => {
176
+ const wrist = roleBone(`${side.key}Hand`);
177
+ const middle = roleBone(`${side.key}MiddleProximal`);
178
+ const index = roleBone(`${side.key}IndexProximal`);
179
+ const little = roleBone(`${side.key}LittleProximal`);
180
+ if (!wrist || !middle || !index || !little) return null;
181
+ const fingerDir = middle.getWorldPosition(new THREE.Vector3())
182
+ .sub(wrist.getWorldPosition(new THREE.Vector3()));
183
+ const knuckleLine = little.getWorldPosition(new THREE.Vector3())
184
+ .sub(index.getWorldPosition(new THREE.Vector3()));
185
+ // Right hand flat on a table, fingers away: index→little runs right,
186
+ // fingers run forward, (index→little) × fingers points down = palm side.
187
+ // The left hand mirrors.
188
+ const normal = new THREE.Vector3().crossVectors(knuckleLine, fingerDir);
189
+ if (normal.lengthSq() < 1e-10) return null;
190
+ normal.normalize();
191
+ if (side.key === 'left') normal.negate();
192
+ return normal;
193
+ };
194
+
195
+ const palmNormalFor = (side) => (rig?.type === 'mmd'
196
+ ? medialPalmNormal(side)
197
+ : geometricPalmNormal(side) || medialPalmNormal(side));
198
+
199
+ // Shape the hand into a swimmer's paddle: fingers pressed together (each
200
+ // finger steered parallel to the middle finger within the palm plane) and
201
+ // every joint curled gently toward the palm so the hand cups the water.
202
+ // Static offsets — the shape holds through the whole stroke and the loop
203
+ // stays seamless.
204
+ const handFor = (side) => {
205
+ const wrist = roleBone(`${side.key}Hand`);
206
+ if (!wrist) return [];
207
+ const palmNormal = palmNormalFor(side);
208
+ if (!palmNormal) return [];
209
+
210
+ const jointDir = (bone) => {
211
+ const child = bone.children.find((node) => node.isBone);
212
+ const pos = bone.getWorldPosition(new THREE.Vector3());
213
+ return child
214
+ ? child.getWorldPosition(new THREE.Vector3()).sub(pos)
215
+ : pos.sub(bone.parent.getWorldPosition(new THREE.Vector3()));
216
+ };
217
+ const inPalmPlane = (dir) => {
218
+ const projected = dir.clone().addScaledVector(palmNormal, -dir.dot(palmNormal));
219
+ return projected.lengthSq() > 1e-10 ? projected.normalize() : null;
220
+ };
221
+
222
+ const middleBase = roleBone(`${side.key}MiddleProximal`);
223
+ const middleDir = middleBase ? inPalmPlane(jointDir(middleBase)) : null;
224
+
225
+ const specs = [];
226
+ const addJoint = (bone, curl, adductTo) => {
227
+ if (!bone) return;
228
+ const dir = jointDir(bone);
229
+ if (dir.lengthSq() < 1e-10) return;
230
+ dir.normalize();
231
+ // Rotating about dir × palmNormal moves the fingertip toward the palm.
232
+ const curlAxis = dir.clone().cross(palmNormal);
233
+ if (curlAxis.lengthSq() < 1e-10) return;
234
+ const shape = new THREE.Quaternion().setFromAxisAngle(curlAxis.normalize(), curl);
235
+ const flatDir = adductTo ? inPalmPlane(dir) : null;
236
+ if (flatDir && adductTo) {
237
+ shape.multiply(new THREE.Quaternion().setFromUnitVectors(flatDir, adductTo));
238
+ }
239
+ specs.push({ name: bone.name, offset: () => shape.clone() });
240
+ };
241
+
242
+ for (const finger of FINGERS) {
243
+ for (const joint of FINGER_JOINTS) {
244
+ addJoint(
245
+ roleBone(`${side.key}${finger}${joint}`),
246
+ FINGER_CURL,
247
+ joint === 'Proximal' && finger !== 'Middle' ? middleDir : null,
248
+ );
249
+ }
250
+ }
251
+ // Tuck the thumb against the index finger: swing the thumb base to run
252
+ // parallel with the index finger's bind direction, add the same base curl
253
+ // the index gets so it hugs the curled finger, then curl the outer joints.
254
+ const indexBase = roleBone(`${side.key}IndexProximal`);
255
+ const indexDir = indexBase ? jointDir(indexBase) : null;
256
+ let isThumbBase = true;
257
+ for (const joint of THUMB_JOINTS) {
258
+ const bone = roleBone(`${side.key}${joint}`);
259
+ if (!bone) continue;
260
+ if (isThumbBase && indexDir && indexDir.lengthSq() > 1e-10) {
261
+ isThumbBase = false;
262
+ const dir = jointDir(bone);
263
+ if (dir.lengthSq() > 1e-10) {
264
+ const shape = new THREE.Quaternion()
265
+ .setFromUnitVectors(dir.normalize(), indexDir.clone().normalize());
266
+ const curlAxis = indexDir.clone().normalize().cross(palmNormal);
267
+ if (curlAxis.lengthSq() > 1e-10) {
268
+ shape.premultiply(new THREE.Quaternion().setFromAxisAngle(curlAxis.normalize(), FINGER_CURL));
269
+ }
270
+ specs.push({ name: bone.name, offset: () => shape.clone() });
271
+ continue;
272
+ }
273
+ }
274
+ isThumbBase = false;
275
+ addJoint(bone, THUMB_CURL, null);
276
+ }
277
+ return specs;
278
+ };
279
+
280
+ // armPhase 0 = hand entry (arm extended past the head). (0, PI) is the
281
+ // underwater pull, (PI, 2*PI) the over-air recovery. The rest pose arm
282
+ // hangs down, so the swing starts PI away from it. ARM_GLIDE slows the
283
+ // swing near entry: with the arms half a cycle apart, both dwell extended
284
+ // in front at the same time — the catch-up moment where the hands meet.
285
+ const armSwing = (armPhase) => Math.PI + armPhase - ARM_GLIDE * Math.sin(armPhase);
286
+ const elbowBend = (armPhase) => {
287
+ const s = Math.sin(armPhase);
288
+ const entry = Math.max(0, Math.cos(armPhase));
289
+ // A touch of relaxed elbow while the arm is extended out front.
290
+ return (s >= 0 ? ELBOW_PULL_BEND * s : -ELBOW_RECOVERY_BEND * s) + ENTRY_ELBOW_BEND * entry * entry;
291
+ };
292
+ // Lateral steering of the stroke plane: negative = toward the centerline.
293
+ const armLateral = (armPhase, inward) => {
294
+ const entry = Math.max(0, Math.cos(armPhase));
295
+ const recovery = Math.max(0, -Math.sin(armPhase));
296
+ return -inward * entry * entry + RECOVERY_OUT * recovery;
297
+ };
298
+ // One breath per clip, timed to the right arm's second recovery. Zero at
299
+ // the clip boundaries so the loop stays seamless.
300
+ const breathPulse = (phase) => {
301
+ const s = Math.max(0, Math.sin(2 * phase - 3 * Math.PI));
302
+ return s * s;
303
+ };
304
+
305
+ // The clip spans STROKES_PER_CLIP single-arm strokes: the windmill phase
306
+ // runs twice as fast as the clip phase. One entry per animated bone;
307
+ // offset(phase) returns the world-frame delta for the clip phase [0, 2*PI).
308
+ const windmill = (phase) => 2 * phase;
309
+
310
+ // Pronation: the palm should land flat on the water at entry and stay
311
+ // facing backward through the pull — palming the water, not slicing it.
312
+ // MMD keeps the tuned quarter turn (bind palms face the body); other rigs
313
+ // compute the twist that maps their actual bind palm normal to straight
314
+ // down at the entry pose, absorbing whatever the bind convention is (and
315
+ // the twist ambiguity of the upper-arm align). Held constant so the loop
316
+ // stays seamless.
317
+ const pronationFor = (side, { align, inward, forearmAxis }) => {
318
+ const fallback = new THREE.Quaternion().setFromAxisAngle(forearmAxis, side.sign * PALM_DOWN_TWIST);
319
+ if (rig?.type === 'mmd') return fallback;
320
+ const bindPalm = palmNormalFor(side);
321
+ if (!bindPalm) return fallback;
322
+ // Full world delta carrying the hand at entry (armPhase 0): the torso's
323
+ // prone pitch and chest counter-pitch (their phase terms are zero at
324
+ // entry), the arm swing/steer/align, and the entry elbow bend. Pull the
325
+ // world-down target back into the bind frame the offsets compose in.
326
+ const entryDelta = compose(
327
+ rotX(PRONE_PITCH),
328
+ rotX(-0.1),
329
+ rotZ(side.sign * armLateral(0, inward)),
330
+ rotX(armSwing(0)),
331
+ align.clone(),
332
+ rotX(elbowBend(0)),
333
+ );
334
+ const targetBind = new THREE.Vector3(0, -1, 0).applyQuaternion(entryDelta.clone().invert());
335
+ const project = (v) => v.clone().addScaledVector(forearmAxis, -v.dot(forearmAxis));
336
+ const from = project(bindPalm);
337
+ const to = project(targetBind);
338
+ if (from.lengthSq() < 1e-8 || to.lengthSq() < 1e-8) return fallback;
339
+ from.normalize();
340
+ to.normalize();
341
+ let angle = Math.acos(THREE.MathUtils.clamp(from.dot(to), -1, 1));
342
+ if (new THREE.Vector3().crossVectors(from, to).dot(forearmAxis) < 0) angle = -angle;
343
+ return new THREE.Quaternion().setFromAxisAngle(forearmAxis, angle);
344
+ };
345
+
346
+ const armFor = (side, phaseShift) => {
347
+ // Sign of the lateral steer. The steer rotates about the forward axis and
348
+ // acts while the arm points up/forward (entry and recovery) — a Z-rotation
349
+ // moves an up-pointing arm the opposite way from a hanging one, so the
350
+ // right arm converges with a positive rotation, not a negative one.
351
+ const { sign } = side;
352
+ const geometry = armGeometry(side);
353
+ const { align, inward, twistCarrier } = geometry;
354
+ const arm = (p) => windmill(p) + phaseShift;
355
+ const wristName = roleBoneName(`${side.key}Hand`);
356
+ const palmDown = pronationFor(side, geometry);
357
+ const wristFlap = (p) => rotX(0.15 * Math.sin(arm(p)));
358
+ return [
359
+ { name: roleBoneName(`${side.key}Shoulder`), offset: (p) => rotX(0.18 * Math.sin(arm(p))) },
360
+ {
361
+ name: roleBoneName(`${side.key}UpperArm`),
362
+ offset: (p) => compose(rotZ(sign * armLateral(arm(p), inward)), rotX(armSwing(arm(p))), align.clone()),
363
+ },
364
+ { name: roleBoneName(`${side.key}LowerArm`), offset: (p) => rotX(elbowBend(arm(p))) },
365
+ ...(twistCarrier === wristName
366
+ ? [{ name: wristName, offset: (p) => compose(palmDown.clone(), wristFlap(p)) }]
367
+ : [
368
+ { name: twistCarrier, offset: () => palmDown.clone() },
369
+ { name: wristName, offset: (p) => wristFlap(p) },
370
+ ]),
371
+ ];
372
+ };
373
+
374
+ // Roles that expand to several bones (MMD FK/D leg pairs) share a motion;
375
+ // the D bones are siblings of the FK bones on standard rigs and both drive
376
+ // skin weights depending on the model.
377
+ const legFor = (side, phaseShift) => {
378
+ const kick = (p) => windmill(p) * KICK_BEATS + phaseShift;
379
+ const role = (suffix) => `${side.key}${suffix}`;
380
+ const expand = (roleName, offset) => targetBoneNamesForRole(rig, roleName)
381
+ .map((name) => ({ name, offset, role: roleName }));
382
+ return [
383
+ ...expand(role('UpperLeg'), (p) => rotX(KICK_SWING * Math.sin(kick(p)) + 0.06)),
384
+ ...expand(role('LowerLeg'), (p) => rotX(KNEE_BEND * Math.max(0, Math.cos(kick(p))))),
385
+ ...expand(role('Foot'), (p) => rotX(ANKLE_POINT + 0.1 * Math.sin(kick(p)))),
386
+ ];
387
+ };
388
+
389
+ const specs = [
390
+ { name: roleBoneName('hips'), offset: (p) => compose(rotX(PRONE_PITCH + 0.12), rotY(0.15 * Math.sin(windmill(p)))) },
391
+ {
392
+ name: roleBoneName('spine'),
393
+ offset: (p) => compose(
394
+ rotX(PRONE_PITCH),
395
+ rotY(BODY_ROLL * Math.sin(windmill(p)) - BREATH_ROLL * breathPulse(p)),
396
+ ),
397
+ },
398
+ { name: roleBoneName('upperChest'), offset: (p) => compose(rotX(-0.1), rotY(0.4 * BODY_ROLL * Math.sin(windmill(p)))) },
399
+ { name: roleBoneName('chest'), offset: (p) => rotY(0.2 * BODY_ROLL * Math.sin(windmill(p))) },
400
+ { name: roleBoneName('neck'), offset: (p) => rotX(HEAD_TUCK - 0.3 * BREATH_LIFT * breathPulse(p)) },
401
+ {
402
+ name: roleBoneName('head'),
403
+ offset: (p) => compose(
404
+ rotX(HEAD_TUCK - 0.7 * BREATH_LIFT * breathPulse(p)),
405
+ rotY(-BREATH_TURN * breathPulse(p)),
406
+ ),
407
+ },
408
+ ...armFor(SIDES.right, 0),
409
+ ...armFor(SIDES.left, Math.PI),
410
+ ...handFor(SIDES.right),
411
+ ...handFor(SIDES.left),
412
+ ...legFor(SIDES.right, 0),
413
+ ...legFor(SIDES.left, Math.PI),
414
+ ];
415
+
416
+ const animated = [];
417
+ const offsetsByBone = new Map();
418
+ for (const spec of specs) {
419
+ const bone = findBone(targetMesh, spec.name);
420
+ if (!bone || offsetsByBone.has(bone)) continue;
421
+ animated.push({
422
+ bone,
423
+ role: spec.role ?? null,
424
+ offset: spec.offset,
425
+ restWorldQuat: bone.getWorldQuaternion(new THREE.Quaternion()),
426
+ parentRestWorldQuat: bone.parent
427
+ ? bone.parent.getWorldQuaternion(new THREE.Quaternion())
428
+ : new THREE.Quaternion(),
429
+ });
430
+ offsetsByBone.set(bone, spec.offset);
431
+ }
432
+
433
+ // Guard against non-standard rigs where one of a role's bones is a child of
434
+ // another (e.g. an MMD D bone under its FK counterpart) — the inherited
435
+ // delta already carries the motion there.
436
+ for (let i = animated.length - 1; i >= 0; i -= 1) {
437
+ const { bone, role } = animated[i];
438
+ if (!role) continue;
439
+ const roleAncestor = animated.find((other) => other !== animated[i]
440
+ && other.role === role
441
+ && isDescendantOf(bone, other.bone));
442
+ if (roleAncestor) {
443
+ offsetsByBone.delete(bone);
444
+ animated.splice(i, 1);
445
+ }
446
+ }
447
+
448
+ if (animated.length === 0) {
449
+ throw new Error('Freestyle swim clip: no animatable humanoid bones were found.');
450
+ }
451
+
452
+ // The specs author world-space intents per branch assuming the MMD layout,
453
+ // where hips (下半身) and spine (上半身) are SIBLINGS under センター. Standard
454
+ // humanoid rigs (Rigify/VRM/Mixamo) parent the spine under the hips, so the
455
+ // accumulated delta would stack the hips' prone pitch on top of the spine's
456
+ // own — folding the body in half and driving the head underwater. Rebase
457
+ // the spine offset by the inverse of the hips offset so its accumulated
458
+ // world delta matches the authored intent on either hierarchy.
459
+ const hipsEntry = animated.find((node) => node.bone.name === roleBoneName('hips'));
460
+ const spineEntry = animated.find((node) => node.bone.name === roleBoneName('spine'));
461
+ if (hipsEntry && spineEntry && isDescendantOf(spineEntry.bone, hipsEntry.bone)) {
462
+ const hipsOffset = hipsEntry.offset;
463
+ const spineOffset = spineEntry.offset;
464
+ const rebasedOffset = (p) => hipsOffset(p).invert().multiply(spineOffset(p));
465
+ spineEntry.offset = rebasedOffset;
466
+ offsetsByBone.set(spineEntry.bone, rebasedOffset);
467
+ }
468
+
469
+ const frameCount = Math.round(FPS * DURATION) + 1;
470
+ const times = new Float32Array(frameCount);
471
+ for (const node of animated) node.values = new Float32Array(frameCount * 4);
472
+
473
+ const centerBone = findBone(targetMesh, rig?.hipCarrierName);
474
+ const centerState = centerBone ? {
475
+ values: new Float32Array(frameCount * 3),
476
+ restLocalPos: centerBone.position.clone(),
477
+ parentWorldQuatInverse: centerBone.parent
478
+ ? centerBone.parent.getWorldQuaternion(new THREE.Quaternion()).invert()
479
+ : new THREE.Quaternion(),
480
+ parentWorldScale: centerBone.parent
481
+ ? centerBone.parent.getWorldScale(new THREE.Vector3())
482
+ : new THREE.Vector3(1, 1, 1),
483
+ } : null;
484
+
485
+ const identity = new THREE.Quaternion();
486
+ const deltaCache = new Map();
487
+ const getDelta = (node) => {
488
+ if (!node || node === targetMesh || !node.isBone) return identity;
489
+ let delta = deltaCache.get(node);
490
+ if (!delta) {
491
+ delta = getDelta(node.parent).clone();
492
+ const offset = offsetsByBone.get(node);
493
+ if (offset) delta.multiply(offset.__current);
494
+ deltaCache.set(node, delta);
495
+ }
496
+ return delta;
497
+ };
498
+
499
+ const worldQuat = new THREE.Quaternion();
500
+ const parentWorldQuat = new THREE.Quaternion();
501
+ const bobVector = new THREE.Vector3();
502
+
503
+ for (let frame = 0; frame < frameCount; frame += 1) {
504
+ const phase = (2 * Math.PI * frame) / (frameCount - 1);
505
+ times[frame] = frame / FPS;
506
+
507
+ deltaCache.clear();
508
+ for (const node of animated) node.offset.__current = node.offset(phase);
509
+
510
+ for (const node of animated) {
511
+ const parentDelta = getDelta(node.bone.parent);
512
+ worldQuat.copy(parentDelta).multiply(node.offset.__current).multiply(node.restWorldQuat);
513
+ parentWorldQuat.copy(parentDelta).multiply(node.parentRestWorldQuat);
514
+ parentWorldQuat.invert().multiply(worldQuat).normalize();
515
+ parentWorldQuat.toArray(node.values, frame * 4);
516
+ }
517
+
518
+ if (centerState) {
519
+ bobVector.set(0, CENTER_BOB * Math.sin(2 * windmill(phase) - 0.5), 0)
520
+ .applyQuaternion(centerState.parentWorldQuatInverse)
521
+ .divide(centerState.parentWorldScale)
522
+ .add(centerState.restLocalPos);
523
+ bobVector.toArray(centerState.values, frame * 3);
524
+ }
525
+ }
526
+
527
+ const tracks = animated.map((node) => new THREE.QuaternionKeyframeTrack(
528
+ trackName(node.bone.name, 'quaternion'),
529
+ times.slice(),
530
+ node.values,
531
+ ));
532
+ if (centerState) {
533
+ tracks.push(new THREE.VectorKeyframeTrack(
534
+ trackName(centerBone.name, 'position'),
535
+ times.slice(),
536
+ centerState.values,
537
+ ));
538
+ }
539
+
540
+ return new THREE.AnimationClip(clipName, DURATION, tracks);
541
+ }
@@ -0,0 +1,4 @@
1
+ // Character pipeline barrel (rig roles and retarget helpers).
2
+ // Import from '@call-me-sensei/toonlab/character'.
3
+ export * from './characterRig.js';
4
+ export * from './freestyleSwimClip.js';