postext-folio 0.1.1 → 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.
Files changed (42) hide show
  1. package/README.md +59 -4
  2. package/dist/bookGeometry.d.ts +77 -0
  3. package/dist/bookGeometry.d.ts.map +1 -0
  4. package/dist/bookGeometry.js +204 -0
  5. package/dist/bookGeometry.js.map +1 -0
  6. package/dist/deskTextures.d.ts +24 -0
  7. package/dist/deskTextures.d.ts.map +1 -0
  8. package/dist/deskTextures.js +88 -0
  9. package/dist/deskTextures.js.map +1 -0
  10. package/dist/environments.d.ts +27 -0
  11. package/dist/environments.d.ts.map +1 -0
  12. package/dist/environments.js +90 -0
  13. package/dist/environments.js.map +1 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/pageFlip.d.ts +160 -26
  19. package/dist/pageFlip.d.ts.map +1 -1
  20. package/dist/pageFlip.js +1195 -342
  21. package/dist/pageFlip.js.map +1 -1
  22. package/dist/pageFlipFlat.d.ts +131 -0
  23. package/dist/pageFlipFlat.d.ts.map +1 -0
  24. package/dist/pageFlipFlat.js +870 -0
  25. package/dist/pageFlipFlat.js.map +1 -0
  26. package/dist/paper.d.ts +40 -0
  27. package/dist/paper.d.ts.map +1 -0
  28. package/dist/paper.js +65 -0
  29. package/dist/paper.js.map +1 -0
  30. package/dist/postext.d.ts +12 -2
  31. package/dist/postext.d.ts.map +1 -1
  32. package/dist/postext.js +167 -6
  33. package/dist/postext.js.map +1 -1
  34. package/dist/procedural.d.ts +35 -0
  35. package/dist/procedural.d.ts.map +1 -0
  36. package/dist/procedural.js +390 -0
  37. package/dist/procedural.js.map +1 -0
  38. package/dist/viewer.d.ts +41 -1
  39. package/dist/viewer.d.ts.map +1 -1
  40. package/dist/viewer.js +133 -16
  41. package/dist/viewer.js.map +1 -1
  42. package/package.json +3 -3
