@docyrus/animaguru 0.27.0 → 0.29.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 (33) hide show
  1. package/README.md +28 -2
  2. package/dist/animaguru.js +933 -664
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +4 -1
  5. package/skills/animaguru-3d-models/SKILL.md +86 -0
  6. package/skills/animaguru-character-stage-3d/SKILL.md +35 -0
  7. package/skills/animaguru-character-stage-3d/references/recipe.md +26 -0
  8. package/skills/animaguru-exploded-teardown-3d/SKILL.md +38 -0
  9. package/skills/animaguru-exploded-teardown-3d/references/recipe.md +20 -0
  10. package/skills/animaguru-product-turntable-3d/SKILL.md +38 -0
  11. package/skills/animaguru-product-turntable-3d/references/recipe.md +27 -0
  12. package/skills/animaguru-quiz/SKILL.md +1 -1
  13. package/skills/animaguru-toy-diorama-3d/SKILL.md +33 -0
  14. package/skills/animaguru-toy-diorama-3d/references/recipe.md +26 -0
  15. package/skills/animaguru-video/SKILL.md +2 -2
  16. package/templates/video/AGENTS.md +1 -1
  17. package/templates/video/README.md +5 -1
  18. package/templates/video/package.json +3 -1
  19. package/templates/video/scripts/export-mp4.mjs +2 -2
  20. package/templates/video/scripts/install-example.mjs +10 -6
  21. package/templates/video/shared/locales/en/style-studies.json +35 -0
  22. package/templates/video/shared/locales/en/timeline.json +1 -0
  23. package/templates/video/src/kit/index.ts +1 -0
  24. package/templates/video/src/kit/three-stage.ts +262 -0
  25. package/templates/video/src/resource-timeline.ts +1 -0
  26. package/templates/video/src/studies/three/character-stage-3d.ts +172 -0
  27. package/templates/video/src/studies/three/exploded-teardown-3d.ts +214 -0
  28. package/templates/video/src/studies/three/index.ts +16 -0
  29. package/templates/video/src/studies/three/product-turntable-3d.ts +160 -0
  30. package/templates/video/src/studies/three/toy-diorama-3d.ts +245 -0
  31. package/templates/video/src/studies/three-film.ts +42 -0
  32. package/templates/video/src/style.css +2 -1
  33. package/templates/video/src/timeline-model.ts +1 -1
@@ -3,6 +3,7 @@
3
3
  "resourceArt": "Study artwork",
4
4
  "resourceMaterial": "Material & background",
5
5
  "resourceText": "Study typography",
6
+ "resourceScene": "3D scene",
6
7
  "styles": {
7
8
  "dark-tech-stage": {
8
9
  "name": "Dark Tech Stage",
@@ -357,5 +358,39 @@
357
358
  "Sun · arches · profile · ring"
358
359
  ]
359
360
  }
361
+ },
362
+ "threeStyles": {
363
+ "product-turntable-3d": {
364
+ "name": "3D Product Turntable",
365
+ "lines": [
366
+ "One product, every angle.",
367
+ "Studio light · orbit · push-in",
368
+ "Brushed metal"
369
+ ]
370
+ },
371
+ "character-stage-3d": {
372
+ "name": "3D Character Stage",
373
+ "lines": [
374
+ "A character steps onto the page.",
375
+ "Painted backdrop · real shadow",
376
+ "Walk · turn · wave"
377
+ ]
378
+ },
379
+ "toy-diorama-3d": {
380
+ "name": "3D Toy Diorama",
381
+ "lines": [
382
+ "A whole town on a tabletop.",
383
+ "Tilt-shift focus",
384
+ "Little loops, all day"
385
+ ]
386
+ },
387
+ "exploded-teardown-3d": {
388
+ "name": "3D Exploded Teardown",
389
+ "lines": [
390
+ "Every part, in its place.",
391
+ "Orbit · separate · label",
392
+ "Battery"
393
+ ]
394
+ }
360
395
  }
361
396
  }
@@ -20,6 +20,7 @@
20
20
  "text": "Text",
21
21
  "captions": "Captions",
22
22
  "audio": "Audio",
23
+ "model": "3D model",
23
24
  "paper": "Paper backdrop",
24
25
  "branding": "Studio wordmark",
25
26
  "tagline": "Film tagline",
