@docyrus/animaguru 0.27.0 → 0.28.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.
- package/README.md +21 -2
- package/dist/animaguru.js +697 -460
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +4 -1
- package/skills/animaguru-3d-models/SKILL.md +86 -0
- package/skills/animaguru-character-stage-3d/SKILL.md +35 -0
- package/skills/animaguru-character-stage-3d/references/recipe.md +26 -0
- package/skills/animaguru-exploded-teardown-3d/SKILL.md +38 -0
- package/skills/animaguru-exploded-teardown-3d/references/recipe.md +20 -0
- package/skills/animaguru-product-turntable-3d/SKILL.md +38 -0
- package/skills/animaguru-product-turntable-3d/references/recipe.md +27 -0
- package/skills/animaguru-quiz/SKILL.md +1 -1
- package/skills/animaguru-toy-diorama-3d/SKILL.md +33 -0
- package/skills/animaguru-toy-diorama-3d/references/recipe.md +26 -0
- package/skills/animaguru-video/SKILL.md +1 -1
- package/templates/video/AGENTS.md +1 -1
- package/templates/video/README.md +5 -1
- package/templates/video/package.json +3 -1
- package/templates/video/scripts/install-example.mjs +10 -6
- package/templates/video/shared/locales/en/style-studies.json +35 -0
- package/templates/video/shared/locales/en/timeline.json +1 -0
- package/templates/video/src/kit/index.ts +1 -0
- package/templates/video/src/kit/three-stage.ts +262 -0
- package/templates/video/src/resource-timeline.ts +1 -0
- package/templates/video/src/studies/three/character-stage-3d.ts +172 -0
- package/templates/video/src/studies/three/exploded-teardown-3d.ts +214 -0
- package/templates/video/src/studies/three/index.ts +16 -0
- package/templates/video/src/studies/three/product-turntable-3d.ts +160 -0
- package/templates/video/src/studies/three/toy-diorama-3d.ts +245 -0
- package/templates/video/src/studies/three-film.ts +42 -0
- package/templates/video/src/style.css +2 -1
- package/templates/video/src/timeline-model.ts +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { FillGradient, Graphics, type Text } from "pixi.js";
|
|
2
|
+
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
|
|
3
|
+
import { THREE, ThreeStage, clamp01, easeInOut, lerp, rng, show, txt, win } from "../../kit";
|
|
4
|
+
import type { ThreeStudy } from ".";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
3D Exploded Teardown study: an invented handheld console modelled in code lies on a studio-grey set while the
|
|
8
|
+
camera orbits; its parts lift apart along the assembly axis in order, hold, and close again. Each part keeps a
|
|
9
|
+
numbered callout that follows it through the turn (the battery is named), drawn in Pixi over the three.js stage.
|
|
10
|
+
Parts, dimensions and the console are illustrative. Replace them with the product's own CAD (exported to GLB)
|
|
11
|
+
for a real product. Every value is a function of seconds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const W = 3.3, D = 1.62, R = 0.4;
|
|
15
|
+
const INK = 0x1d2226, ACCENT = 0xe2552b;
|
|
16
|
+
|
|
17
|
+
function rounded(width: number, height: number, radius: number, cx = 0, cy = 0, path = new THREE.Shape()) {
|
|
18
|
+
const x = cx - width / 2, y = cy - height / 2, r = Math.min(radius, width / 2, height / 2);
|
|
19
|
+
path.moveTo(x + r, y);
|
|
20
|
+
path.lineTo(x + width - r, y); path.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
21
|
+
path.lineTo(x + width, y + height - r); path.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
22
|
+
path.lineTo(x + r, y + height); path.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
23
|
+
path.lineTo(x, y + r); path.quadraticCurveTo(x, y, x + r, y);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
function circle(x: number, y: number, r: number) { const p = new THREE.Path(); p.absarc(x, y, r, 0, Math.PI * 2, true); return p; }
|
|
27
|
+
function cross(cx: number, cy: number, arm: number, width: number) {
|
|
28
|
+
const s = new THREE.Shape(), w = width / 2;
|
|
29
|
+
([[-w, arm], [w, arm], [w, w], [arm, w], [arm, -w], [w, -w], [w, -arm], [-w, -arm], [-w, -w], [-arm, -w], [-arm, w], [-w, w]] as const)
|
|
30
|
+
.forEach(([x, y], i) => (i ? s.lineTo(cx + x, cy + y) : s.moveTo(cx + x, cy + y)));
|
|
31
|
+
s.closePath();
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
/** A plan Shape (x right, y toward the back) extruded upward from y = 0 with softened edges. */
|
|
35
|
+
function slab(shape: THREE.Shape, depth: number, bevel = 0.02) {
|
|
36
|
+
const geometry = new THREE.ExtrudeGeometry(shape, { depth: depth - bevel * 2, bevelEnabled: bevel > 0, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 4, curveSegments: 24 });
|
|
37
|
+
geometry.rotateX(-Math.PI / 2);
|
|
38
|
+
geometry.translate(0, bevel, 0);
|
|
39
|
+
return geometry;
|
|
40
|
+
}
|
|
41
|
+
function mesh(geometry: THREE.BufferGeometry, material: THREE.Material, x = 0, y = 0, z = 0) {
|
|
42
|
+
const m = new THREE.Mesh(geometry, material);
|
|
43
|
+
m.position.set(x, y, z);
|
|
44
|
+
m.castShadow = m.receiveShadow = true;
|
|
45
|
+
return m;
|
|
46
|
+
}
|
|
47
|
+
/** Seeded board artwork, so every render draws the same traces. */
|
|
48
|
+
function boardTexture() {
|
|
49
|
+
const canvas = document.createElement("canvas");
|
|
50
|
+
canvas.width = 1024; canvas.height = 512;
|
|
51
|
+
const g = canvas.getContext("2d")!, next = rng(81);
|
|
52
|
+
g.fillStyle = "#1b5a45"; g.fillRect(0, 0, 1024, 512);
|
|
53
|
+
g.lineCap = "round";
|
|
54
|
+
for (let i = 0; i < 90; i++) {
|
|
55
|
+
let x = next() * 1024, y = next() * 512;
|
|
56
|
+
g.strokeStyle = next() < 0.2 ? "rgba(214,176,92,.85)" : "rgba(64,140,104,.9)";
|
|
57
|
+
g.lineWidth = next() < 0.2 ? 4 : 2;
|
|
58
|
+
g.beginPath(); g.moveTo(x, y);
|
|
59
|
+
for (let s = 0; s < 3; s++) { const len = 30 + next() * 110, dir = Math.floor(next() * 8) * Math.PI / 4; x += Math.cos(dir) * len; y += Math.sin(dir) * len; g.lineTo(x, y); }
|
|
60
|
+
g.stroke();
|
|
61
|
+
g.fillStyle = "#d8b45e"; g.beginPath(); g.arc(x, y, 4, 0, Math.PI * 2); g.fill();
|
|
62
|
+
}
|
|
63
|
+
const map = new THREE.CanvasTexture(canvas);
|
|
64
|
+
map.colorSpace = THREE.SRGBColorSpace;
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Part = { group: THREE.Group; number: string; side: -1 | 1; lift: number; order: number; anchor: THREE.Vector3 };
|
|
69
|
+
|
|
70
|
+
const study: ThreeStudy = async (root, lines) => {
|
|
71
|
+
const stage = new ThreeStage({ width: 1920, height: 1080 });
|
|
72
|
+
const scene = new THREE.Scene();
|
|
73
|
+
scene.environment = stage.room();
|
|
74
|
+
scene.environmentIntensity = 0.9;
|
|
75
|
+
const camera = new THREE.PerspectiveCamera(28, 1920 / 1080, 0.1, 80);
|
|
76
|
+
|
|
77
|
+
const pbr = (color: number, roughness: number, extra: THREE.MeshPhysicalMaterialParameters = {}) => new THREE.MeshPhysicalMaterial({ color, roughness, ...extra });
|
|
78
|
+
const shell = pbr(0xeae6df, 0.48, { clearcoat: 0.25, clearcoatRoughness: 0.5 });
|
|
79
|
+
const shellDark = pbr(0x5d6369, 0.5, { clearcoat: 0.2, clearcoatRoughness: 0.55 });
|
|
80
|
+
const chip = pbr(0x1b1d20, 0.42);
|
|
81
|
+
const metal = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 1, roughness: 0.3 });
|
|
82
|
+
|
|
83
|
+
const parts: Part[] = [];
|
|
84
|
+
const part = (number: string, side: -1 | 1, lift: number, order: number, anchor: [number, number, number]) => {
|
|
85
|
+
const group = new THREE.Group();
|
|
86
|
+
parts.push({ group, number, side, lift, order, anchor: new THREE.Vector3(...anchor) });
|
|
87
|
+
return group;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const back = part("6", 1, -0.95, 0, [1.55, 0.24, 0.2]);
|
|
91
|
+
back.add(mesh(slab(rounded(W, D, R), 0.07, 0.03), shellDark));
|
|
92
|
+
const wall = rounded(W, D, R); wall.holes.push(rounded(W - 0.12, D - 0.12, R - 0.06));
|
|
93
|
+
back.add(mesh(slab(wall, 0.19, 0.012), shellDark, 0, 0.05));
|
|
94
|
+
|
|
95
|
+
const battery = part("5", 1, -0.42, 6, [0.62, 0.148, 0.26]);
|
|
96
|
+
battery.add(mesh(new RoundedBoxGeometry(1.5, 0.07, 0.8, 4, 0.03), new THREE.MeshStandardMaterial({ color: 0x3a63c9, metalness: 0.3, roughness: 0.45 }), 0, 0.11, 0.05));
|
|
97
|
+
|
|
98
|
+
const board = part("4", -1, 0.02, 5, [-1.3, 0.24, 0.48]);
|
|
99
|
+
board.add(mesh(new RoundedBoxGeometry(3.0, 0.035, 1.36, 2, 0.012), new THREE.MeshStandardMaterial({ map: boardTexture(), roughness: 0.55 }), 0, 0.22, 0));
|
|
100
|
+
for (const [x, z, size] of [[0.05, -0.08, 0.42], [0.5, -0.12, 0.26], [0.5, 0.28, 0.24], [-0.42, 0.3, 0.16]]) board.add(mesh(new RoundedBoxGeometry(size, 0.04, size, 2, 0.006), chip, x, 0.257, z));
|
|
101
|
+
|
|
102
|
+
const display = part("3", 1, 0.78, 4, [0.86, 0.336, 0.56]);
|
|
103
|
+
display.add(mesh(new RoundedBoxGeometry(1.86, 0.05, 1.28, 3, 0.02), metal, 0, 0.31, 0));
|
|
104
|
+
const panel = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 1.12), new THREE.MeshStandardMaterial({ color: 0x000000, emissive: 0xe9805f, emissiveIntensity: 0.9, roughness: 0.25 }));
|
|
105
|
+
panel.rotation.x = -Math.PI / 2; panel.position.y = 0.337;
|
|
106
|
+
display.add(panel);
|
|
107
|
+
|
|
108
|
+
const keys = part("2", -1, 0.84, 3, [-1.5, 0.5, 0]);
|
|
109
|
+
keys.add(mesh(slab(cross(-1.25, 0, 0.27, 0.17), 0.12, 0.025), shellDark, 0, 0.38));
|
|
110
|
+
const cap = new THREE.LatheGeometry([[0, 0], [0.115, 0], [0.12, 0.05], [0.112, 0.085], [0.07, 0.1], [0, 0.103]].map(([x, y]) => new THREE.Vector2(x, y)), 40);
|
|
111
|
+
keys.add(mesh(cap, pbr(0xe2552b, 0.35, { clearcoat: 0.6 }), 1.38, 0.38, -0.08), mesh(cap, pbr(0x2f6f86, 0.35, { clearcoat: 0.6 }), 1.06, 0.38, 0.24));
|
|
112
|
+
|
|
113
|
+
const front = part("1", 1, 1.55, 2, [1.52, 0.44, 0.42]);
|
|
114
|
+
const face = rounded(W, D, R);
|
|
115
|
+
face.holes.push(rounded(1.86, 1.28, 0.08), cross(-1.25, 0, 0.3, 0.2), circle(1.38, 0.08, 0.13), circle(1.06, -0.24, 0.13));
|
|
116
|
+
front.add(mesh(slab(face, 0.08, 0.03), shell, 0, 0.36));
|
|
117
|
+
const lip = rounded(W, D, R); lip.holes.push(rounded(W - 0.12, D - 0.12, R - 0.06));
|
|
118
|
+
front.add(mesh(slab(lip, 0.09, 0.012), shell, 0, 0.27));
|
|
119
|
+
|
|
120
|
+
const product = new THREE.Group();
|
|
121
|
+
for (const p of parts) product.add(p.group);
|
|
122
|
+
scene.add(product);
|
|
123
|
+
const key = new THREE.DirectionalLight(0xfff4e8, 2.4);
|
|
124
|
+
key.position.set(-3.5, 7, 4);
|
|
125
|
+
key.castShadow = true;
|
|
126
|
+
key.shadow.mapSize.set(2048, 2048);
|
|
127
|
+
Object.assign(key.shadow.camera, { left: -4, right: 4, top: 4, bottom: -4, near: 1, far: 20 });
|
|
128
|
+
key.shadow.radius = 5;
|
|
129
|
+
key.shadow.normalBias = 0.02;
|
|
130
|
+
const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.18 }));
|
|
131
|
+
ground.rotation.x = -Math.PI / 2;
|
|
132
|
+
ground.position.y = -2;
|
|
133
|
+
ground.receiveShadow = true;
|
|
134
|
+
scene.add(key, new THREE.DirectionalLight(0xdce8ff, 0.8).translateX(4).translateY(3).translateZ(-5), ground);
|
|
135
|
+
|
|
136
|
+
// 2D layers: the studio ground behind the transparent 3D, then leaders, numbered callouts and type on top.
|
|
137
|
+
const backdrop = new Graphics().rect(0, 0, 1920, 1080).fill(new FillGradient({ start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, colorStops: [{ offset: 0, color: "#e6e7e8" }, { offset: 1, color: "#b3b7bb" }] }));
|
|
138
|
+
const leaders = new Graphics();
|
|
139
|
+
const badges = parts.map((p) => {
|
|
140
|
+
const number = txt(p.number, 26, { family: "mono", weight: "700", fill: 0xffffff });
|
|
141
|
+
number.anchor.set(0.5);
|
|
142
|
+
return number;
|
|
143
|
+
});
|
|
144
|
+
const name = txt(lines[2], 34, { family: "heading", weight: "700", fill: INK });
|
|
145
|
+
const title = txt(lines[0], 60, { family: "heading", weight: "700", fill: INK });
|
|
146
|
+
title.position.set(110, 92);
|
|
147
|
+
const subtitle = txt(lines[1], 26, { family: "mono", weight: "400", fill: 0x5f666d });
|
|
148
|
+
subtitle.position.set(114, 178);
|
|
149
|
+
root.addChild(backdrop, stage.sprite, leaders, ...badges, name, title, subtitle);
|
|
150
|
+
|
|
151
|
+
const TRAVEL = 1.25, CLOSE = 6.45;
|
|
152
|
+
const start = (p: Part) => 0.8 + p.order * 0.2;
|
|
153
|
+
const amount = (p: Part, t: number) => easeInOut(win(t, start(p), TRAVEL)) * (1 - easeInOut(win(t, CLOSE + (6 - p.order) * 0.05, 0.9)));
|
|
154
|
+
const point = new THREE.Vector3();
|
|
155
|
+
await stage.warm(scene, camera);
|
|
156
|
+
|
|
157
|
+
return (t) => {
|
|
158
|
+
let open = 0;
|
|
159
|
+
for (const p of parts) { const f = amount(p, t); open = Math.max(open, f); p.group.position.y = p.lift * f; }
|
|
160
|
+
const k = easeInOut(t / 8);
|
|
161
|
+
const azimuth = lerp(-0.6, 0.48, k), elevation = lerp(0.62, 0.5, k), distance = lerp(8.2, 12.6, easeInOut(open));
|
|
162
|
+
const center = lerp(0.22, 0.4, open);
|
|
163
|
+
camera.position.set(Math.sin(azimuth) * Math.cos(elevation) * distance, center + Math.sin(elevation) * distance, Math.cos(azimuth) * Math.cos(elevation) * distance);
|
|
164
|
+
camera.lookAt(0, center, 0);
|
|
165
|
+
product.updateMatrixWorld(true);
|
|
166
|
+
show(title, clamp01(t / 0.5));
|
|
167
|
+
show(subtitle, clamp01((t - 0.3) / 0.5));
|
|
168
|
+
|
|
169
|
+
// Callouts sit just outside the stack's silhouette on their side, kept a badge apart in anchor order.
|
|
170
|
+
const box = new THREE.Box3().setFromObject(product);
|
|
171
|
+
let minX = Infinity, maxX = -Infinity;
|
|
172
|
+
for (let i = 0; i < 8; i++) {
|
|
173
|
+
point.set(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z);
|
|
174
|
+
const s = stage.project(point, camera);
|
|
175
|
+
minX = Math.min(minX, s.x); maxX = Math.max(maxX, s.x);
|
|
176
|
+
}
|
|
177
|
+
const placed = parts.map((p, i) => {
|
|
178
|
+
point.copy(p.anchor).applyMatrix4(p.group.matrixWorld);
|
|
179
|
+
const a = stage.project(point, camera);
|
|
180
|
+
return { p, a, y: a.y, badge: badges[i], alpha: clamp01((t - start(p) - TRAVEL * 0.7) / 0.4) * (1 - clamp01((t - CLOSE + 0.35) / 0.3)) };
|
|
181
|
+
});
|
|
182
|
+
for (const side of [-1, 1]) {
|
|
183
|
+
const column = placed.filter((l) => l.p.side === side).sort((a, b) => a.a.y - b.a.y);
|
|
184
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
185
|
+
for (let i = 1; i < column.length; i++) column[i].y = Math.max(column[i].y, column[i - 1].y + 86);
|
|
186
|
+
for (let i = column.length - 2; i >= 0; i--) column[i].y = Math.min(column[i].y, column[i + 1].y - 86);
|
|
187
|
+
for (const l of column) l.y = Math.min(Math.max(l.y, 260), 1010);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
leaders.clear();
|
|
191
|
+
show(name, 0);
|
|
192
|
+
for (const { p, a, y, badge, alpha } of placed) {
|
|
193
|
+
show(badge, alpha);
|
|
194
|
+
if (alpha <= 0.002) continue;
|
|
195
|
+
const x = p.side < 0 ? Math.min(minX - 70, a.x - 120) : Math.max(maxX + 70, a.x + 120);
|
|
196
|
+
leaders.moveTo(a.x, a.y).lineTo(x - p.side * 30, y).lineTo(x, y).stroke({ color: INK, width: 2.4, alpha: 0.8 * alpha });
|
|
197
|
+
leaders.circle(a.x, a.y, 8).fill({ color: 0xffffff, alpha }).stroke({ color: INK, width: 3, alpha });
|
|
198
|
+
leaders.circle(x + p.side * 26, y, 26).fill({ color: p.number === "5" ? ACCENT : INK, alpha });
|
|
199
|
+
badge.position.set(x + p.side * 26, y);
|
|
200
|
+
if (p.number === "5") placeName(name, x + p.side * 68, y, p.side, alpha);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stage.draw(scene, camera);
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The battery's name beside its callout, on the callout's side. */
|
|
208
|
+
function placeName(name: Text, x: number, y: number, side: -1 | 1, alpha: number) {
|
|
209
|
+
name.anchor.set(side < 0 ? 1 : 0, 0.5);
|
|
210
|
+
name.position.set(x, y);
|
|
211
|
+
show(name, alpha);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default study;
|