craft-library 0.1.2 → 0.2.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.
@@ -0,0 +1,593 @@
1
+ // Iridescent card · Craft Library, by Sara Lobo
2
+ // A metal credit card in 3D: brushed iridescent plate, a fine struck frame along the edge, a chip and the Visa mark.
3
+ // It arrives with a slow half turn, from its back to its face, and a tap turns it over to show the numbers.
4
+ //
5
+ // import { mountCard } from './card.js';
6
+ // const card = mountCard(document.querySelector('#wallet')); // fills the element, card centred
7
+ // card.turn(); card.show('back'); card.play(); card.destroy();
8
+ //
9
+ // Needs three.js 0.169 (imported from jsDelivr below) and the card-visa.png file next to this one.
10
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js';
11
+
12
+ const DEFAULTS = {
13
+ name: 'Sara Lobo', number: ['4532', '8890', '1274', '3306'], expiry: '08/30', cvv: '412', // fails Luhn on purpose
14
+ logo: new URL('card-visa.png', import.meta.url).href, // the official Visa mark (Wikimedia Commons, public domain)
15
+ size: 0.72, // the card's height as a share of the element's height (it also keeps 12% clear at the sides)
16
+ entrance: true, // the half turn when it mounts
17
+ interactive: true, // tap, drag and keys turn it over
18
+ transparent: true, // the page shows around the card; false paints the ground black
19
+ label: 'Cartão de crédito. Pressione para virar e ver o verso.',
20
+ manual: false, // no loop of its own: call frame(ms) to draw (used to film it)
21
+ };
22
+
23
+ // ─── Geometry: an ID-1 card (85.6 × 53.98 mm, r 3.18 mm), stood upright ──────
24
+ const H = 3.4, W = H * 53.98 / 85.6, R = H * 3.18 / 85.6, T = 0.056;
25
+ const BEVEL = 0.016; // a fully rounded rim, so the edge catches a line of silver
26
+ // The relief: a slim frame along each face's edge, a hair higher than the plate, ending in a fine chamfer.
27
+ const FRAME = { inset: 0.05, chamfer: 0.007, height: 0.0035 };
28
+ const REST_X = 0.05, REST_Y = -0.26; // a three-quarter pose: the right edge in view
29
+ const CW = W - 2 * BEVEL, CH = H - 2 * BEVEL;
30
+
31
+ // ─── Motion ─────────────────────────────────────────────────────────────────
32
+ class Spring {
33
+ constructor(value = 0, { stiffness = 220, zeta = 0.7 } = {}) {
34
+ this.x = value; this.v = 0; this.target = value;
35
+ this.k = stiffness; this.c = 2 * zeta * Math.sqrt(stiffness);
36
+ }
37
+ step(dt) {
38
+ const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
39
+ for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.target) - this.c * this.v) * h; this.x += this.v * h; }
40
+ return this.x;
41
+ }
42
+ }
43
+ // The entrance: each channel eases from its offset to the rest pose over its own time, and both curves end with
44
+ // no speed and no acceleration, so the card comes to rest without a step.
45
+ const EASE = {
46
+ out: (s) => 1 - (1 - s) ** 3, // already moving: it fades in as it comes forward
47
+ glide: (s) => s * s * (6 - 8 * s + 3 * s * s), // from rest, fastest at a third, then a long soft slowdown
48
+ };
49
+ const ENTRANCE = {
50
+ fade: { from: 1, dur: 0.8 }, // out of the dark
51
+ z: { from: -1.6, dur: 1.6 }, // from just behind its place
52
+ spin: { from: -Math.PI, dur: 1.6, ease: 'glide' }, // showing its back, a slow half turn to the face
53
+ };
54
+ const settle = (t, { from, dur, ease = 'out' }) => from * (1 - EASE[ease](Math.min(1, Math.max(0, t / dur))));
55
+ const ENTRANCE_END = 1.6;
56
+
57
+ // ─── The light on the plate ─────────────────────────────────────────────────
58
+ // A lilac pool in the top-left corner cut by a soft diagonal, a ribbon of teal green just under that edge, royal
59
+ // blue down the right, sinking to ink at the foot. u runs left → right, v top → bottom.
60
+ const ss = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
61
+ const mix3 = (a, b, t) => a.map((x, i) => x + (b[i] - x) * t);
62
+ const LILAC = [152, 128, 204], PERIWINKLE = [108, 120, 198], TEAL = [42, 152, 138], DEEP = [20, 38, 104],
63
+ ROYAL = [50, 62, 158], ROYAL_D = [24, 30, 100], DUSK = [12, 15, 54], INK = [3, 5, 14];
64
+ function lightField(u, v) {
65
+ const d = v - (0.26 + 0.76 * u);
66
+ const lit = 1 - ss(-0.12, 0.18, d);
67
+ const dim = (1 - 0.25 * ss(0.15, 0.6, u)) * (1 - 0.4 * ss(0.35, 0.85, v)) * (1 - 0.1 * ss(0.14, 0, v));
68
+ const upper = mix3(LILAC, PERIWINKLE, Math.min(1, ss(0.1, 0.5, u) * 0.8 + ss(0.25, 0.7, v) * 0.5)).map((x) => x * dim);
69
+ const lower = mix3(mix3(TEAL, DEEP, ss(0.04, 0.42, d)), INK, ss(0.3, 0.6, d));
70
+ let right = mix3(ROYAL, ROYAL_D, ss(0.65, 1.0, u));
71
+ right = mix3(mix3(right, DUSK, ss(0.6, 0.82, v)), INK, ss(0.8, 0.96, v));
72
+ const foot = 1 - 0.86 * ss(0.6, 0.94, v);
73
+ return mix3(mix3(lower, upper, lit), right, ss(0.42, 0.86, u)).map((x) => x * foot);
74
+ }
75
+ // Painted wider than the card (−0.35…1.35) so the light can slide when the card turns.
76
+ const FIELD_PAD = 0.35, SHIFT_GAIN = 0.8;
77
+ const TW = 1024, TH = Math.round(TW * H / W), U = TW / 100; // print canvas, and 1% of the card's width
78
+ const JOST = '"Jost", "Futura", ui-sans-serif, system-ui, sans-serif';
79
+ const INK_PRINT = 'rgba(242,240,250,0.94)', MUTED_PRINT = 'rgba(222,220,240,0.5)';
80
+
81
+ function rng(seed) { return () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646; }
82
+ function canvas2d(w = TW, h = TH) {
83
+ const c = document.createElement('canvas'); c.width = w; c.height = h;
84
+ return [c, c.getContext('2d')];
85
+ }
86
+ function fieldCanvas() {
87
+ const fw = 160, fh = Math.round(fw * H / W), [c, g] = canvas2d(fw, fh), img = g.createImageData(fw, fh);
88
+ for (let j = 0; j < fh; j++) for (let i = 0; i < fw; i++) {
89
+ const u = -FIELD_PAD + (1 + 2 * FIELD_PAD) * (i + 0.5) / fw, v = -FIELD_PAD + (1 + 2 * FIELD_PAD) * (j + 0.5) / fh;
90
+ const [r, gg, b] = lightField(u, v), k = (j * fw + i) * 4;
91
+ img.data[k] = r; img.data[k + 1] = gg; img.data[k + 2] = b; img.data[k + 3] = 255;
92
+ }
93
+ g.putImageData(img, 0, 0);
94
+ return c;
95
+ }
96
+ // The brushed grooves: thin bright lines on strips of slightly different shade, constant down the card.
97
+ // R holds the line, G the shade of the strip it sits on.
98
+ function grooveCanvas(seed) {
99
+ const r = rng(seed), marks = [];
100
+ for (let x = 6 + r() * 10; x < TW; x += 15 + r() * 20) {
101
+ marks.push([x, 0.9 + r() * 1.4, 0.35 + r() * 0.65]);
102
+ if (r() < 0.22) marks.push([x + 4 + r() * 3, 0.8 + r(), 0.25 + r() * 0.4]);
103
+ }
104
+ const [c, g] = canvas2d(TW, 1), img = g.createImageData(TW, 1);
105
+ let m = 0, shade = 0;
106
+ for (let i = 0; i < TW; i++) {
107
+ if (m < marks.length && i >= marks[m][0]) { shade = (r() - 0.5) * 0.12; m++; }
108
+ let line = 0;
109
+ for (const [mx, w, s] of marks) { const d = (i - mx) / w; if (d > -4 && d < 4) line += Math.exp(-d * d) * s; }
110
+ img.data[i * 4] = Math.min(1, line) * 255; img.data[i * 4 + 1] = (0.5 + shade * 4) * 255; img.data[i * 4 + 3] = 255;
111
+ }
112
+ g.putImageData(img, 0, 0);
113
+ return { canvas: c, data: img.data };
114
+ }
115
+ // Roughness in G, metalness in B (three.js reads them from these channels).
116
+ function paintSurface(g, grooves, roughBase, metal) {
117
+ for (let x = 0; x < TW; x++) {
118
+ g.fillStyle = `rgb(0,${Math.round(255 * (roughBase - grooves[x * 4] / 255 * 0.16))},${Math.round(metal * 255)})`;
119
+ g.fillRect(x, 0, 1, TH);
120
+ }
121
+ }
122
+ // Print goes on a transparent layer over the plate (a) and into the surface map (b), where it is smoother and more
123
+ // metallic than the brushed plate, so it glints as the card turns.
124
+ function printer(a, b) {
125
+ return (str, x, y, { size, weight = 300, tracking = 0, fill, align = 'left' }) => {
126
+ for (const [g, style] of [[a, fill], [b, 'rgb(0,42,235)']]) {
127
+ g.font = `${weight} ${size * U}px ${JOST}`;
128
+ g.letterSpacing = `${tracking * size * U}px`; // tracking in em
129
+ g.textAlign = align;
130
+ g.fillStyle = style;
131
+ g.fillText(str, x, y);
132
+ }
133
+ };
134
+ }
135
+ function tinted(img, w, h, fill) {
136
+ const [c, g] = canvas2d(Math.round(w), Math.round(h));
137
+ g.drawImage(img, 0, 0, c.width, c.height);
138
+ g.globalCompositeOperation = 'source-in';
139
+ g.fillStyle = fill; g.fillRect(0, 0, c.width, c.height);
140
+ return c;
141
+ }
142
+ function roundedRect(w, h, r) {
143
+ const s = new THREE.Shape(), x = -w / 2, y = -h / 2;
144
+ s.moveTo(x + r, y);
145
+ s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r);
146
+ s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
147
+ s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r);
148
+ s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y);
149
+ return s;
150
+ }
151
+ function chipCanvas() {
152
+ const [c, g] = canvas2d(256, 320);
153
+ const grd = g.createLinearGradient(0, 0, 256, 320);
154
+ grd.addColorStop(0, '#d4d3d6'); grd.addColorStop(0.55, '#a7a6ad'); grd.addColorStop(1, '#bebdc3');
155
+ g.fillStyle = grd; g.fillRect(0, 0, 256, 320);
156
+ g.strokeStyle = 'rgba(78,78,86,0.7)'; g.lineWidth = 4; g.lineCap = 'round'; g.lineJoin = 'round';
157
+ g.beginPath();
158
+ g.moveTo(128, 0); g.lineTo(128, 92); // centre spine
159
+ g.moveTo(128, 228); g.lineTo(128, 320);
160
+ g.roundRect(84, 92, 88, 136, 18); // the die pad
161
+ g.moveTo(0, 112); g.lineTo(84, 112); g.moveTo(172, 112); g.lineTo(256, 112);
162
+ g.moveTo(0, 208); g.lineTo(84, 208); g.moveTo(172, 208); g.lineTo(256, 208);
163
+ g.moveTo(84, 160); g.lineTo(40, 160); g.quadraticCurveTo(30, 160, 30, 170); g.lineTo(30, 208);
164
+ g.moveTo(172, 160); g.lineTo(226, 160); g.quadraticCurveTo(236, 160, 236, 150); g.lineTo(236, 112);
165
+ g.stroke();
166
+ return c;
167
+ }
168
+ // The typeface of the print: Jost, from Google Fonts, added to the page once.
169
+ function loadJost() {
170
+ if (!document.querySelector('link[data-craft-jost]')) {
171
+ const l = document.createElement('link');
172
+ l.rel = 'stylesheet'; l.dataset.craftJost = '';
173
+ l.href = 'https://fonts.googleapis.com/css2?family=Jost:wght@300;400&display=swap';
174
+ document.head.appendChild(l);
175
+ }
176
+ return Promise.race([
177
+ Promise.all(['300 40px', '400 40px'].map((f) => document.fonts.load(`${f} "Jost"`))),
178
+ new Promise((r) => setTimeout(r, 3000)),
179
+ ]).catch(() => {});
180
+ }
181
+
182
+ export function mountCard(container, options = {}) {
183
+ const o = { ...DEFAULTS, ...options };
184
+ const reduceMq = matchMedia('(prefers-reduced-motion: reduce)');
185
+ let reduce = reduceMq.matches;
186
+ const onReduce = (e) => { reduce = e.matches; };
187
+ reduceMq.addEventListener('change', onReduce);
188
+
189
+ // ─── Renderer ─────────────────────────────────────────────────────────────
190
+ const host = document.createElement('div');
191
+ host.className = 'craft-card';
192
+ host.style.cssText = 'position:absolute;inset:0;outline:none;touch-action:none;-webkit-tap-highlight-color:transparent';
193
+ if (getComputedStyle(container).position === 'static') container.style.position = 'relative';
194
+ if (o.interactive) { host.setAttribute('role', 'button'); host.tabIndex = 0; host.setAttribute('aria-label', o.label); }
195
+ container.appendChild(host);
196
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: o.transparent, powerPreference: 'high-performance', preserveDrawingBuffer: o.manual });
197
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
198
+ renderer.setClearColor(0x000000, o.transparent ? 0 : 1);
199
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
200
+ // No tone mapping: Neutral's toe pulls the dark violets toward saturated blue.
201
+ renderer.toneMapping = THREE.NoToneMapping;
202
+ const canvas = renderer.domElement;
203
+ canvas.style.cssText = 'display:block;width:100%;height:100%;transition:opacity 160ms ease';
204
+ host.appendChild(canvas);
205
+ const scene = new THREE.Scene();
206
+ const camera = new THREE.PerspectiveCamera(26, 1, 0.1, 100);
207
+ const aniso = renderer.capabilities.getMaxAnisotropy();
208
+ const disposables = [];
209
+ const keep = (x) => { disposables.push(x); return x; };
210
+
211
+ // ─── Studio ───────────────────────────────────────────────────────────────
212
+ // The colour of the plate comes from its own light field, so the sky stays black where the face looks at rest.
213
+ // The panels show when the card turns, and give the rim its line of silver.
214
+ function bake(env) {
215
+ const pmrem = new THREE.PMREMGenerator(renderer);
216
+ const rt = pmrem.fromScene(env, 0.02);
217
+ pmrem.dispose();
218
+ env.traverse((n) => { if (n.isMesh) { n.geometry.dispose(); n.material.dispose(); } });
219
+ return keep(rt).texture;
220
+ }
221
+ function panels(env, list) {
222
+ for (const [w, h, intensity, tint, pos] of list) {
223
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(tint).multiplyScalar(intensity), side: THREE.DoubleSide }));
224
+ m.position.set(...pos); m.lookAt(0, 0, 0);
225
+ env.add(m);
226
+ }
227
+ return env;
228
+ }
229
+ scene.environment = bake(panels(new THREE.Scene(), [
230
+ [4, 3, 1.8, 0xffffff, [-5, 5, 4]], // key, top left in front
231
+ [7, 9, 1.4, 0xf2f2ff, [6, 0.5, -1.5]], // right side: the silver of the edge
232
+ [8, 2.5, 0.9, 0xffffff, [0, 6, 1]], // top rim
233
+ [3, 6, 0.3, 0xdfe2ff, [-7, 0, -1]], // dim left
234
+ ]));
235
+ // The edge gets a brighter studio of its own, with an aqua and a violet strip its metal picks up as it turns.
236
+ const rimScene = new THREE.Scene();
237
+ rimScene.add(new THREE.Mesh(new THREE.SphereGeometry(20, 32, 16), new THREE.ShaderMaterial({
238
+ side: THREE.BackSide, depthWrite: false,
239
+ vertexShader: 'varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.); }',
240
+ fragmentShader: 'varying vec3 vP; void main(){ gl_FragColor = vec4(mix(vec3(.012,.012,.02), vec3(.07,.07,.09), vP.y * .5 + .5), 1.); }',
241
+ })));
242
+ const rimEnv = bake(panels(rimScene, [
243
+ [4, 4, 0.5, 0xf0eeff, [-4, 1.2, 4.6]], // front fill, brighter toward the top left
244
+ [4, 4, 0.16, 0xc8d4ff, [-1.2, -2.6, 5.2]], // and fading toward the bottom
245
+ [4, 3, 2.4, 0xffffff, [-5, 5, 4]], // key
246
+ [7, 9, 1.6, 0xeef8ff, [6, 0.5, -1.5]], // right side
247
+ [8, 2.5, 1.2, 0xffffff, [0, 6, 1]], // top
248
+ [5, 1.2, 1.1, 0x6ff0dc, [3, -3.5, 4]], // aqua
249
+ [5, 1.2, 1.0, 0x9a78ff, [-5, -3, 1]], // violet
250
+ ]));
251
+
252
+ // ─── Textures, painted once ───────────────────────────────────────────────
253
+ function texture(c, srgb) {
254
+ const t = keep(new THREE.CanvasTexture(c));
255
+ t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
256
+ t.anisotropy = aniso;
257
+ return t;
258
+ }
259
+ const field = texture(fieldCanvas(), true);
260
+ const frontG = grooveCanvas(7), backG = grooveCanvas(31);
261
+ const frontGrooves = texture(frontG.canvas, false), backGrooves = texture(backG.canvas, false);
262
+ let logo = null;
263
+ const [fc, fg] = canvas2d(), [fsC, fsG] = canvas2d();
264
+ function paintFront() {
265
+ fg.clearRect(0, 0, TW, TH);
266
+ paintSurface(fsG, frontG.data, 0.32, 0.6);
267
+ if (!logo) return;
268
+ const w = 0.24 * TW, h = w * logo.naturalHeight / logo.naturalWidth;
269
+ const x = 0.865 * TW - w, y = 0.918 * TH - h;
270
+ fg.drawImage(tinted(logo, w, h, 'rgba(246,246,252,0.97)'), x, y);
271
+ fsG.drawImage(tinted(logo, w, h, 'rgb(0,42,235)'), x, y); // smooth and metallic, so it glints
272
+ }
273
+ // The back: a slim glossy stripe, then one column of small type with room around it.
274
+ const stripe = { x: 0.075, w: 0.12 };
275
+ const [bc, bg] = canvas2d(), [bsC, bsG] = canvas2d();
276
+ function paintBack() {
277
+ bg.clearRect(0, 0, TW, TH);
278
+ const band = bg.createLinearGradient(stripe.x * TW, 0, (stripe.x + stripe.w) * TW, 0);
279
+ band.addColorStop(0, 'rgba(7,7,13,0.97)'); band.addColorStop(0.5, 'rgba(18,18,30,0.97)'); band.addColorStop(1, 'rgba(7,7,13,0.97)');
280
+ bg.fillStyle = band; bg.fillRect(stripe.x * TW, 0, stripe.w * TW, TH);
281
+ paintSurface(bsG, backG.data, 0.34, 0.6);
282
+ bsG.fillStyle = 'rgb(0,40,40)'; bsG.fillRect(stripe.x * TW, 0, stripe.w * TW, TH);
283
+ const print = printer(bg, bsG), x0 = (stripe.x + stripe.w + 0.085) * TW, col2 = x0 + 21 * U;
284
+ print(o.name.toUpperCase(), x0, 0.108 * TH, { size: 2.9, weight: 400, tracking: 0.24, fill: INK_PRINT });
285
+ const yN = 0.69 * TH;
286
+ print(`${o.number[0]} ${o.number[1]}`, x0, yN, { size: 5.2, tracking: 0.12, fill: INK_PRINT });
287
+ print(`${o.number[2]} ${o.number[3]}`, x0, yN + 7.6 * U, { size: 5.2, tracking: 0.12, fill: INK_PRINT });
288
+ const yL = 0.835 * TH;
289
+ print('VALID THRU', x0, yL, { size: 2.1, weight: 400, tracking: 0.2, fill: MUTED_PRINT });
290
+ print('CVV', col2, yL, { size: 2.1, weight: 400, tracking: 0.2, fill: MUTED_PRINT });
291
+ print(o.expiry, x0, yL + 4.9 * U, { size: 3.4, tracking: 0.12, fill: INK_PRINT });
292
+ print(o.cvv, col2, yL + 4.9 * U, { size: 3.4, tracking: 0.12, fill: INK_PRINT });
293
+ }
294
+ paintFront(); paintBack();
295
+ const frontDetail = texture(fc, true), frontSurf = texture(fsC, false);
296
+ const backDetail = texture(bc, true), backSurf = texture(bsC, false);
297
+ frontDetail.premultiplyAlpha = backDetail.premultiplyAlpha = true; // clean type edges in the mipmaps
298
+ // The back is seen through the card's −z side, so its u runs the other way.
299
+ for (const t of [backDetail, backSurf]) { t.wrapS = THREE.RepeatWrapping; t.repeat.x = -1; t.offset.x = 1; }
300
+ const repaint = () => { paintFront(); paintBack(); for (const t of [frontDetail, frontSurf, backDetail, backSurf]) t.needsUpdate = true; };
301
+ const logoReady = new Promise((resolve) => {
302
+ const img = new Image();
303
+ img.crossOrigin = 'anonymous';
304
+ img.src = o.logo;
305
+ img.decode().then(() => { logo = img; }, () => {}).then(resolve);
306
+ });
307
+
308
+ // ─── Materials ────────────────────────────────────────────────────────────
309
+ // The entrance fade: every material ends scaled by the same amount (an opacity fade on a transparent canvas,
310
+ // a fade from black on an opaque one).
311
+ const fadeU = { value: 1 };
312
+ function withFade(m) {
313
+ const own = m.onBeforeCompile;
314
+ m.onBeforeCompile = (sh, r) => {
315
+ own.call(m, sh, r);
316
+ sh.uniforms.uFade = fadeU;
317
+ sh.fragmentShader = sh.fragmentShader
318
+ .replace('#include <common>', '#include <common>\nuniform float uFade;')
319
+ .replace('#include <dithering_fragment>', '#include <dithering_fragment>\ngl_FragColor *= uFade;');
320
+ };
321
+ return keep(m);
322
+ }
323
+ // A physical metal whose colour is the light field, sampled with an offset that follows the face's angle, so
324
+ // turning the card slides the light across it. The grooves and the print stay put.
325
+ function plate(detail, surf, grooves) {
326
+ const shift = { value: new THREE.Vector2() };
327
+ const m = new THREE.MeshPhysicalMaterial({
328
+ map: detail, roughnessMap: surf, metalnessMap: surf,
329
+ roughness: 1, metalness: 1,
330
+ emissive: 0xffffff, emissiveIntensity: 1,
331
+ clearcoat: 0.5, clearcoatRoughness: 0.12,
332
+ iridescence: 0.25, iridescenceIOR: 1.4, iridescenceThicknessRange: [220, 520],
333
+ });
334
+ m.onBeforeCompile = (sh) => {
335
+ Object.assign(sh.uniforms, { uField: { value: field }, uGroove: { value: grooves }, uShift: shift });
336
+ sh.fragmentShader = sh.fragmentShader
337
+ .replace('#include <common>', `#include <common>
338
+ uniform sampler2D uField, uGroove;
339
+ uniform vec2 uShift;
340
+ // Height of the relief at a point of the face (card units, centred): the frame along the edge stands
341
+ // FRAME.height above the plate and steps down over the chamfer.
342
+ float frameHeight( vec2 p ) {
343
+ vec2 q = abs( p ) - vec2( ${(CW / 2 - (R - BEVEL)).toFixed(4)}, ${(CH / 2 - (R - BEVEL)).toFixed(4)} );
344
+ float inset = ${(R - BEVEL).toFixed(4)} - length( max( q, 0.0 ) ) - min( max( q.x, q.y ), 0.0 );
345
+ return ${FRAME.height.toFixed(4)} * ( 1.0 - smoothstep( ${(FRAME.inset - FRAME.chamfer).toFixed(4)}, ${(FRAME.inset + FRAME.chamfer).toFixed(4)}, inset ) );
346
+ }`)
347
+ .replace('#include <map_fragment>', `
348
+ vec4 detail = texture2D( map, vMapUv );
349
+ vec3 light = texture2D( uField, ( vMapUv + uShift + ${FIELD_PAD.toFixed(2)} ) / ${(1 + 2 * FIELD_PAD).toFixed(2)} ).rgb;
350
+ vec2 groove = texture2D( uGroove, vec2( vMapUv.x, 0.5 ) ).rg;
351
+ vec3 plateColor = light * ( 1.0 + ( groove.g - 0.5 ) * 0.5 ) + groove.r * 0.6 * ( light + 0.004 );
352
+ vec3 surfaceColor = plateColor * ( 1.0 - detail.a ) + detail.rgb;
353
+ // The relief: the frame's chamfer lit from the top left (the light leans as the card turns), so the inner
354
+ // walls facing it brighten and the others fall into shade, the way a struck edge reads.
355
+ vec2 cp = ( vMapUv - 0.5 ) * vec2( ${CW.toFixed(4)}, ${CH.toFixed(4)} );
356
+ const float reliefE = 0.0015;
357
+ vec2 slope = vec2( frameHeight( cp + vec2( reliefE, 0.0 ) ) - frameHeight( cp - vec2( reliefE, 0.0 ) ),
358
+ frameHeight( cp + vec2( 0.0, reliefE ) ) - frameHeight( cp - vec2( 0.0, reliefE ) ) ) / ( 2.0 * reliefE );
359
+ vec3 reliefN = normalize( vec3( -slope, 1.0 ) );
360
+ vec3 reliefL = normalize( vec3( -0.55 + uShift.x * 1.5, 0.65 + uShift.y * 1.5, 0.55 ) );
361
+ float relief = dot( reliefN, reliefL ) - reliefL.z;
362
+ surfaceColor = surfaceColor * ( 1.0 + relief * 1.4 ) + max( relief, 0.0 ) * vec3( 0.2, 0.22, 0.26 );
363
+ diffuseColor.rgb *= surfaceColor;
364
+ // The aqua beam: a band along the light's diagonal edge that runs ahead of the light when the card turns
365
+ // and lights up the grooves it crosses, the way brushed metal streaks.
366
+ vec2 gp = vMapUv + uShift * 2.4;
367
+ float gb = ( 1.0 - gp.y ) - ( 0.17 + 0.76 * gp.x );
368
+ float glint = exp( -gb * gb / 0.005 ) * ( 1.0 - smoothstep( 0.2, 0.62, gp.x ) ) * ( 0.15 + groove.r * 0.95 ) * ( 1.0 - detail.a );`)
369
+ .replace('#include <emissivemap_fragment>', 'totalEmissiveRadiance = totalEmissiveRadiance * surfaceColor + vec3( 0.72, 0.96, 1.0 ) * glint * 0.22;');
370
+ };
371
+ m.userData.shift = shift.value;
372
+ return withFade(m);
373
+ }
374
+ const frontMat = plate(frontDetail, frontSurf, frontGrooves), backMat = plate(backDetail, backSurf, backGrooves);
375
+ const edgeMat = withFade(new THREE.MeshStandardMaterial({ color: 0xc4c4d0, metalness: 1, roughness: 0.28, envMap: rimEnv }));
376
+
377
+ // ─── The card ─────────────────────────────────────────────────────────────
378
+ const capUV = {
379
+ generateTopUV(g, v, a, b, c) { return [a, b, c].map((i) => new THREE.Vector2(v[i * 3] / CW + 0.5, v[i * 3 + 1] / CH + 0.5)); },
380
+ generateSideWallUV() { return [0, 0, 0, 0].map(() => new THREE.Vector2()); },
381
+ };
382
+ const geo = keep(new THREE.ExtrudeGeometry(roundedRect(CW, CH, R - BEVEL), {
383
+ depth: T - 2 * BEVEL, bevelEnabled: true, bevelThickness: BEVEL, bevelSize: BEVEL, bevelSegments: 6,
384
+ curveSegments: 14, UVGenerator: capUV,
385
+ }));
386
+ geo.translate(0, 0, -(T - 2 * BEVEL) / 2);
387
+ { // split the lid group into back (first half) and front (second half); the sides stay on their own
388
+ const [lids, sides] = geo.groups, half = lids.count / 2;
389
+ geo.clearGroups();
390
+ geo.addGroup(lids.start, half, 0);
391
+ geo.addGroup(lids.start + half, half, 1);
392
+ geo.addGroup(sides.start, sides.count, 2);
393
+ }
394
+ const card = new THREE.Mesh(geo, [backMat, frontMat, edgeMat]);
395
+ // the chip, inlaid on the front
396
+ const chipW = W * 0.167, chipH = H * 0.128;
397
+ const chipTex = texture(chipCanvas(), true);
398
+ const chip = new THREE.Mesh(
399
+ keep(new THREE.ShapeGeometry(roundedRect(chipW, chipH, chipW * 0.12), 6)),
400
+ withFade(new THREE.MeshStandardMaterial({ map: chipTex, emissiveMap: chipTex, metalness: 0.9, roughness: 0.35, emissive: 0xffffff, emissiveIntensity: 0.55 })),
401
+ );
402
+ { const p = chip.geometry.attributes.position, uv = chip.geometry.attributes.uv;
403
+ for (let i = 0; i < p.count; i++) uv.setXY(i, p.getX(i) / chipW + 0.5, p.getY(i) / chipH + 0.5); }
404
+ chip.position.set(W * 0.05, H * 0.5 - H * 0.166, T / 2 + 0.0008);
405
+
406
+ const flipNode = new THREE.Group(); flipNode.add(card, chip);
407
+ const spinNode = new THREE.Group(); spinNode.add(flipNode); // the entrance's half turn
408
+ const tiltNode = new THREE.Group(); tiltNode.rotation.order = 'YXZ'; tiltNode.add(spinNode);
409
+ const root = new THREE.Group(); root.add(tiltNode);
410
+ scene.add(root);
411
+
412
+ // How far the light slides for a given turn of the face, measured from the rest pose.
413
+ const REST_N = new THREE.Vector3(0, 0, 1).applyEuler(new THREE.Euler(REST_X, REST_Y, 0, 'YXZ'));
414
+ const _n = new THREE.Vector3();
415
+ function slideLight(mat, side) {
416
+ _n.set(0, 0, side).transformDirection(flipNode.matrixWorld);
417
+ mat.userData.shift.set( // tanh eases into the edge of the painted field
418
+ FIELD_PAD * Math.tanh((_n.x - REST_N.x) * SHIFT_GAIN / FIELD_PAD),
419
+ FIELD_PAD * Math.tanh((_n.y - REST_N.y) * SHIFT_GAIN / FIELD_PAD),
420
+ );
421
+ }
422
+
423
+ // ─── Layout: the card at `size` of the element's height, centred ───────────
424
+ function fit() {
425
+ const w = Math.max(1, host.clientWidth), h = Math.max(1, host.clientHeight);
426
+ renderer.setSize(w, h, false);
427
+ camera.aspect = w / h;
428
+ const tan = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
429
+ const byH = H / o.size / 2 / tan, byW = W / 0.76 / 2 / tan / camera.aspect;
430
+ camera.position.set(0, 0, Math.max(byH, byW));
431
+ camera.lookAt(0, 0, 0);
432
+ camera.updateProjectionMatrix();
433
+ }
434
+ const ro = new ResizeObserver(fit); ro.observe(host);
435
+ fit();
436
+
437
+ // ─── Motion state ─────────────────────────────────────────────────────────
438
+ const flip = new Spring(0, { stiffness: 16, zeta: 1 }); // a heavy turn, no overshoot: a metal card
439
+ const tiltX = new Spring(0, { stiffness: 120, zeta: 0.9 }); // faces the pointer
440
+ const tiltY = new Spring(0, { stiffness: 120, zeta: 0.9 });
441
+ let entrance = null; // { t0 } while the half turn plays
442
+ let ready = false, arriving = false;
443
+
444
+ function play() {
445
+ flip.x = flip.target = 0; flip.v = 0;
446
+ entrance = { t0: null };
447
+ arriving = true;
448
+ if (reduce) { entrance = null; arriving = false; fadeIn(); }
449
+ }
450
+ function fadeIn() { canvas.style.opacity = '0'; requestAnimationFrame(() => { canvas.style.opacity = '1'; }); }
451
+ function turn(dir = 1) {
452
+ const target = (Math.round(flip.target / Math.PI) + dir) * Math.PI;
453
+ if (reduce) { // no travel: fade out, swap faces, fade in
454
+ canvas.style.opacity = '0';
455
+ setTimeout(() => { flip.x = flip.target = target; flip.v = 0; canvas.style.opacity = '1'; changed(); }, 170);
456
+ return;
457
+ }
458
+ flip.target = target;
459
+ changed();
460
+ }
461
+ function show(face) {
462
+ entrance = null; arriving = false;
463
+ flip.x = flip.target = face === 'back' ? Math.PI : 0; flip.v = 0;
464
+ changed();
465
+ }
466
+ const faceNow = () => (Math.round(flip.target / Math.PI) % 2 !== 0 ? 'back' : 'front');
467
+ let lastFace = 'front';
468
+ function changed() {
469
+ const f = faceNow();
470
+ if (f === lastFace) return;
471
+ lastFace = f;
472
+ host.dispatchEvent(new CustomEvent('cardturn', { bubbles: true, detail: { face: f } }));
473
+ }
474
+
475
+ // ─── Input ────────────────────────────────────────────────────────────────
476
+ const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();
477
+ function hitCard(e) {
478
+ const r = host.getBoundingClientRect();
479
+ ndc.set((e.clientX - r.left) / r.width * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
480
+ ray.setFromCamera(ndc, camera);
481
+ return ray.intersectObject(card, false)[0];
482
+ }
483
+ let drag = null;
484
+ const on = (el, type, fn) => { el.addEventListener(type, fn); return () => el.removeEventListener(type, fn); };
485
+ const off = [];
486
+ if (o.interactive) {
487
+ off.push(on(host, 'pointerdown', (e) => {
488
+ if (arriving || !hitCard(e)) return;
489
+ host.setPointerCapture(e.pointerId);
490
+ drag = { x0: e.clientX, x: e.clientX, t: e.timeStamp, vel: 0, moved: false, side: e.clientX };
491
+ }));
492
+ off.push(on(host, 'pointermove', (e) => {
493
+ if (e.pointerType === 'mouse' && !drag) {
494
+ const r = host.getBoundingClientRect();
495
+ tiltY.target = ((e.clientX - r.left) / r.width * 2 - 1) * 0.1; // ±6° and ±4°: the face follows the pointer
496
+ tiltX.target = ((e.clientY - r.top) / r.height * 2 - 1) * 0.07;
497
+ host.style.cursor = hitCard(e) ? 'pointer' : 'default';
498
+ }
499
+ if (!drag || reduce) return;
500
+ if (!drag.moved && Math.abs(e.clientX - drag.x0) < 6) return;
501
+ drag.moved = true;
502
+ const dx = e.clientX - drag.x, k = Math.PI / (host.getBoundingClientRect().width * 0.55);
503
+ const dts = Math.max(8, e.timeStamp - drag.t) / 1000;
504
+ flip.x += dx * k; flip.target = flip.x;
505
+ drag.vel = drag.vel * 0.3 + (dx * k / dts) * 0.7;
506
+ drag.x = e.clientX; drag.t = e.timeStamp;
507
+ }));
508
+ const release = (e) => {
509
+ if (!drag) return;
510
+ if (!drag.moved) { // pressing a side pushes it away
511
+ const r = host.getBoundingClientRect();
512
+ turn(drag.side > r.left + r.width / 2 ? 1 : -1);
513
+ } else {
514
+ const vel = e.timeStamp - drag.t > 90 ? 0 : drag.vel; // a hand that stopped carries no throw
515
+ flip.v = vel;
516
+ flip.target = Math.round((flip.x + vel * 0.16) / Math.PI) * Math.PI;
517
+ changed();
518
+ }
519
+ drag = null;
520
+ };
521
+ off.push(on(host, 'pointerup', release), on(host, 'pointercancel', release));
522
+ off.push(on(host, 'pointerleave', (e) => { if (e.pointerType === 'mouse') { tiltX.target = 0; tiltY.target = 0; } }));
523
+ off.push(on(host, 'keydown', (e) => {
524
+ if (arriving) return;
525
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); turn(1); }
526
+ if (e.key === 'ArrowRight') turn(1);
527
+ if (e.key === 'ArrowLeft') turn(-1);
528
+ }));
529
+ }
530
+
531
+ // ─── The frame ────────────────────────────────────────────────────────────
532
+ let prev = null;
533
+ function draw(now) {
534
+ const dt = prev === null ? 1 / 60 : Math.min(0.1, (now - prev) / 1000); prev = now;
535
+ let fade = 0, z = 0, spin = 0;
536
+ if (entrance) {
537
+ if (entrance.t0 === null && ready) entrance.t0 = now;
538
+ const t = entrance.t0 === null ? 0 : (now - entrance.t0) / 1000;
539
+ fade = settle(t, ENTRANCE.fade); z = settle(t, ENTRANCE.z); spin = settle(t, ENTRANCE.spin);
540
+ if (t > ENTRANCE_END - 0.05) arriving = false;
541
+ if (t >= ENTRANCE_END) entrance = null;
542
+ }
543
+ if (!drag) flip.step(Math.min(dt, 1 / 30));
544
+ tiltX.step(Math.min(dt, 1 / 30)); tiltY.step(Math.min(dt, 1 / 30));
545
+ const swayY = reduce ? 0 : Math.sin(now / 4300) * 0.06; // 27 s and 19.5 s cycles: it breathes, never spins
546
+ const swayX = reduce ? 0 : Math.sin(now / 3100 + 1.3) * 0.025;
547
+ const bob = reduce ? 0 : Math.sin(now / 2600) * 0.015;
548
+ root.position.set(0, bob, z);
549
+ spinNode.rotation.y = spin;
550
+ flipNode.rotation.y = flip.x;
551
+ tiltNode.rotation.y = REST_Y + tiltY.x + swayY;
552
+ tiltNode.rotation.x = REST_X + tiltX.x + swayX;
553
+ fadeU.value = 1 - fade;
554
+ root.updateMatrixWorld();
555
+ slideLight(frontMat, 1); slideLight(backMat, -1);
556
+ renderer.render(scene, camera);
557
+ }
558
+
559
+ // The card waits for its print (the logo and the typeface) and for the GPU, so nothing pops in as it arrives.
560
+ const whenReady = Promise.allSettled([logoReady, loadJost()]).then(() => {
561
+ repaint();
562
+ for (const t of [frontDetail, frontSurf, backDetail, backSurf]) renderer.initTexture(t);
563
+ renderer.compile(scene, camera);
564
+ ready = true;
565
+ });
566
+ if (o.entrance) play(); else { fadeU.value = 1; }
567
+
568
+ let raf = 0, visible = true, io = null;
569
+ if (!o.manual) {
570
+ const loop = (now) => {
571
+ if (!host.isConnected) { api.destroy(); return; } // taken off the page: let the GPU go
572
+ raf = requestAnimationFrame(loop);
573
+ if (visible && !document.hidden) draw(now); else prev = null;
574
+ };
575
+ raf = requestAnimationFrame(loop);
576
+ io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; }); io.observe(host);
577
+ }
578
+
579
+ const api = {
580
+ element: host,
581
+ ready: whenReady,
582
+ play, turn, show,
583
+ get face() { return faceNow(); },
584
+ frame: (ms) => draw(ms), // manual mode: draw the card at this time
585
+ destroy() {
586
+ cancelAnimationFrame(raf); ro.disconnect(); if (io) io.disconnect();
587
+ off.forEach((f) => f()); reduceMq.removeEventListener('change', onReduce);
588
+ disposables.forEach((d) => d.dispose && d.dispose());
589
+ renderer.dispose(); host.remove();
590
+ },
591
+ };
592
+ return api;
593
+ }