@@ -10,3 +10,4 @@ export * from "./story";
10
10
  export * from "./layout";
11
11
  export * from "./film-base";
12
12
  export * from "./dark-stage";
13
+ export * from "./three-stage";
@@ -0,0 +1,262 @@
1
+ import { Sprite, Texture } from "pixi.js";
2
+ import * as THREE from "three";
3
+ import { GLTFLoader, type GLTF } from "three/addons/loaders/GLTFLoader.js";
4
+ import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
5
+ import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
6
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
7
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
8
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
9
+ import { BokehPass } from "three/addons/postprocessing/BokehPass.js";
10
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
11
+ import type { Pass } from "three/addons/postprocessing/Pass.js";
12
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
13
+ import { publicAsset } from "../assets";
14
+ import { easeInOut } from "./motion";
15
+
16
+ /*
17
+ 3D shots for the film: three.js draws into its own canvas, which the film shows as one Pixi Sprite,
18
+ so 2D art can sit behind it (the canvas is transparent where nothing is drawn) and text, captions
19
+ and leader lines on top. Everything stays a function of film time: pose rigs with Rig.pose(), move
20
+ cameras with vecAt(), never use THREE.Clock, mixer.update(delta) or Math.random() in a frame, and draw
21
+ last, from the film.on() update registered after every 3D update. See the animaguru-3d-models skill.
22
+ */
23
+
24
+ export { THREE };
25
+
26
+ export type StageOptions = {
27
+ /** The design size the sprite covers, in film coordinates. */
28
+ width: number;
29
+ height: number;
30
+ /** Render scale: 1.5–2 renders larger and lets Pixi downsample it, on top of MSAA. */
31
+ supersample?: number;
32
+ };
33
+
34
+ /** `passes` (for example tilt-shift or colour grading ShaderPasses) run after depth of field and bloom, before tone mapping. */
35
+ export type PostOptions = { bloom?: { strength: number; radius: number; threshold: number }; bokeh?: { focus: number; aperture: number; maxblur: number }; passes?: Pass[] };
36
+
37
+ export class ThreeStage {
38
+ readonly renderer: THREE.WebGLRenderer;
39
+ readonly sprite: Sprite;
40
+ readonly width: number;
41
+ readonly height: number;
42
+ private readonly scale: number;
43
+ private readonly composers = new Map<THREE.Scene, EffectComposer>();
44
+ private readonly gltf = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
45
+
46
+ constructor({ width, height, supersample = 1.5 }: StageOptions) {
47
+ this.width = width;
48
+ this.height = height;
49
+ this.scale = supersample;
50
+ const canvas = document.createElement("canvas");
51
+ // preserveDrawingBuffer: Pixi reads the canvas after three.js has presented it.
52
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: "high-performance" });
53
+ this.renderer.setPixelRatio(supersample);
54
+ this.renderer.setSize(width, height, false);
55
+ this.renderer.setClearColor(0x000000, 0);
56
+ this.renderer.toneMapping = THREE.AgXToneMapping;
57
+ this.renderer.shadowMap.enabled = true;
58
+ this.renderer.shadowMap.type = THREE.PCFShadowMap;
59
+ this.sprite = new Sprite(Texture.from(canvas));
60
+ this.sprite.width = width;
61
+ this.sprite.height = height;
62
+ }
63
+
64
+ /** Loads a GLB from public/ (meshopt-compressed files too); its meshes cast and receive shadows. */
65
+ async load(path: string): Promise<GLTF> {
66
+ const gltf = await this.gltf.loadAsync(publicAsset(path));
67
+ gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
68
+ return gltf;
69
+ }
70
+
71
+ /** An equirectangular .hdr from public/ as image-based lighting; `hdr` can also be a scene's background. */
72
+ async environment(path: string) {
73
+ const hdr = await new HDRLoader().loadAsync(publicAsset(path));
74
+ hdr.mapping = THREE.EquirectangularReflectionMapping;
75
+ const pmrem = new THREE.PMREMGenerator(this.renderer);
76
+ const env = pmrem.fromEquirectangular(hdr).texture;
77
+ pmrem.dispose();
78
+ return { hdr, env };
79
+ }
80
+
81
+ /** A soft studio environment generated in code, for image-based lighting without an HDR file. */
82
+ room(blur = 0.04) {
83
+ const pmrem = new THREE.PMREMGenerator(this.renderer);
84
+ const env = pmrem.fromScene(new RoomEnvironment(), blur).texture;
85
+ pmrem.dispose();
86
+ return env;
87
+ }
88
+
89
+ /** Post-processing for one scene: an MSAA target, optional depth of field and bloom, then tone mapping. Returns the depth-of-field pass, whose focus the film sets each frame. */
90
+ post(scene: THREE.Scene, camera: THREE.PerspectiveCamera, { bloom, bokeh, passes = [] }: PostOptions) {
91
+ const w = this.width * this.scale, h = this.height * this.scale;
92
+ const composer = new EffectComposer(this.renderer, new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, samples: 4 }));
93
+ composer.setPixelRatio(this.scale);
94
+ composer.setSize(this.width, this.height);
95
+ composer.addPass(new RenderPass(scene, camera));
96
+ const depth = bokeh ? new BokehPass(scene, camera, bokeh) : undefined;
97
+ if (depth) composer.addPass(depth);
98
+ if (bloom) composer.addPass(new UnrealBloomPass(new THREE.Vector2(w, h), bloom.strength, bloom.radius, bloom.threshold));
99
+ for (const pass of passes) composer.addPass(pass);
100
+ composer.addPass(new OutputPass());
101
+ this.composers.set(scene, composer);
102
+ return depth;
103
+ }
104
+
105
+ /** Compiles the scene's materials and draws it once, so the first exported frame never waits on shaders. Await it in build(). */
106
+ async warm(scene: THREE.Scene, camera: THREE.Camera) {
107
+ await this.renderer.compileAsync(scene, camera);
108
+ this.draw(scene, camera);
109
+ }
110
+
111
+ /** Draws the scene and hands the canvas to Pixi for this frame. */
112
+ draw(scene: THREE.Scene, camera: THREE.Camera) {
113
+ const composer = this.composers.get(scene);
114
+ if (composer) composer.render();
115
+ else this.renderer.render(scene, camera);
116
+ this.sprite.texture.source.update();
117
+ }
118
+
119
+ /** Film position of a world point, for 2D labels and leader lines that follow a 3D part. `behind` is true when the point is behind the camera. */
120
+ project(point: THREE.Vector3, camera: THREE.Camera) {
121
+ // three.js refreshes matrices while rendering, which happens after this; without this the label follows last frame's camera.
122
+ camera.updateMatrixWorld();
123
+ const v = point.clone().project(camera);
124
+ return { x: (v.x + 1) / 2 * this.width, y: (1 - v.y) / 2 * this.height, behind: v.z > 1 };
125
+ }
126
+
127
+ dispose() {
128
+ for (const composer of this.composers.values()) composer.dispose();
129
+ this.renderer.dispose();
130
+ this.sprite.destroy({ texture: true, textureSource: true });
131
+ }
132
+ }
133
+
134
+ /** Scales a model to `height` scene units (metres), stands it on y = 0 and centres it on the origin. Saved AnimaGuru models face +Z. */
135
+ export function normalize<T extends THREE.Object3D>(object: T, height: number): T {
136
+ object.updateMatrixWorld(true);
137
+ const box = new THREE.Box3().setFromObject(object);
138
+ object.scale.multiplyScalar(height / box.getSize(new THREE.Vector3()).y);
139
+ object.updateMatrixWorld(true);
140
+ box.setFromObject(object);
141
+ const center = box.getCenter(new THREE.Vector3());
142
+ object.position.x -= center.x;
143
+ object.position.z -= center.z;
144
+ object.position.y -= box.min.y;
145
+ return object;
146
+ }
147
+
148
+ export type ClipLayer = { clip: string; time: number; weight?: number; loop?: boolean };
149
+
150
+ /**
151
+ A rigged model's clips sampled at absolute times. pose() sets every clip's time and weight and applies
152
+ them with a zero-length update, so a frame depends only on its arguments, never on the frame before:
153
+ crossfade by easing two weights against film time.
154
+ */
155
+ export class Rig {
156
+ readonly mixer: THREE.AnimationMixer;
157
+ private readonly actions = new Map<string, THREE.AnimationAction>();
158
+
159
+ constructor(root: THREE.Object3D, clips: THREE.AnimationClip[]) {
160
+ this.mixer = new THREE.AnimationMixer(root);
161
+ for (const clip of clips) {
162
+ const action = this.mixer.clipAction(clip);
163
+ action.play();
164
+ action.setEffectiveWeight(0);
165
+ this.actions.set(clip.name, action);
166
+ }
167
+ }
168
+
169
+ get clips() { return [...this.actions.keys()]; }
170
+ duration(clip: string) { return this.action(clip).getClip().duration; }
171
+
172
+ /** `loop: false` holds a clip's last pose after it ends. */
173
+ pose(layers: ClipLayer[]) {
174
+ for (const action of this.actions.values()) { action.enabled = true; action.paused = false; action.setEffectiveWeight(0); }
175
+ for (const layer of layers) {
176
+ const action = this.action(layer.clip);
177
+ const length = action.getClip().duration;
178
+ action.time = layer.loop === false ? Math.min(Math.max(layer.time, 0), length - 1e-4) : ((layer.time % length) + length) % length;
179
+ action.setEffectiveWeight(layer.weight ?? 1);
180
+ }
181
+ this.mixer.update(0);
182
+ }
183
+
184
+ private action(clip: string) {
185
+ const action = this.actions.get(clip);
186
+ if (!action) throw new Error(`No clip "${clip}"; the model has ${this.clips.join(", ")}`);
187
+ return action;
188
+ }
189
+ }
190
+
191
+ /**
192
+ Removes a clip's horizontal root motion (clips from motion capture or other rigs often travel forward) and
193
+ returns the speed it had in world units a second at the model's current scale, so the film moves the
194
+ character itself; it returns 0 for in-place clips such as Meshy's, whose speed strideSpeed() measures. Call it
195
+ after normalize() and before posing.
196
+ */
197
+ export function inPlace(model: THREE.Object3D, clip: THREE.AnimationClip, bone = "Hips") {
198
+ const track = clip.tracks.find((item) => item.name === `${bone}.position`);
199
+ const node = model.getObjectByName(bone);
200
+ if (!track || !node?.parent) return 0;
201
+ model.updateMatrixWorld(true);
202
+ const parent = node.parent.matrixWorld, inverse = parent.clone().invert(), v = track.values, p = new THREE.Vector3();
203
+ const first = new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(parent);
204
+ const last = new THREE.Vector3(v[v.length - 3], v[v.length - 2], v[v.length - 1]).applyMatrix4(parent);
205
+ for (let i = 0; i < v.length; i += 3) {
206
+ p.set(v[i], v[i + 1], v[i + 2]).applyMatrix4(parent);
207
+ p.x = first.x;
208
+ p.z = first.z;
209
+ p.applyMatrix4(inverse);
210
+ v[i] = p.x; v[i + 1] = p.y; v[i + 2] = p.z;
211
+ }
212
+ return Math.hypot(last.x - first.x, last.z - first.z) / clip.duration;
213
+ }
214
+
215
+ /**
216
+ * Walking speed of an in-place walk cycle, in world units a second at the model's current scale. Meshy's clips
217
+ * have no root motion: while a foot is at its lowest it slides backwards at the speed the body must travel for it
218
+ * to stay planted. Call it after normalize(), with the model facing +Z and its holder not yet turned.
219
+ */
220
+ export function strideSpeed(rig: Rig, model: THREE.Object3D, clip: string, feet = ["LeftToeBase", "RightToeBase"]) {
221
+ const bones = feet.map((name) => model.getObjectByName(name)).filter((bone): bone is THREE.Object3D => Boolean(bone));
222
+ if (!bones.length) return 0;
223
+ const length = rig.duration(clip), step = 1 / 120, p = new THREE.Vector3();
224
+ const tracks = bones.map(() => [] as { y: number; z: number }[]);
225
+ for (let t = 0; t <= length; t += step) {
226
+ rig.pose([{ clip, time: t }]);
227
+ model.updateMatrixWorld(true);
228
+ bones.forEach((bone, i) => { bone.getWorldPosition(p); tracks[i].push({ y: p.y, z: p.z }); });
229
+ }
230
+ const speeds: number[] = [];
231
+ for (const track of tracks) {
232
+ const low = Math.min(...track.map((sample) => sample.y));
233
+ for (let i = 1; i < track.length; i++) if (track[i].y < low + 0.012 && track[i - 1].y < low + 0.012) speeds.push((track[i - 1].z - track[i].z) / step);
234
+ }
235
+ speeds.sort((a, b) => a - b);
236
+ return speeds.length ? speeds[Math.floor(speeds.length / 2)] : 0;
237
+ }
238
+
239
+ /** Sets a named blend shape on every mesh that has it, for models that carry expressions or visemes. */
240
+ export function setMorph(root: THREE.Object3D, name: string, weight: number) {
241
+ root.traverse((node) => {
242
+ const mesh = node as THREE.Mesh;
243
+ const index = mesh.morphTargetDictionary?.[name];
244
+ if (index !== undefined && mesh.morphTargetInfluences) mesh.morphTargetInfluences[index] = weight;
245
+ });
246
+ }
247
+
248
+ export type Key<T> = [time: number, value: T];
249
+
250
+ /** Eased keyframes for a position or target: holds before the first key and after the last. */
251
+ export function vecAt(t: number, keys: Key<[number, number, number]>[], out = new THREE.Vector3()) {
252
+ if (t <= keys[0][0]) return out.fromArray(keys[0][1]);
253
+ for (let i = 1; i < keys.length; i++) {
254
+ const [t1, b] = keys[i];
255
+ if (t <= t1) {
256
+ const [t0, a] = keys[i - 1];
257
+ const k = easeInOut((t - t0) / (t1 - t0));
258
+ return out.set(a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k);
259
+ }
260
+ }
261
+ return out.fromArray(keys[keys.length - 1][1]);
262
+ }
@@ -9,6 +9,7 @@ const icons = {
9
9
  graphics: "m12 3 9 9-9 9-9-9Z", image: "M3 3h18v18H3zM3 17l6-6 4 4 3-3 5 5M15 7h.01",
10
10
  video: "M3 5h13v14H3zM16 9l5-3v12l-5-3", text: "M4 5h16M12 5v15M8 20h8",
11
11
  captions: "M3 5h18v14H3zM10 9H7v6h3m7-6h-3v6h3", audio: "M4 9v6m4-10v14m4-16v18m4-15v12m4-9v6",
12
+ model: "M12 3 3 7.5v9L12 21l9-4.5v-9ZM3 7.5 12 12l9-4.5M12 12v9",
12
13
  };