@@ -0,0 +1,870 @@
1
+ import { CustomBlending, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, LinearSRGBColorSpace, MaxEquation, Mesh, NearestFilter, NoColorSpace, OneFactor, OrthographicCamera, PerspectiveCamera, PlaneGeometry, Scene, ShaderMaterial, Texture, TextureLoader, Vector2, Vector3, Vector4, WebGLRenderer, WebGLRenderTarget, } from "three";
2
+ /** Columns and rows of a leaf's mesh: fine enough for a tight roll. */
3
+ const NX = 96;
4
+ const NY = 120;
5
+ /** Vertical field of view: low, so a page at rest maps 1:1 onto the DOM spread. */
6
+ const FOV = 26;
7
+ /** Radius of the roll at mid-turn, relative to the page's width. */
8
+ const ROLL = 0.11;
9
+ /** Milliseconds one leaf takes to turn. */
10
+ const DURATION = 1000;
11
+ /** A leaf follows the one before it once that one is this far through its turn. */
12
+ const GAP = 0.14;
13
+ /** Towards the light: from above, a little to the upper left. */
14
+ const LIGHT = new Vector3(-0.22, 0.3, 1).normalize();
15
+ const VERTEX = /* glsl */ `
16
+ varying vec2 vUv;
17
+ varying vec3 vNormal;
18
+ varying vec3 vPos;
19
+ void main() {
20
+ vUv = uv;
21
+ vNormal = normal;
22
+ vPos = position;
23
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
24
+ }
25
+ `;
26
+ // Shadows are cast for real: each frame the leaves in the air are drawn,
27
+ // seen from the light, into a height map (the highest paper over each
28
+ // point, drawn in `draw`). A surface is in shadow where paper lies between
29
+ // it and the light; the penumbra widens with the gap, as a real one does
30
+ // (a blocker search, then a filter of that width). Paper lifted nearby
31
+ // also hides part of the sky from it (ambient occlusion): the soft
32
+ // darkening along a roll and round a flap.
33
+ const SHADOW = /* glsl */ `
34
+ uniform sampler2D uShadowMap;
35
+ uniform vec4 uShadowBox;
36
+ uniform vec3 uLd;
37
+ uniform float uShadowOn;
38
+
39
+ float casterAt(vec2 s) {
40
+ vec2 uv = (s - uShadowBox.xy) / uShadowBox.zw;
41
+ if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) return -1e4;
42
+ return texture2D(uShadowMap, uv).r - 10.0;
43
+ }
44
+
45
+ // How much of the sky paper lifted nearby hides: 0 … 1.
46
+ float occlusionAt(vec3 p, vec2 s, float spin) {
47
+ float occ = 0.0;
48
+ for (int i = 0; i < 16; i++) {
49
+ float a = float(i) * 2.39996 + spin;
50
+ float r = sqrt((float(i) + 0.5) / 16.0) * 72.0;
51
+ float h = casterAt(s + r * vec2(cos(a), sin(a)));
52
+ occ += clamp((h - p.z - 2.5) / (r + 14.0), 0.0, 1.0);
53
+ }
54
+ return occ / 16.0;
55
+ }
56
+
57
+ // The light lost at p: 0 … about 0.6.
58
+ float shadowAt(vec3 p) {
59
+ if (uShadowOn < 0.5) return 0.0;
60
+ vec2 s = p.xy - uLd.xy / uLd.z * p.z;
61
+ float spin = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) * 6.2831;
62
+ float ambient = 0.34 * occlusionAt(p, s, spin);
63
+ const float bias = 2.5;
64
+ float sum = 0.0;
65
+ float found = 0.0;
66
+ for (int i = 0; i < 12; i++) {
67
+ float a = float(i) * 2.39996 + spin;
68
+ vec2 o = sqrt((float(i) + 0.5) / 12.0) * vec2(cos(a), sin(a));
69
+ float h = casterAt(s + o * 16.0);
70
+ if (h > p.z + bias) {
71
+ sum += h;
72
+ found += 1.0;
73
+ }
74
+ }
75
+ if (found < 0.5) return ambient;
76
+ float gap = sum / found - p.z;
77
+ float spread = clamp(gap * 0.3, 1.5, 40.0);
78
+ float hit = 0.0;
79
+ for (int i = 0; i < 16; i++) {
80
+ float a = float(i) * 2.39996 + spin;
81
+ vec2 o = sqrt((float(i) + 0.5) / 16.0) * vec2(cos(a), sin(a));
82
+ if (casterAt(s + o * spread) > p.z + bias) hit += 1.0;
83
+ }
84
+ // Paper high above casts a paler shadow (more light gets round it).
85
+ float direct = 0.4 * hit / 16.0 * (1.0 - 0.4 * smoothstep(30.0, 300.0, gap));
86
+ return min(0.6, 1.0 - (1.0 - direct) * (1.0 - ambient));
87
+ }
88
+ `;
89
+ // Colours are passed through untouched (textures and output both without
90
+ // colour management) so a page at rest matches the <img> pixel for pixel,
91
+ // spine gradient included (the CSS one, blended the way CSS blends it).
92
+ // Lighting never brightens: a page facing the reader is as the <img> is,
93
+ // turned from the light it darkens. On a mirrored stage (a right-bound
94
+ // book) the images are read back the right way round.
95
+ const FRAGMENT = /* glsl */ `
96
+ uniform sampler2D uFront;
97
+ uniform sampler2D uBack;
98
+ // 0 no page, 1 its image, 2 blank paper.
99
+ uniform float uHasFront;
100
+ uniform float uHasBack;
101
+ uniform vec3 uPaper;
102
+ uniform float uBlank;
103
+ uniform float uLit;
104
+ uniform float uMirror;
105
+ varying vec2 vUv;
106
+ varying vec3 vNormal;
107
+ varying vec3 vPos;
108
+ ${SHADOW}
109
+
110
+ float spine(float u, float a0, float a1) {
111
+ if (u < 0.03) return mix(a0, a1, u / 0.03);
112
+ if (u < 0.09) return mix(a1, 0.0, (u - 0.03) / 0.06);
113
+ return 0.0;
114
+ }
115
+
116
+ void main() {
117
+ bool front = gl_FrontFacing;
118
+ float has = front ? uHasFront : uHasBack;
119
+ vec3 col;
120
+ if (has < 0.5 && uBlank < 0.5) discard;
121
+ if (has < 0.5 || has > 1.5) {
122
+ col = uPaper;
123
+ } else {
124
+ float u = uMirror > 0.5 ? 1.0 - vUv.x : vUv.x;
125
+ col = front ? texture2D(uFront, vec2(u, vUv.y)).rgb : texture2D(uBack, vec2(1.0 - u, vUv.y)).rgb;
126
+ }
127
+ // uv.x is the distance from the spine on both faces.
128
+ col *= 1.0 - (front ? spine(vUv.x, 0.18, 0.05) : spine(vUv.x, 0.16, 0.04));
129
+
130
+ vec3 p = vPos;
131
+ if (uLit > 0.5) {
132
+ vec3 n = normalize(vNormal) * (front ? 1.0 : -1.0);
133
+ col *= mix(0.6, 1.0, clamp(dot(n, uLd) / uLd.z, 0.0, 1.0));
134
+ // Look the shadow up a little off the surface (no self-shadow acne).
135
+ p += n * 2.0;
136
+ }
137
+ col *= 1.0 - shadowAt(p);
138
+ gl_FragColor = vec4(col, 1.0);
139
+ }
140
+ `;
141
+ // The desk under the book: the pages' drop shadow (the CSS box-shadow of
142
+ // `.cb-lt-page`, a gaussian-blurred rectangle) and the leaves'.
143
+ const DESK = /* glsl */ `
144
+ uniform float uW;
145
+ uniform float uH;
146
+ uniform vec2 uPages;
147
+ varying vec2 vUv;
148
+ varying vec3 vNormal;
149
+ varying vec3 vPos;
150
+ ${SHADOW}
151
+
152
+ float erf(float x) {
153
+ float a = abs(x);
154
+ float t = 1.0 / (1.0 + 0.3275911 * a);
155
+ float y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-a * a);
156
+ return sign(x) * y;
157
+ }
158
+ float band(float x, float lo, float hi, float sigma) {
159
+ return 0.5 * (erf((x - lo) / (sigma * 1.4142)) - erf((x - hi) / (sigma * 1.4142)));
160
+ }
161
+ // box-shadow: 0 1px 1px .35, 0 16px 36px -8px .65 (CSS y runs down).
162
+ float page(vec2 p, float x0, float x1) {
163
+ float h = uH / 2.0;
164
+ float soft = 0.65 * band(p.x, x0 + 8.0, x1 - 8.0, 18.0) * band(p.y + 16.0, -h + 8.0, h - 8.0, 18.0);
165
+ float hard = 0.35 * band(p.x, x0, x1, 0.5) * band(p.y + 1.0, -h, h, 0.5);
166
+ return 1.0 - (1.0 - soft) * (1.0 - hard);
167
+ }
168
+
169
+ void main() {
170
+ vec2 p = vPos.xy;
171
+ float clear = 1.0;
172
+ if (uPages.x > 0.5) clear *= 1.0 - page(p, -uW, 0.0);
173
+ if (uPages.y > 0.5) clear *= 1.0 - page(p, 0.0, uW);
174
+ clear *= 1.0 - 1.3 * shadowAt(vPos);
175
+ gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - clear);
176
+ }
177
+ `;
178
+ // Draws the leaves as the light sees them: each point where its shadow
179
+ // falls on the desk plane, keeping the highest paper (max blending).
180
+ const CASTER_VERTEX = /* glsl */ `
181
+ uniform vec3 uLd;
182
+ varying float vZ;
183
+ void main() {
184
+ vZ = position.z;
185
+ vec2 s = position.xy - uLd.xy / uLd.z * position.z;
186
+ gl_Position = projectionMatrix * viewMatrix * vec4(s, 0.0, 1.0);
187
+ }
188
+ `;
189
+ const CASTER_FRAGMENT = /* glsl */ `
190
+ varying float vZ;
191
+ void main() {
192
+ gl_FragColor = vec4(vZ + 10.0, 0.0, 0.0, 1.0);
193
+ }
194
+ `;
195
+ /** The shadow uniforms every receiving material shares (same objects). */
196
+ const shadowUniforms = {
197
+ uShadowMap: { value: null },
198
+ uShadowBox: { value: new Vector4(0, 0, 1, 1) },
199
+ uLd: { value: LIGHT },
200
+ uShadowOn: { value: 0 },
201
+ };
202
+ /** The book's paper, for its blank pages (shared too). */
203
+ const paperUniform = { uPaper: { value: new Vector3(1, 1, 1) } };
204
+ function material(lit, mirror) {
205
+ return new ShaderMaterial({
206
+ vertexShader: VERTEX,
207
+ fragmentShader: FRAGMENT,
208
+ side: DoubleSide,
209
+ uniforms: {
210
+ uFront: { value: null },
211
+ uBack: { value: null },
212
+ uHasFront: { value: 0 },
213
+ uHasBack: { value: 0 },
214
+ uBlank: { value: lit ? 1 : 0 },
215
+ uLit: { value: lit ? 1 : 0 },
216
+ uMirror: { value: mirror ? 1 : 0 },
217
+ ...shadowUniforms,
218
+ ...paperUniform,
219
+ },
220
+ });
221
+ }
222
+ const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
223
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
224
+ const smooth = (a, b, x) => {
225
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
226
+ return t * t * (3 - 2 * t);
227
+ };
228
+ /**
229
+ * How a leaf folds when the point `G` of it has been carried to `P`: the
230
+ * paper stays flat up to a fold line, rolls round a cylinder of radius R
231
+ * and lies over itself beyond it, back up (the page-curl cylinder). The
232
+ * fold is square to the pull, placed so the point taken lands under the
233
+ * hand, and never crosses the spine the leaf is sewn to. R grows as the
234
+ * page lifts and shrinks to nothing as it lands on the other side.
235
+ */
236
+ function foldOf(G, P, W, H) {
237
+ const du = G.u - P.u;
238
+ const dv = G.v - P.v;
239
+ const D = Math.hypot(du, dv);
240
+ if (D < 0.5)
241
+ return null;
242
+ const n = { u: du / D, v: dv / D };
243
+ const q = progressFrom(G, P);
244
+ const R = ROLL * W * (0.3 + 0.7 * smooth(0, 0.2, q)) * (1 - smooth(0.62, 1, q)) * Math.min(1, D / (0.1 * W));
245
+ const roll = Math.PI * R;
246
+ let c = (D + roll * Math.min(1, roll > 0 ? D / roll : 0)) / 2;
247
+ for (const S of [-H / 2, H / 2])
248
+ c = Math.min(c, G.u * n.u + (G.v - S) * n.v);
249
+ if (c <= 0)
250
+ return null;
251
+ return { F: { u: G.u - n.u * c, v: G.v - n.v * c }, n, R };
252
+ }
253
+ /** How far through its turn a leaf is: the point taken, from where it
254
+ * was to its mirror image across the spine. */
255
+ function progressFrom(G, P) {
256
+ return Math.min(1, Math.max(0, (G.u - P.u) / (2 * G.u)));
257
+ }
258
+ /** The hand can carry the point taken no farther from the spine's ends
259
+ * than the paper reaches. */
260
+ function reach(G, P, H) {
261
+ let { u, v } = P;
262
+ for (let i = 0; i < 4; i++) {
263
+ for (const S of [-H / 2, H / 2]) {
264
+ const r = Math.hypot(G.u, G.v - S);
265
+ const d = Math.hypot(u, v - S);
266
+ if (d > r) {
267
+ u *= r / d;
268
+ v = S + ((v - S) * r) / d;
269
+ }
270
+ }
271
+ }
272
+ return { u, v };
273
+ }
274
+ /** Lays a leaf's mesh out for a fold (flat when there is none). */
275
+ function layLeaf(geometry, fold, forward, W, H, z0) {
276
+ const pos = geometry.attributes.position;
277
+ const sx = forward ? 1 : -1;
278
+ for (let iy = 0; iy <= NY; iy++) {
279
+ const v = (0.5 - iy / NY) * H;
280
+ for (let ix = 0; ix <= NX; ix++) {
281
+ const u = (ix / NX) * W;
282
+ let x = u;
283
+ let y = v;
284
+ let z = 0;
285
+ if (fold) {
286
+ const { F, n, R } = fold;
287
+ const d = (u - F.u) * n.u + (v - F.v) * n.v;
288
+ if (d > 0) {
289
+ let dd;
290
+ if (R < 1e-3)
291
+ dd = -d;
292
+ else if (d < Math.PI * R) {
293
+ dd = R * Math.sin(d / R);
294
+ z = R * (1 - Math.cos(d / R));
295
+ }
296
+ else {
297
+ dd = -(d - Math.PI * R);
298
+ z = 2 * R;
299
+ }
300
+ x = u - n.u * (d - dd);
301
+ y = v - n.v * (d - dd);
302
+ }
303
+ }
304
+ pos.setXYZ(iy * (NX + 1) + ix, sx * x, y, z + z0);
305
+ }
306
+ }
307
+ pos.needsUpdate = true;
308
+ geometry.computeVertexNormals();
309
+ }
310
+ /**
311
+ * Turns the pages of the light table's DOM spread. The book is a stack of
312
+ * leaves: leaf k has spread k's recto on its front and spread k + 1's verso
313
+ * on its back. `go(n)` sends every leaf before spread n to the left and
314
+ * the rest to the right; each turns on its own, following the one before
315
+ * it at a short remove, so several pages can be in the air at once. At
316
+ * rest the canvas is transparent and the <img> pages show; while leaves
317
+ * move it draws the spread, lit, with the lifted leaves' shadows.
318
+ *
319
+ * The book is modelled bound on the left. A right-bound one is the same
320
+ * book seen in a mirror (the DOM spread's `dir="rtl"`): the stage is
321
+ * mirrored, so the verso lies on the right and the leaves turn from left
322
+ * to right, while the page images, the pointer and the light are mirrored
323
+ * back to read as they are on the desk.
324
+ */
325
+ export class FlatPageFlipper {
326
+ canvas;
327
+ spread;
328
+ book;
329
+ onSettle;
330
+ onTarget;
331
+ renderer;
332
+ scene = new Scene();
333
+ camera = new PerspectiveCamera(FOV, 1, 1, 10000);
334
+ left;
335
+ right;
336
+ desk;
337
+ /** Everything on the desk, in the book's own coordinates (mirrored for a
338
+ * right-bound book). */
339
+ stage = new Group();
340
+ /** 1 for a left-bound book, −1 for a right-bound one. */
341
+ sign;
342
+ /** Towards the light, in the book's coordinates. */
343
+ light;
344
+ shadowScene = new Scene();
345
+ shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 1000);
346
+ shadowTarget = null;
347
+ casterMaterial = new ShaderMaterial({
348
+ vertexShader: CASTER_VERTEX,
349
+ fragmentShader: CASTER_FRAGMENT,
350
+ side: DoubleSide,
351
+ blending: CustomBlending,
352
+ blendEquation: MaxEquation,
353
+ blendEquationAlpha: MaxEquation,
354
+ blendSrc: OneFactor,
355
+ blendDst: OneFactor,
356
+ depthTest: false,
357
+ depthWrite: false,
358
+ uniforms: { uLd: { value: LIGHT } },
359
+ });
360
+ leaves = new Map();
361
+ loader = new TextureLoader();
362
+ textures = new Map();
363
+ ready = new Map();
364
+ W = 1;
365
+ H = 1;
366
+ /** Leaf k lies on the left. */
367
+ turned;
368
+ turns = new Map();
369
+ target;
370
+ raf = 0;
371
+ starting = false;
372
+ disposed = false;
373
+ /** The spread last reported settled (the one the DOM shows). */
374
+ reported;
375
+ persistent;
376
+ turnAt;
377
+ /** A redraw of the book at rest is queued. */
378
+ still = 0;
379
+ constructor(canvas, spread, book, at, onSettle,
380
+ /** The reader's hand turned the book to another spread. */
381
+ onTarget,
382
+ /** The edge the book is bound on. */
383
+ binding = "left",
384
+ /** `persistent`: the canvas draws the book at rest too (the DOM pages
385
+ * are hidden by the host and serve only as texture sources and text
386
+ * alternatives), so a page looks the same lying still and turning. */
387
+ /** `turnAt`: how far through its turn (0 … 1) a leaf let go of turns
388
+ * over rather than falling back. Default 0.5. */
389
+ { persistent = false, turnAt = 0.5 } = {}) {
390
+ this.canvas = canvas;
391
+ this.spread = spread;
392
+ this.book = book;
393
+ this.onSettle = onSettle;
394
+ this.onTarget = onTarget;
395
+ this.persistent = persistent;
396
+ this.turnAt = turnAt;
397
+ this.sign = binding === "right" ? -1 : 1;
398
+ this.light = new Vector3(this.sign * LIGHT.x, LIGHT.y, LIGHT.z);
399
+ this.casterMaterial.uniforms.uLd.value = this.light;
400
+ const mirror = this.sign < 0;
401
+ this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true });
402
+ this.renderer.outputColorSpace = LinearSRGBColorSpace;
403
+ this.renderer.setClearColor(0x000000, 0);
404
+ this.left = new Mesh(new PlaneGeometry(1, 1, 1, 1), material(false, mirror));
405
+ this.right = new Mesh(new PlaneGeometry(1, 1, 1, 1), material(false, mirror));
406
+ this.desk = new Mesh(new PlaneGeometry(1, 1), new ShaderMaterial({
407
+ vertexShader: VERTEX,
408
+ fragmentShader: DESK,
409
+ transparent: true,
410
+ depthWrite: false,
411
+ uniforms: {
412
+ uW: { value: 1 },
413
+ uH: { value: 1 },
414
+ uPages: { value: new Vector2() },
415
+ ...shadowUniforms,
416
+ },
417
+ }));
418
+ this.desk.renderOrder = -1;
419
+ this.shadowCamera.position.z = 100;
420
+ // The height map needs a float colour buffer (and max blending).
421
+ const ext = this.renderer.extensions;
422
+ if (ext.has("EXT_color_buffer_float") || ext.has("EXT_color_buffer_half_float")) {
423
+ this.shadowTarget = new WebGLRenderTarget(1, 1, {
424
+ type: HalfFloatType,
425
+ minFilter: NearestFilter,
426
+ magFilter: NearestFilter,
427
+ depthBuffer: false,
428
+ generateMipmaps: false,
429
+ });
430
+ }
431
+ // A mirror turns three.js's face culling round with it (the front of a
432
+ // page stays its front).
433
+ this.stage.scale.x = this.sign;
434
+ this.stage.add(this.desk, this.left, this.right);
435
+ this.scene.add(this.stage);
436
+ this.target = at;
437
+ this.reported = at;
438
+ this.turned = Array.from({ length: Math.max(0, book.length - 1) }, (_, k) => k < at);
439
+ this.redraw();
440
+ }
441
+ /** Fits the camera so the z = 0 plane maps onto the DOM spread 1:1.
442
+ * Sizes are read unrounded and the drawing buffer covers the canvas's
443
+ * device pixels exactly, so a page lying flat is drawn texel for pixel,
444
+ * as the DOM shows it. */
445
+ layout() {
446
+ const box = this.canvas.getBoundingClientRect();
447
+ const cw = box.width;
448
+ const ch = box.height;
449
+ if (!cw || !ch)
450
+ return;
451
+ const dpr = Math.min(window.devicePixelRatio || 1, 3);
452
+ this.renderer.setPixelRatio(1);
453
+ this.renderer.setSize(Math.round(cw * dpr), Math.round(ch * dpr), false);
454
+ this.camera.aspect = cw / ch;
455
+ this.camera.position.set(0, 0, ch / 2 / Math.tan((FOV * Math.PI) / 360));
456
+ this.camera.near = this.camera.position.z / 20;
457
+ this.camera.far = this.camera.position.z * 4;
458
+ this.camera.updateProjectionMatrix();
459
+ const spread = this.spread.getBoundingClientRect();
460
+ this.W = spread.width / 2;
461
+ this.H = spread.height;
462
+ this.flat(this.right.geometry, 1);
463
+ this.flat(this.left.geometry, -1);
464
+ // The desk plane fills the canvas (its vertices are world positions).
465
+ const pos = this.desk.geometry.attributes.position;
466
+ for (let i = 0; i < 4; i++)
467
+ pos.setXYZ(i, (i % 2 ? 1 : -1) * this.W * 2, (i < 2 ? 1 : -1) * this.H, -1);
468
+ pos.needsUpdate = true;
469
+ this.desk.material.uniforms.uW.value = this.W;
470
+ this.desk.material.uniforms.uH.value = this.H;
471
+ // The height map covers the canvas (1 texel ≈ 1 px at the desk).
472
+ if (this.shadowTarget) {
473
+ const hw = cw / 2 + 60;
474
+ const hh = ch / 2 + 60;
475
+ Object.assign(this.shadowCamera, { left: -hw, right: hw, top: hh, bottom: -hh });
476
+ this.shadowCamera.updateProjectionMatrix();
477
+ const scale = Math.min(1, 2048 / (2 * hw));
478
+ this.shadowTarget.setSize(Math.round(2 * hw * scale), Math.round(2 * hh * scale));
479
+ shadowUniforms.uShadowBox.value.set(-hw, -hh, 2 * hw, 2 * hh);
480
+ shadowUniforms.uShadowMap.value = this.shadowTarget.texture;
481
+ }
482
+ }
483
+ flat(geometry, side) {
484
+ const pos = geometry.attributes.position;
485
+ // PlaneGeometry(1, 1, 1, 1): TL, TR, BL, BR; uv.x = 0 at the spine.
486
+ const { W, H } = this;
487
+ pos.setXYZ(0, 0, H / 2, 0);
488
+ pos.setXYZ(1, side * W, H / 2, 0);
489
+ pos.setXYZ(2, 0, -H / 2, 0);
490
+ pos.setXYZ(3, side * W, -H / 2, 0);
491
+ pos.needsUpdate = true;
492
+ geometry.computeVertexNormals();
493
+ }
494
+ /** The colour of the book's paper (0 … 1), for its blank pages. */
495
+ setPaper(r, g, b) {
496
+ paperUniform.uPaper.value.set(r, g, b);
497
+ }
498
+ /** Starts loading the pages of these spreads as textures. */
499
+ preload(indexes) {
500
+ for (const i of indexes)
501
+ for (const src of this.book[i] ?? [])
502
+ if (src)
503
+ void this.texture(src);
504
+ }
505
+ texture(src) {
506
+ let hit = this.textures.get(src);
507
+ if (!hit) {
508
+ const prepare = (tex) => {
509
+ tex.colorSpace = NoColorSpace;
510
+ tex.minFilter = LinearMipmapLinearFilter;
511
+ tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
512
+ this.ready.set(src, tex);
513
+ return tex;
514
+ };
515
+ if (typeof src === "string") {
516
+ hit = this.loader.loadAsync(src).then(prepare, () => null);
517
+ }
518
+ else {
519
+ // A canvas is drawn already; an image may still be decoding.
520
+ const tex = new Texture(src);
521
+ const decoded = src instanceof HTMLCanvasElement ? Promise.resolve() : src.decode();
522
+ hit = decoded.then(() => {
523
+ tex.needsUpdate = true;
524
+ return prepare(tex);
525
+ }, () => null);
526
+ }
527
+ this.textures.set(src, hit);
528
+ }
529
+ return hit;
530
+ }
531
+ /**
532
+ * Replaces the book's pages (after a relayout, or once more pages are
533
+ * painted), keeping the open spread when `at` is left out. Leaves at
534
+ * rest take their new pages at once; textures no page uses any more are
535
+ * freed. A book of another length starts afresh at `at`.
536
+ */
537
+ setBook(book, at) {
538
+ const resized = book.length !== this.book.length;
539
+ this.book = book;
540
+ if (resized || at !== undefined) {
541
+ const open = Math.max(0, Math.min(book.length - 1, at ?? this.settledAt()));
542
+ if (resized) {
543
+ cancelAnimationFrame(this.raf);
544
+ this.raf = 0;
545
+ this.turns.clear();
546
+ for (const mesh of this.leaves.values()) {
547
+ mesh.removeFromParent();
548
+ mesh.userData.caster.removeFromParent();
549
+ mesh.geometry.dispose();
550
+ mesh.material.dispose();
551
+ }
552
+ this.leaves.clear();
553
+ }
554
+ this.turned = Array.from({ length: Math.max(0, book.length - 1) }, (_, k) => k < open);
555
+ this.target = open;
556
+ this.reported = open;
557
+ if (!this.raf)
558
+ this.clear();
559
+ }
560
+ for (const [k, mesh] of this.leaves) {
561
+ this.assign(mesh, "Front", book[k]?.[1] ?? null);
562
+ this.assign(mesh, "Back", book[k + 1]?.[0] ?? null);
563
+ }
564
+ const used = new Set(book.flat());
565
+ for (const mesh of [this.left, this.right])
566
+ used.add(mesh.userData.Front).add(mesh.userData.Back);
567
+ for (const [src, tex] of this.ready) {
568
+ if (used.has(src))
569
+ continue;
570
+ tex.dispose();
571
+ this.ready.delete(src);
572
+ this.textures.delete(src);
573
+ }
574
+ this.redraw();
575
+ }
576
+ assign(mesh, face, src) {
577
+ const u = mesh.material.uniforms;
578
+ if (mesh.userData[face] === src)
579
+ return;
580
+ mesh.userData[face] = src;
581
+ u[`uHas${face}`].value = 0;
582
+ u[`u${face}`].value = null;
583
+ if (src === "")
584
+ u[`uHas${face}`].value = 2;
585
+ if (!src)
586
+ return;
587
+ const set = (tex) => {
588
+ if (!tex || mesh.userData[face] !== src)
589
+ return;
590
+ u[`u${face}`].value = tex;
591
+ u[`uHas${face}`].value = 1;
592
+ this.redraw();
593
+ };
594
+ const tex = this.ready.get(src);
595
+ if (tex)
596
+ set(tex);
597
+ else
598
+ void this.texture(src).then(set);
599
+ }
600
+ leaf(k) {
601
+ let mesh = this.leaves.get(k);
602
+ if (!mesh) {
603
+ mesh = new Mesh(new PlaneGeometry(1, 1, NX, NY), material(true, this.sign < 0));
604
+ const caster = new Mesh(mesh.geometry, this.casterMaterial);
605
+ caster.frustumCulled = false;
606
+ this.shadowScene.add(caster);
607
+ mesh.userData.caster = caster;
608
+ this.assign(mesh, "Front", this.book[k]?.[1] ?? null);
609
+ this.assign(mesh, "Back", this.book[k + 1]?.[0] ?? null);
610
+ this.leaves.set(k, mesh);
611
+ }
612
+ return mesh;
613
+ }
614
+ /** Turns the pages until spread `index` lies open. */
615
+ go(index) {
616
+ this.target = Math.max(0, Math.min(this.book.length - 1, index));
617
+ if (this.raf || this.starting)
618
+ return;
619
+ const lo = Math.min(this.target, this.settledAt());
620
+ const hi = Math.max(this.target, this.settledAt());
621
+ if (lo === hi)
622
+ return this.onSettle(this.target);
623
+ // The first leaves' pages are warm; wait a moment for cold ones.
624
+ this.starting = true;
625
+ const wanted = [];
626
+ for (let i = lo; i <= Math.min(hi, lo + 2); i++)
627
+ for (const src of this.book[i])
628
+ if (src)
629
+ wanted.push(src);
630
+ this.preload(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
631
+ void Promise.race([Promise.all(wanted.map((s) => this.texture(s))), new Promise((r) => setTimeout(r, 700))]).then(() => {
632
+ this.starting = false;
633
+ if (this.disposed)
634
+ return;
635
+ this.layout();
636
+ this.raf = requestAnimationFrame(this.frame);
637
+ });
638
+ }
639
+ /** The spread open when no leaf is in the air. */
640
+ settledAt() {
641
+ const i = this.turned.indexOf(false);
642
+ return i < 0 ? this.turned.length : i;
643
+ }
644
+ /** A new turn by the virtual hand, from the bottom corner (or the
645
+ * point clicked) over to its mirror image. */
646
+ lift(forward, now, G = { u: this.W, v: -this.H / 2 }) {
647
+ return {
648
+ forward,
649
+ G,
650
+ P: { ...G },
651
+ anim: { start: now, duration: DURATION, from: G, to: { u: -G.u, v: G.v }, arc: 0.28 * this.H, lands: true },
652
+ };
653
+ }
654
+ frame = (now) => {
655
+ this.raf = 0;
656
+ if (this.disposed)
657
+ return;
658
+ const n = this.turned.length;
659
+ const progress = (turn) => progressFrom(turn.G, turn.P);
660
+ // Move the hands; land the leaves that have finished (or fallen back).
661
+ for (const [k, turn] of this.turns) {
662
+ if (turn.held) {
663
+ // The page follows the hand with a little inertia.
664
+ const { aim } = turn.held;
665
+ turn.P = reach(turn.G, { u: turn.P.u + (aim.u - turn.P.u) * 0.35, v: turn.P.v + (aim.v - turn.P.v) * 0.35 }, this.H);
666
+ }
667
+ else if (turn.anim) {
668
+ const { start, duration, from, to, arc, lands } = turn.anim;
669
+ const t = Math.min(1, (now - start) / duration);
670
+ const e = arc ? ease(t) : easeOut(t);
671
+ turn.P = reach(turn.G, { u: from.u + (to.u - from.u) * e, v: from.v + (to.v - from.v) * e + arc * Math.sin(Math.PI * e) }, this.H);
672
+ if (t >= 1) {
673
+ this.turned[k] = lands ? turn.forward : !turn.forward;
674
+ this.turns.delete(k);
675
+ }
676
+ }
677
+ }
678
+ // Lift the next ones: each follows its neighbour a little behind, so a
679
+ // jump of several spreads turns every page in between.
680
+ const pending = Math.abs(this.target - this.settledAt()) || 1;
681
+ const gap = Math.min(GAP, 0.7 / pending);
682
+ const follows = (k, forward) => {
683
+ const turn = this.turns.get(k);
684
+ if (!turn)
685
+ return forward ? this.turned[k] : !this.turned[k];
686
+ return turn.forward === forward && !turn.held && progress(turn) >= gap;
687
+ };
688
+ for (let k = 0; k < this.target && k < n; k++) {
689
+ if (this.turned[k] || this.turns.has(k))
690
+ continue;
691
+ if (k > 0 && !follows(k - 1, true))
692
+ break;
693
+ this.turns.set(k, this.lift(true, now));
694
+ }
695
+ for (let k = n - 1; k >= this.target; k--) {
696
+ if (!this.turned[k] || this.turns.has(k))
697
+ continue;
698
+ if (k < n - 1 && !follows(k + 1, false))
699
+ break;
700
+ this.turns.set(k, this.lift(false, now));
701
+ }
702
+ this.draw();
703
+ this.spread.classList.add("is-turning");
704
+ if (this.turns.size > 0 || this.settledAt() !== this.target)
705
+ this.raf = requestAnimationFrame(this.frame);
706
+ else
707
+ this.settle();
708
+ };
709
+ settle() {
710
+ if (this.target === this.reported) {
711
+ // Fallen back: the DOM already shows this spread.
712
+ this.clear();
713
+ }
714
+ else {
715
+ this.reported = this.target;
716
+ this.onSettle(this.target);
717
+ }
718
+ }
719
+ // ── The reader's hand ──
720
+ /** The pointer on the z = 0 plane, in the book's coordinates (x
721
+ * mirrored for a right-bound book). */
722
+ toWorld(event) {
723
+ const rect = this.canvas.getBoundingClientRect();
724
+ return { x: this.sign * (event.clientX - (rect.left + rect.width / 2)), y: -(event.clientY - (rect.top + rect.height / 2)) };
725
+ }
726
+ /** Takes hold of the page under the pointer; false when there is none
727
+ * to turn (or pages are already in the air). */
728
+ grab(event) {
729
+ if (this.raf || this.starting || this.turns.size)
730
+ return false;
731
+ this.layout();
732
+ const { x, y } = this.toWorld(event);
733
+ const at = this.settledAt();
734
+ const forward = x > 0;
735
+ const k = forward ? at : at - 1;
736
+ if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W || Math.abs(y) > this.H / 2)
737
+ return false;
738
+ const G = { u: Math.max(0.15 * this.W, Math.abs(x)), v: y };
739
+ this.turns.set(k, { forward, G, P: { ...G }, held: { aim: { ...G }, samples: [] } });
740
+ this.raf = requestAnimationFrame(this.frame);
741
+ return true;
742
+ }
743
+ /** Carries the held point to the pointer. */
744
+ drag(event) {
745
+ const turn = [...this.turns.values()].find((t) => t.held);
746
+ if (!turn?.held)
747
+ return;
748
+ const { x, y } = this.toWorld(event);
749
+ turn.held.aim = reach(turn.G, { u: turn.forward ? x : -x, v: y }, this.H);
750
+ const t = performance.now();
751
+ turn.held.samples.push({ t, q: progressFrom(turn.G, turn.held.aim) });
752
+ while (turn.held.samples.length > 2 && t - turn.held.samples[0].t > 120)
753
+ turn.held.samples.shift();
754
+ }
755
+ /** Lets go. A click turns the page over from where it was taken; a drag
756
+ * carries on over if the page is past halfway or thrown that way, and
757
+ * falls back otherwise. */
758
+ release(click = false) {
759
+ const entry = [...this.turns].find(([, t]) => t.held);
760
+ if (!entry)
761
+ return;
762
+ const [k, turn] = entry;
763
+ const { samples } = turn.held;
764
+ const first = samples[0];
765
+ const last = samples[samples.length - 1];
766
+ const speed = first && last && last.t > first.t ? (last.q - first.q) / (last.t - first.t) : 0; // per ms
767
+ const q = progressFrom(turn.G, turn.P);
768
+ const over = click || q + speed * 250 > this.turnAt;
769
+ const to = over ? { u: -turn.G.u, v: turn.G.v } : { ...turn.G };
770
+ turn.held = undefined;
771
+ turn.anim = {
772
+ start: performance.now(),
773
+ duration: click ? DURATION : Math.max(260, DURATION * 0.8 * (over ? 1 - q : q)),
774
+ from: { ...turn.P },
775
+ to,
776
+ arc: click ? 0.2 * this.H : 0,
777
+ lands: over,
778
+ };
779
+ if (over) {
780
+ this.target = turn.forward ? k + 1 : k;
781
+ this.onTarget(this.target);
782
+ }
783
+ }
784
+ draw() {
785
+ const { W, H } = this;
786
+ const n = this.turned.length;
787
+ // The leaves in the air, in book order.
788
+ const air = [...this.turns]
789
+ .sort(([a], [b]) => a - b)
790
+ .map(([k, turn]) => ({
791
+ k,
792
+ q: progressFrom(turn.G, turn.P),
793
+ forward: turn.forward,
794
+ fold: foldOf(turn.G, turn.P, W, H),
795
+ mesh: this.leaf(k),
796
+ }));
797
+ for (const leaf of this.leaves.values())
798
+ leaf.visible = leaf.userData.caster.visible = false;
799
+ air.forEach(({ q, forward, fold, mesh }, i) => {
800
+ // Leaf k lies over leaf k + 1 on the right and under it on the left.
801
+ const side = forward ? q : 1 - q;
802
+ layLeaf(mesh.geometry, fold, forward, W, H, 0.6 + 0.8 * ((1 - side) * (air.length - 1 - i) + side * i));
803
+ mesh.visible = mesh.userData.caster.visible = true;
804
+ if (!mesh.parent)
805
+ this.stage.add(mesh);
806
+ });
807
+ // The pages lying flat: the top leaf of each stack.
808
+ let l = -1;
809
+ for (let k = 0; k < n; k++)
810
+ if (this.turned[k] && !this.turns.has(k))
811
+ l = k;
812
+ let r = n;
813
+ for (let k = n - 1; k >= 0; k--)
814
+ if (!this.turned[k] && !this.turns.has(k))
815
+ r = k;
816
+ const leftSrc = this.book[l + 1]?.[0] ?? null;
817
+ const rightSrc = this.book[r]?.[1] ?? null;
818
+ this.assign(this.left, "Back", leftSrc);
819
+ this.assign(this.right, "Front", rightSrc);
820
+ this.desk.material.uniforms.uPages.value.set(Number(leftSrc !== null), Number(rightSrc !== null));
821
+ // The height map the shadows are read from.
822
+ shadowUniforms.uLd.value = this.light;
823
+ shadowUniforms.uShadowOn.value = this.shadowTarget && air.length ? 1 : 0;
824
+ if (this.shadowTarget && air.length) {
825
+ this.renderer.setRenderTarget(this.shadowTarget);
826
+ this.renderer.render(this.shadowScene, this.shadowCamera);
827
+ this.renderer.setRenderTarget(null);
828
+ }
829
+ this.renderer.render(this.scene, this.camera);
830
+ }
831
+ /** Back to a transparent canvas (the DOM spread shows), unless leaves
832
+ * are moving again. A persistent book is drawn at rest instead. */
833
+ clear() {
834
+ if (this.raf || this.starting)
835
+ return;
836
+ if (this.persistent)
837
+ return this.redraw();
838
+ this.spread.classList.remove("is-turning");
839
+ this.renderer.clear();
840
+ }
841
+ /** Draws the book at rest again (a page painted, the box resized) on the
842
+ * next frame; leaves in the air are drawn by their own frames. Only for
843
+ * a persistent book: the other one shows the DOM at rest. */
844
+ redraw() {
845
+ if (!this.persistent || this.disposed || this.still)
846
+ return;
847
+ this.still = requestAnimationFrame(() => {
848
+ this.still = 0;
849
+ if (this.disposed || this.raf || this.starting)
850
+ return;
851
+ this.layout();
852
+ this.draw();
853
+ });
854
+ }
855
+ dispose() {
856
+ this.disposed = true;
857
+ cancelAnimationFrame(this.raf);
858
+ cancelAnimationFrame(this.still);
859
+ for (const tex of this.ready.values())
860
+ tex.dispose();
861
+ this.shadowTarget?.dispose();
862
+ this.casterMaterial.dispose();
863
+ for (const mesh of [this.left, this.right, this.desk, ...this.leaves.values()]) {
864
+ mesh.geometry.dispose();
865
+ mesh.material.dispose();
866
+ }
867
+ this.renderer.dispose();
868
+ }
869
+ }
870
+ //# sourceMappingURL=pageFlipFlat.js.map