13
14
 
14
15
  /** A navigable resource view. It changes the playhead, never the film's clip timing. */
@@ -0,0 +1,172 @@
1
+ import { Container, FillGradient, Graphics } from "pixi.js";
2
+ import { THREE, ThreeStage, clamp01, easeInOut, inOut, lerp, show, txt, win } from "../../kit";
3
+ import type { ThreeStudy } from ".";
4
+
5
+ /*
6
+ 3D Character Stage study: a wooden artist's mannequin, built from primitives in a joint hierarchy and posed by
7
+ functions of t, walks across a painted 2D square, turns to the camera and waves, casting a real shadow onto
8
+ the painting. The 3D camera is matched to the painting: level, at its eye height, with the lens shifted so the
9
+ horizon sits where it is painted. A film replaces the mannequin with a generated, rigged character
10
+ (animaguru-3d-models) posed with Rig.pose(), and the drawn square with a painted backdrop.
11
+ */
12
+
13
+ const W = 1920, H = 1080;
14
+ /** The painted horizon and the matching camera: eye 1.0 m above the ground, 34° vertical field. */
15
+ const HORIZON = 610, EYE = 1.0, FOV = 34;
16
+ /** The walk crosses the square on this line and stops at its middle. */
17
+ const FROM = new THREE.Vector3(-3.2, 0, -9.5), STOP = new THREE.Vector3(0.35, 0, -6.2);
18
+ /** Two steps take CYCLE seconds; a 0.3 rad leg swing reaches ±0.27 m, so the body moves 2 × 0.54 m a cycle. */
19
+ const CYCLE = 1.1, SWING = 0.3, SPEED = (4 * 0.9 * Math.sin(SWING)) / CYCLE;
20
+ const ARRIVE = Math.min(4, FROM.distanceTo(STOP) / SPEED);
21
+
22
+ /** The square, painted in flat strokes: sky, sun, roofs, the lit ground and the lines that recede to the horizon. */
23
+ function paintSquare() {
24
+ const paint = new Container();
25
+ const sky = new FillGradient({ type: "linear", start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, colorStops: [{ offset: 0, color: "#f2b563" }, { offset: 1, color: "#fde7b8" }] });
26
+ paint.addChild(new Graphics().rect(0, 0, W, HORIZON + 20).fill(sky));
27
+ for (const [r, a] of [[260, 0.16], [150, 0.28], [70, 0.9]] as const) paint.addChild(new Graphics().circle(760, HORIZON - 150, r).fill({ color: 0xfff3d1, alpha: a }));
28
+ paint.addChild(new Graphics().poly([0, HORIZON - 40, 380, HORIZON - 120, 760, HORIZON - 70, 1180, HORIZON - 150, 1600, HORIZON - 80, W, HORIZON - 110, W, HORIZON, 0, HORIZON]).fill({ color: 0xc98a5a, alpha: 0.55 }));
29
+ const houses = new Graphics();
30
+ const house = (x: number, w: number, h: number, wall: number, roof: number) => {
31
+ houses.rect(x, HORIZON - h, w, h).fill(wall);
32
+ houses.poly([x - 14, HORIZON - h, x + w / 2, HORIZON - h - w * 0.28, x + w + 14, HORIZON - h]).fill(roof);
33
+ for (let wx = x + 22; wx < x + w - 30; wx += 58) for (let wy = HORIZON - h + 34; wy < HORIZON - 60; wy += 78) houses.roundRect(wx, wy, 26, 40, 6).fill({ color: 0x5f7f8a, alpha: 0.8 });
34
+ };
35
+ house(-40, 300, 520, 0xe8b88a, 0xb5573c); house(250, 220, 360, 0xf1cf98, 0xa9573f); house(460, 180, 250, 0xe9c2a0, 0xb86a48);
36
+ house(1290, 200, 260, 0xefd0a6, 0xb0603f); house(1480, 230, 380, 0xe6b37f, 0xa8533a); house(1700, 260, 540, 0xf0c890, 0xb5573c);
37
+ paint.addChild(houses);
38
+ const ground = new FillGradient({ type: "linear", start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, colorStops: [{ offset: 0, color: "#e8b27a" }, { offset: 1, color: "#c98656" }] });
39
+ paint.addChild(new Graphics().rect(0, HORIZON, W, H - HORIZON).fill(ground));
40
+ // Paving joints drawn toward the vanishing point: the 3D ground plane must agree with them.
41
+ const joints = new Graphics();
42
+ for (let i = -14; i <= 14; i++) joints.moveTo(W / 2 + i * 34, HORIZON).lineTo(W / 2 + i * 260, H);
43
+ for (let d = 1.5; d < 40; d *= 1.32) { const y = HORIZON + (H / 2 / Math.tan((FOV / 2) * Math.PI / 180)) * EYE / d; joints.moveTo(0, y).lineTo(W, y); }
44
+ joints.stroke({ color: 0xa8683f, alpha: 0.35, width: 2 });
45
+ paint.addChild(joints);
46
+ return paint;
47
+ }
48
+
49
+ type Limb = { top: THREE.Group; lower: THREE.Group; end: THREE.Group };
50
+
51
+ /** A jointed wooden mannequin, 1.75 m tall, facing +Z like a generated model; each joint is a Group to rotate. */
52
+ function mannequin() {
53
+ const wood = new THREE.MeshStandardMaterial({ color: 0xd7a066, roughness: 0.48 });
54
+ const knob = new THREE.MeshStandardMaterial({ color: 0xa8713f, roughness: 0.4 });
55
+ const piece = (length: number, radius: number, down = true) => {
56
+ const mesh = new THREE.Mesh(new THREE.CapsuleGeometry(radius, Math.max(0.01, length - 2 * radius), 6, 18), wood);
57
+ mesh.position.y = down ? -length / 2 : length / 2;
58
+ mesh.castShadow = true;
59
+ return mesh;
60
+ };
61
+ const ball = (radius: number) => { const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 20, 14), knob); mesh.castShadow = true; return mesh; };
62
+ const joint = (parent: THREE.Object3D, x: number, y: number, z = 0) => { const group = new THREE.Group(); group.position.set(x, y, z); parent.add(group); return group; };
63
+ const root = new THREE.Group();
64
+ const hips = joint(root, 0, 0.95);
65
+ hips.add(new THREE.Mesh(new THREE.SphereGeometry(0.15, 22, 16).scale(1.2, 0.7, 0.85), wood));
66
+ const spine = joint(hips, 0, 0.05);
67
+ spine.add(piece(0.42, 0.13, false));
68
+ const chest = joint(spine, 0, 0.42);
69
+ chest.add(new THREE.Mesh(new THREE.SphereGeometry(0.17, 22, 16).scale(1.25, 0.8, 0.8), wood));
70
+ const neck = joint(chest, 0, 0.1);
71
+ neck.add(piece(0.1, 0.045, false));
72
+ const head = joint(neck, 0, 0.12);
73
+ head.add(new THREE.Mesh(new THREE.SphereGeometry(0.11, 24, 18).scale(0.88, 1.15, 0.95).translate(0, 0.1, 0.01), wood));
74
+ const limb = (parent: THREE.Object3D, x: number, y: number, upper: number, lower: number, radius: number, foot: boolean): Limb => {
75
+ const top = joint(parent, x, y);
76
+ top.add(ball(radius * 1.15), piece(upper, radius));
77
+ const knee = joint(top, 0, -upper);
78
+ knee.add(ball(radius * 0.95), piece(lower, radius * 0.85));
79
+ const end = joint(knee, 0, -lower);
80
+ end.add(foot ? Object.assign(new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.06, 0.24).translate(0, -0.02, 0.06), wood), { castShadow: true }) : Object.assign(new THREE.Mesh(new THREE.SphereGeometry(0.05, 16, 12).scale(0.8, 1.4, 0.6).translate(0, -0.05, 0), wood), { castShadow: true }));
81
+ return { top, lower: knee, end };
82
+ };
83
+ const legs = [limb(hips, -0.1, -0.03, 0.44, 0.42, 0.06, true), limb(hips, 0.1, -0.03, 0.44, 0.42, 0.06, true)];
84
+ const arms = [limb(chest, -0.22, 0.02, 0.3, 0.27, 0.045, false), limb(chest, 0.22, 0.02, 0.3, 0.27, 0.045, false)];
85
+ root.traverse((node) => { if ((node as THREE.Mesh).isMesh) node.castShadow = true; });
86
+ return { root, hips, spine, head, legs, arms };
87
+ }
88
+
89
+ const study: ThreeStudy = async (root, lines) => {
90
+ const stage = new ThreeStage({ width: W, height: H, supersample: 1.5 });
91
+ stage.renderer.toneMapping = THREE.ACESFilmicToneMapping;
92
+ const scene = new THREE.Scene();
93
+ scene.environment = stage.room();
94
+ scene.environmentIntensity = 0.3;
95
+ const camera = new THREE.PerspectiveCamera(FOV, W / H, 0.1, 80);
96
+ camera.position.set(0, EYE, 0);
97
+ // Lens shift instead of tilt: verticals stay vertical, as in the painting, and the horizon lands on HORIZON.
98
+ camera.updateProjectionMatrix();
99
+ camera.projectionMatrix.elements[9] = -(1 - 2 * HORIZON / H);
100
+ camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
101
+
102
+ const figure = mannequin();
103
+ const holder = new THREE.Group();
104
+ holder.add(figure.root);
105
+ // The painted sun sits back left: the shadow falls right, toward the camera.
106
+ const sun = new THREE.DirectionalLight(0xffcf94, 2.8);
107
+ sun.position.set(STOP.x - 7, 5, STOP.z - 8);
108
+ sun.target.position.copy(STOP);
109
+ sun.castShadow = true;
110
+ sun.shadow.mapSize.set(2048, 2048);
111
+ Object.assign(sun.shadow.camera, { left: -8, right: 8, top: 8, bottom: -8, near: 1, far: 30 });
112
+ sun.shadow.radius = 5;
113
+ sun.shadow.bias = -0.0005;
114
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(50, 50), new THREE.ShadowMaterial({ color: 0x4a2412, opacity: 0.55 }));
115
+ ground.rotation.x = -Math.PI / 2;
116
+ ground.receiveShadow = true;
117
+ scene.add(holder, sun, sun.target, ground, new THREE.HemisphereLight(0xffe0b0, 0xc98050, 1.6));
118
+
119
+ const caption = txt(lines[0], 46, { family: "heading", weight: "700", fill: 0xfff4e2, dropShadow: { color: 0x3a1a08, alpha: 0.6, blur: 8, distance: 2, angle: Math.PI / 2 } });
120
+ caption.anchor.set(0.5, 1);
121
+ caption.position.set(W / 2, H - 70);
122
+ const eyebrow = txt(lines[1], 30, { family: "mono", weight: "700", fill: 0x6b3a1c });
123
+ eyebrow.position.set(96, 84);
124
+ const beat = txt(lines[2], 30, { family: "heading", weight: "700", fill: 0x3b2414 });
125
+ const leader = new Graphics();
126
+ root.addChild(paintSquare(), stage.sprite, leader, beat, eyebrow, caption);
127
+ await stage.warm(scene, camera);
128
+
129
+ const direction = STOP.clone().sub(FROM).normalize();
130
+ const start = STOP.clone().addScaledVector(direction, -SPEED * ARRIVE);
131
+ const heading = Math.atan2(direction.x, direction.z);
132
+ const head = new THREE.Vector3();
133
+
134
+ return (t) => {
135
+ // Walk: legs and arms swing in opposition, knees bend in the swing, hips bob twice a cycle.
136
+ const phase = (t / CYCLE) * Math.PI * 2, walking = 1 - clamp01((t - ARRIVE + 0.2) / 0.4);
137
+ // Facing +Z, his right side is -X: arms[0] is the right arm, the one that waves.
138
+ const [left, right] = figure.legs, [armR, armL] = figure.arms;
139
+ for (const [leg, sign] of [[left, 1], [right, -1]] as const) {
140
+ const swing = Math.sin(phase) * sign;
141
+ leg.top.rotation.x = -SWING * swing * walking;
142
+ leg.lower.rotation.x = Math.max(0, Math.sin(phase * sign + 1.2)) * 0.7 * walking;
143
+ leg.end.rotation.x = -(leg.top.rotation.x + leg.lower.rotation.x) * 0.6;
144
+ }
145
+ figure.hips.position.y = 0.95 + Math.cos(phase * 2) * 0.02 * walking;
146
+ figure.spine.rotation.y = Math.sin(phase) * 0.08 * walking;
147
+ holder.position.lerpVectors(start, STOP, clamp01(t / ARRIVE));
148
+ // Turn to the camera, then wave: the right arm rises and the forearm rocks.
149
+ holder.rotation.y = lerp(heading, 0, easeInOut(win(t, ARRIVE - 0.1, 0.8)));
150
+ const wave = easeInOut(win(t, ARRIVE + 0.5, 0.5)) * (1 - easeInOut(win(t, 7.1, 0.6)));
151
+ armL.top.rotation.x = SWING * 1.1 * Math.sin(phase) * walking;
152
+ armL.top.rotation.z = 0.12;
153
+ armR.top.rotation.x = -SWING * 1.1 * Math.sin(phase) * walking;
154
+ armR.top.rotation.z = lerp(-0.12, -2.55, wave);
155
+ armR.lower.rotation.z = -lerp(0, 0.35 + 0.45 * Math.sin((t - ARRIVE) * Math.PI * 2 * 1.6), wave);
156
+ armL.lower.rotation.x = -0.25;
157
+ figure.head.rotation.z = Math.sin((t - ARRIVE) * 3) * 0.08 * wave;
158
+
159
+ // Text: the caption and eyebrow hold; the beat label follows his head through the shot.
160
+ show(caption, inOut(t, 0.4, 7.6));
161
+ show(eyebrow, inOut(t, 0.2, 7.6));
162
+ holder.updateMatrixWorld(true);
163
+ figure.head.getWorldPosition(head).y += 0.35;
164
+ const p = stage.project(head, camera), label = inOut(t, ARRIVE + 0.4, 7.6);
165
+ leader.clear().moveTo(p.x, p.y).lineTo(p.x + 70, p.y - 70).lineTo(p.x + 110, p.y - 70).stroke({ color: 0x3b2414, width: 3 }).circle(p.x, p.y, 6).fill(0x3b2414);
166
+ show(leader, label);
167
+ beat.position.set(p.x + 120, p.y - 90);
168
+ show(beat, label);
169
+ stage.draw(scene, camera);
170
+ };
171
+ };
172
+ export default study;