postext-folio 0.2.3 → 0.3.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/dist/pageFlip.js CHANGED
@@ -16,6 +16,13 @@ const FOV = 24;
16
16
  const ROLL = 0.11;
17
17
  /** Milliseconds one leaf takes to turn. */
18
18
  const DURATION = 1000;
19
+ /** A jump of more pages than this is made in one move: the block of
20
+ * leaves in between is lifted together and laid over on the other side,
21
+ * instead of every leaf turning on its own. */
22
+ export const BLOCK_PAGES = 10;
23
+ /** How long a block of leaves takes to go over (ms), a little longer for
24
+ * a thicker one. */
25
+ const BLOCK_DURATION = 1150;
19
26
  /** A leaf follows the one before it once that one is this far through its turn. */
20
27
  const GAP = 0.14;
21
28
  // Ambient occlusion from the book's own shape: each frame the book is
@@ -274,24 +281,92 @@ function pageMaterial(mirror, sign) {
274
281
  };
275
282
  return m;
276
283
  }
284
+ /** The uniforms every page block's edge material shares: book units per
285
+ * mm, and how strongly the cut face is relieved (uncoated stock shows
286
+ * its fibres; a coated sheet is cut cleaner). */
287
+ const edgeUniforms = {
288
+ uEdgeK: { value: 1 },
289
+ uEdgeRelief: { value: 1 },
290
+ };
291
+ // The cut face of a page block, as a height field in mm over (leaf, mm
292
+ // along the cut): every leaf a rounded ridge standing a hair proud of or
293
+ // behind its neighbours (sheets are never quite in line), the sections
294
+ // and the bundles the knife took together a little out of line with the
295
+ // next, and the faint streaks the guillotine drags across the block. Each
296
+ // scale fades out where it is finer than a pixel, so the face never
297
+ // shimmers. The relief bends the normal (bump mapping from the screen-
298
+ // space derivatives of the field), so the face catches a grazing light.
299
+ const EDGE_RELIEF = /* glsl */ `
300
+ uniform float uEdgeK;
301
+ uniform float uEdgeRelief;
302
+ varying float vRun;
303
+ float eHash(float n) { return fract(sin(n * 127.1) * 43758.5453); }
304
+ float eHash2(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
305
+ float eNoise(vec2 p) {
306
+ vec2 i = floor(p);
307
+ vec2 f = fract(p);
308
+ vec2 u = f * f * (3.0 - 2.0 * f);
309
+ return mix(mix(eHash2(i), eHash2(i + vec2(1.0, 0.0)), u.x), mix(eHash2(i + vec2(0.0, 1.0)), eHash2(i + vec2(1.0, 1.0)), u.x), u.y);
310
+ }
311
+ // fade: x = the leaves, y = the bundles, z = the fine knife marks.
312
+ float edgeHeight(vec2 lv, vec3 fade) {
313
+ float id = floor(lv.x);
314
+ float ridge = sin(fract(lv.x) * PI);
315
+ float proud = eHash(id) - 0.5 + 0.8 * (eNoise(vec2(id * 0.37, lv.y * 0.12)) - 0.5);
316
+ float leaf = (0.45 * ridge + proud) * 0.04 * fade.x;
317
+ float bundle = (eNoise(vec2(lv.x / 5.0, lv.y * 0.03)) - 0.5) * 0.12 * fade.y
318
+ + (eNoise(vec2(lv.x / 24.0 + 7.0, lv.y * 0.02)) - 0.5) * 0.18;
319
+ float knife = (eNoise(vec2(lv.y * 0.7, lv.x / 90.0)) - 0.5) * 0.035
320
+ + (eNoise(vec2(lv.y * 3.0, lv.x / 40.0)) - 0.5) * 0.006 * fade.z;
321
+ return leaf + bundle + knife;
322
+ }
323
+ vec3 edgeNormal(vec3 surfPos, vec3 n, vec2 dH, float faceDir) {
324
+ vec3 sx = dFdx(surfPos);
325
+ vec3 sy = dFdy(surfPos);
326
+ vec3 r1 = cross(sy, n);
327
+ vec3 r2 = cross(n, sx);
328
+ float det = dot(sx, r1) * faceDir;
329
+ vec3 grad = sign(det) * (dH.x * r1 + dH.y * r2);
330
+ return normalize(abs(det) * n - grad);
331
+ }
332
+ `;
277
333
  /** The page block's edges: paper with a fine line between leaves (fading
278
- * to their average where the lines are finer than a pixel). */
279
- function edgeMaterial(sign, bands) {
334
+ * to their average where the lines are finer than a pixel), its cut face
335
+ * in relief. */
336
+ function edgeMaterial(sign, bands, paper) {
280
337
  const m = new MeshStandardMaterial({ side: DoubleSide, roughness: 0.92 });
338
+ if (paper)
339
+ m.color.copy(paper);
281
340
  const band = { uBoardLo: { value: bands?.lo ?? -1 }, uBoardHi: { value: bands?.hi ?? 2 }, uBoardColor: { value: bands?.color ?? new Color(0, 0, 0) } };
282
341
  m.onBeforeCompile = (shader) => {
283
- Object.assign(shader.uniforms, occlusionUniforms, band, { uSign: { value: sign } });
342
+ Object.assign(shader.uniforms, occlusionUniforms, edgeUniforms, band, { uSign: { value: sign } });
284
343
  shader.vertexShader = shader.vertexShader
285
- .replace("#include <common>", `#include <common>\nattribute float layer;\nattribute float lambda;\nvarying float vLayer;\nvarying float vLambda;\n${BOOK_POS_PARS}`)
286
- .replace("#include <begin_vertex>", `#include <begin_vertex>\nvLayer = layer;\nvLambda = lambda;\n${BOOK_POS}`);
344
+ .replace("#include <common>", `#include <common>\nattribute float layer;\nattribute float lambda;\nattribute float run;\nvarying float vLayer;\nvarying float vLambda;\nvarying float vRun;\n${BOOK_POS_PARS}`)
345
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\nvLayer = layer;\nvLambda = lambda;\nvRun = run;\n${BOOK_POS}`);
287
346
  shader.fragmentShader = keyShadowed(shader.fragmentShader)
288
- .replace("#include <common>", `#include <common>\nuniform float uBoardLo;\nuniform float uBoardHi;\nuniform vec3 uBoardColor;\nvarying float vLayer;\nvarying float vLambda;\nvarying vec3 vBookPos;\n${OCCLUSION}`)
347
+ .replace("#include <common>", `#include <common>\nuniform float uBoardLo;\nuniform float uBoardHi;\nuniform vec3 uBoardColor;\nvarying float vLayer;\nvarying float vLambda;\nvarying vec3 vBookPos;\n${OCCLUSION}\n${EDGE_RELIEF}`)
348
+ .replace("#include <normal_fragment_maps>", `{
349
+ vec2 lv = vec2(vLayer, vRun / uEdgeK);
350
+ vec2 dx = dFdx(lv);
351
+ vec2 dy = dFdy(lv);
352
+ float wl = fwidth(vLayer);
353
+ vec3 fade = vec3(
354
+ 1.0 - smoothstep(0.3, 0.8, wl),
355
+ 1.0 - smoothstep(2.0, 5.0, wl),
356
+ 1.0 - smoothstep(0.05, 0.15, fwidth(lv.y))
357
+ );
358
+ float h0 = edgeHeight(lv, fade);
359
+ vec2 dH = vec2(edgeHeight(lv + dx, fade) - h0, edgeHeight(lv + dy, fade) - h0) * uEdgeK * uEdgeRelief;
360
+ if (vLambda >= uBoardLo && vLambda <= uBoardHi) normal = edgeNormal(-vViewPosition, normal, dH, faceDirection);
361
+ }`)
289
362
  .replace("#include <map_fragment>", `{
290
363
  float w = fwidth(vLayer);
291
364
  float d = abs(fract(vLayer) - 0.5) * 2.0;
292
365
  float line = smoothstep(0.55 - w, 0.95 + w, d);
293
366
  float fine = clamp(w * 1.5, 0.0, 1.0);
294
367
  diffuseColor.rgb *= 1.0 - mix(0.22 * line, 0.08, fine);
368
+ // The bundles' sheets are never quite one shade.
369
+ diffuseColor.rgb *= 1.0 + 0.06 * uEdgeRelief * (eNoise(vec2(vLayer / 9.0, 3.0)) - 0.5);
295
370
  // A board lying in the block (the book's own cover) shows its
296
371
  // own edge, not paper.
297
372
  if (vLambda < uBoardLo || vLambda > uBoardHi) diffuseColor.rgb = uBoardColor;
@@ -389,7 +464,7 @@ function surfaceAt(book, signed) {
389
464
  * page lifts and shrinks to nothing as it lands on the other side; a
390
465
  * stiffer paper (`roll`) bends in a wider roll.
391
466
  */
392
- function foldOf(G, P, W, H, roll = 1) {
467
+ export function foldOf(G, P, W, H, roll = 1) {
393
468
  const du = G.u - P.u;
394
469
  const dv = G.v - P.v;
395
470
  const D = Math.hypot(du, dv);
@@ -416,7 +491,7 @@ function foldOf(G, P, W, H, roll = 1) {
416
491
  }
417
492
  /** How far through its turn a leaf is: the point taken, from where it
418
493
  * was to its mirror image across the spine. */
419
- function progressFrom(G, P) {
494
+ export function progressFrom(G, P) {
420
495
  return Math.min(1, Math.max(0, (G.u - P.u) / (2 * G.u)));
421
496
  }
422
497
  /** The hand can carry the point taken no farther from the spine's ends
@@ -442,7 +517,7 @@ function reach(G, P, H) {
442
517
  * the spine the left one's. A stiff leaf (`rigidity`) turns more as a
443
518
  * plate on its hinge: a board does not bend at all.
444
519
  */
445
- function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter = 0, time = 0, board) {
520
+ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter = 0, time = 0, board) {
446
521
  const pos = geometry.attributes.position;
447
522
  const sx = forward ? 1 : -1;
448
523
  const [xr, zr] = along(book.right, W);
@@ -495,6 +570,25 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
495
570
  // it leans off that slope, not off a level plane over it.
496
571
  const chordR = (zr - zr0) / (xr || 1);
497
572
  const chordL = (zl - zl0) / (xl || 1);
573
+ // Onto the book. At rest (and as it lands) the leaf lies on the open
574
+ // book's surface, into the gutter and out. In the air it is lifted off
575
+ // at the spine: its own plane rises from the gutter's floor at a small
576
+ // angle, and it touches the pages only where they stand higher (the
577
+ // gutter's walls), joined smoothly. `z`: how far over that it stands.
578
+ const onBook = (s, z) => {
579
+ const [rx, rz] = surfaceAt(book, s);
580
+ let px = rx;
581
+ let pz = rz + z + lift;
582
+ if (inAir > 0) {
583
+ const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * cosA;
584
+ const plane = (s >= 0 ? zr0 + s * chordR : zl0 - s * chordL) + Math.abs(s) * sinA + z + lift;
585
+ const under = surfaceAt(book, fx)[1] + lift + 0.6;
586
+ const flying = smoothMax(plane, under, 0.006 * W);
587
+ px += (fx - px) * inAir;
588
+ pz += (flying - pz) * inAir;
589
+ }
590
+ return [px, pz];
591
+ };
498
592
  for (let iy = 0; iy <= NY; iy++) {
499
593
  const v = (0.5 - iy / NY) * H;
500
594
  for (let ix = 0; ix <= NX; ix++) {
@@ -502,13 +596,14 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
502
596
  let x = u;
503
597
  let y = v;
504
598
  let z = 0;
599
+ let d = 0;
600
+ let dd = 0;
505
601
  if (fold) {
506
602
  const { F, n, R } = fold;
507
- const d = (u - F.u) * n.u + (v - F.v) * n.v;
603
+ d = (u - F.u) * n.u + (v - F.v) * n.v;
508
604
  if (d > 0) {
509
605
  // Round the roll, then straight on at the fold's angle.
510
606
  const theta = fold.theta;
511
- let dd;
512
607
  if (R > 1e-3 && d < R * theta) {
513
608
  dd = R * Math.sin(d / R);
514
609
  z = R * (1 - Math.cos(d / R));
@@ -522,22 +617,17 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
522
617
  y = v - n.v * (d - dd);
523
618
  }
524
619
  }
525
- // Onto the book. At rest (and as it lands) the leaf lies on the open
526
- // book's surface, into the gutter and out. In the air it is lifted
527
- // off at the spine: its own plane rises from the gutter's floor at a
528
- // small angle, and it touches the pages only where they stand higher
529
- // (the gutter's walls), joined smoothly.
530
- const s = sx * x;
531
- const [rx, rz] = surfaceAt(book, s);
532
- let px = rx;
533
- let pz = rz + z + lift;
534
- if (inAir > 0) {
535
- const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * cosA;
536
- const plane = (s >= 0 ? zr0 + s * chordR : zl0 - s * chordL) + Math.abs(s) * sinA + z + lift;
537
- const under = surfaceAt(book, fx)[1] + lift + 0.6;
538
- const flying = smoothMax(plane, under, 0.006 * W);
539
- px += (fx - px) * inAir;
540
- pz += (flying - pz) * inAir;
620
+ let [px, pz] = onBook(sx * x, z);
621
+ if (fold && d > 0) {
622
+ // Paper up in the air is not pressed onto the pages under it: wrapped
623
+ // onto them it dipped into the gutter and was squeezed across it (a
624
+ // crease where the curl passed over the spine). It goes on from the
625
+ // foot of its fold, level, as the fold has it, and only comes down
626
+ // onto the pages as it does (lying back over itself, landing).
627
+ const [fx, fz] = onBook(sx * (u - fold.n.u * d), 0);
628
+ const w = smooth(0, 0.05 * W, z);
629
+ px += (fx + sx * fold.n.u * dd - px) * w;
630
+ pz += (fz + z - pz) * w;
541
631
  }
542
632
  if (r > 0) {
543
633
  px += (u * Math.cos(phi) - offset * Math.sin(phi) - px) * r;
@@ -632,6 +722,10 @@ export class PageFlipper {
632
722
  /** Leaf k lies on the left. */
633
723
  turned;
634
724
  turns = new Map();
725
+ /** A long jump in progress: leaves lo … hi − 1 turning over as one
726
+ * block (a rigid slab as thick as they are together). */
727
+ block = null;
728
+ blockMesh = null;
635
729
  target;
636
730
  raf = 0;
637
731
  lastFrame = 0;
@@ -774,9 +868,10 @@ export class PageFlipper {
774
868
  paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false })
775
869
  : {
776
870
  ...paperSpec({ ...r.paper, type: "board", grammage: 1250, bulk: 1.6, finish: "silk", texture: "smooth", shade: { hex: "#ffffff", model: "hex" }, showThrough: false }),
777
- // The book's own printed covers: a laminated card board, thinner
778
- // than a case's.
779
- caliperMm: Math.min(0.45, BINDINGS[r.binding.type].boardMm || 0.45),
871
+ // The book's own printed covers: in a case binding a printed
872
+ // (litho-laminated) case, on the case's 2–3 mm board; on a
873
+ // paperback a laminated cover card, about 0.3 mm.
874
+ caliperMm: BINDINGS[r.binding.type].boardMm || 0.45,
780
875
  // Board does not bend: it turns on its joint as a plate.
781
876
  rigidity: 1,
782
877
  roughness: 0.45,
@@ -854,6 +949,7 @@ export class PageFlipper {
854
949
  this.key.shadow.intensity = 0.55 + 0.25 * (1 - env.softness);
855
950
  this.renderer.toneMappingExposure = env.exposure * r.lighting.intensity;
856
951
  this.edges.color.set(r.paper.shade.hex);
952
+ edgeUniforms.uEdgeRelief.value = r.paper.finish === "uncoated" ? 1 : r.paper.finish === "matte" ? 0.7 : 0.5;
857
953
  this.surfaceKey = "";
858
954
  for (const mesh of [this.left, this.right, ...this.leaves.values()])
859
955
  mesh.material.userData.spec = null;
@@ -922,8 +1018,13 @@ export class PageFlipper {
922
1018
  /** The paper of leaf k (outside the pages shown: the book's). */
923
1019
  specOf(k) {
924
1020
  const covers = this.appearance.coverLeaves;
925
- if (this.coverSpec && (k === covers?.front || k === covers?.back))
926
- return this.coverSpec;
1021
+ if (this.coverSpec && (k === covers?.front || k === covers?.back)) {
1022
+ // A stapled cover printed on a stock of its own (a `:::paper` card
1023
+ // round newsprint pages) is that sheet; a board is a board whatever
1024
+ // is printed on it.
1025
+ const own = this.resolved.binding.type === "saddleStitch" && this.appearance.leafPapers?.[k];
1026
+ return own ? this.leafSpecs[k] : this.coverSpec;
1027
+ }
927
1028
  return this.leafSpecs[k] ?? this.bookSpec;
928
1029
  }
929
1030
  dressPage(mesh, spec) {
@@ -959,11 +1060,20 @@ export class PageFlipper {
959
1060
  const left = [];
960
1061
  const right = [];
961
1062
  this.turned.forEach((t, k) => {
962
- if (!this.turns.has(k))
1063
+ if (!this.turns.has(k) && !this.inBlock(k))
963
1064
  (t ? left : right).push(k);
964
1065
  });
965
1066
  return { left, right };
966
1067
  }
1068
+ /** Leaf k is part of the block in the air. */
1069
+ inBlock(k) {
1070
+ return !!this.block && k >= this.block.lo && k < this.block.hi;
1071
+ }
1072
+ /** Pages a leaf carries: two, or one when the book shows a page at a
1073
+ * time (each sheet's back is blank). */
1074
+ pagesPerLeaf() {
1075
+ return this.appearance.singlePage ? 1 : 2;
1076
+ }
967
1077
  /** Loads the spine picture, then rebuilds the book to print it. */
968
1078
  async loadSpine(src) {
969
1079
  let tex = null;
@@ -1050,6 +1160,7 @@ export class PageFlipper {
1050
1160
  return;
1051
1161
  this.surfaceKey = key;
1052
1162
  const k = this.pxPerMm();
1163
+ edgeUniforms.uEdgeK.value = k;
1053
1164
  const half = this.appearance.singlePage ? 0.5 : 1;
1054
1165
  const thick = (leaves, more) => (leaves.reduce((s, i) => s + this.specOf(i).caliperMm, 0) * half + more * this.bookSpec.caliperMm) * k;
1055
1166
  const tL = thick(left, extra.before);
@@ -1117,7 +1228,7 @@ export class PageFlipper {
1117
1228
  color: this.boardColor(),
1118
1229
  }
1119
1230
  : undefined;
1120
- const mesh = new Mesh(stackGeometry(p, side, W, H, Math.round(n * half)), bands ? edgeMaterial(this.sign, bands) : this.edges);
1231
+ const mesh = new Mesh(stackGeometry(p, side, W, H, Math.round(n * half)), bands ? edgeMaterial(this.sign, bands, this.edges.color) : this.edges);
1121
1232
  mesh.castShadow = mesh.receiveShadow = true;
1122
1233
  this.stacks.push(mesh);
1123
1234
  this.stage.add(mesh);
@@ -1385,6 +1496,17 @@ export class PageFlipper {
1385
1496
  mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1386
1497
  this.redraw();
1387
1498
  }
1499
+ /** A page source drawn again in place (a canvas repainted): its
1500
+ * texture is uploaded again and the book redrawn. */
1501
+ touch(src) {
1502
+ if (!src || typeof src === "string")
1503
+ return;
1504
+ const tex = this.ready.get(src);
1505
+ if (!tex)
1506
+ return;
1507
+ tex.needsUpdate = true;
1508
+ this.redraw();
1509
+ }
1388
1510
  /** Starts loading the pages of these spreads as textures. */
1389
1511
  preload(indexes) {
1390
1512
  for (const i of indexes)
@@ -1433,6 +1555,7 @@ export class PageFlipper {
1433
1555
  cancelAnimationFrame(this.raf);
1434
1556
  this.raf = 0;
1435
1557
  this.turns.clear();
1558
+ this.block = null;
1436
1559
  for (const mesh of this.leaves.values()) {
1437
1560
  mesh.removeFromParent();
1438
1561
  mesh.userData.caster.removeFromParent();
@@ -1492,17 +1615,7 @@ export class PageFlipper {
1492
1615
  leaf(k) {
1493
1616
  let mesh = this.leaves.get(k);
1494
1617
  if (!mesh) {
1495
- const geometry = new PlaneGeometry(1, 1, NX, NY);
1496
- spreadColumns(geometry, NX);
1497
- geometry.setAttribute("ao", new BufferAttribute(new Float32Array(geometry.attributes.position.count).fill(1), 1));
1498
- mesh = new Mesh(geometry, pageMaterial(this.sign < 0, this.sign));
1499
- mesh.material.userData.uniforms.uPaper.value.setRGB(...this.paper, SRGBColorSpace);
1500
- mesh.castShadow = mesh.receiveShadow = true;
1501
- mesh.frustumCulled = false;
1502
- const caster = new Mesh(mesh.geometry, this.leafCaster);
1503
- caster.frustumCulled = false;
1504
- this.shadowScene.add(caster);
1505
- mesh.userData.caster = caster;
1618
+ mesh = this.leafMesh();
1506
1619
  this.assign(mesh, "Front", this.book[k]?.[1] ?? null);
1507
1620
  this.assign(mesh, "Back", this.book[k + 1]?.[0] ?? null);
1508
1621
  this.leaves.set(k, mesh);
@@ -1510,6 +1623,39 @@ export class PageFlipper {
1510
1623
  this.dressPage(mesh, this.specOf(k));
1511
1624
  return mesh;
1512
1625
  }
1626
+ /** A leaf's mesh, its pages not yet assigned. */
1627
+ leafMesh() {
1628
+ const geometry = new PlaneGeometry(1, 1, NX, NY);
1629
+ spreadColumns(geometry, NX);
1630
+ geometry.setAttribute("ao", new BufferAttribute(new Float32Array(geometry.attributes.position.count).fill(1), 1));
1631
+ const mesh = new Mesh(geometry, pageMaterial(this.sign < 0, this.sign));
1632
+ mesh.material.userData.uniforms.uPaper.value.setRGB(...this.paper, SRGBColorSpace);
1633
+ mesh.castShadow = mesh.receiveShadow = true;
1634
+ mesh.frustumCulled = false;
1635
+ const caster = new Mesh(mesh.geometry, this.leafCaster);
1636
+ caster.frustumCulled = false;
1637
+ caster.visible = false;
1638
+ this.shadowScene.add(caster);
1639
+ mesh.userData.caster = caster;
1640
+ return mesh;
1641
+ }
1642
+ /** The block in the air: its top page (the first leaf's front) and its
1643
+ * bottom one (the last leaf's back), and the stock of its first leaf. */
1644
+ blockLeaf(lo, hi) {
1645
+ this.blockMesh ??= this.leafMesh();
1646
+ const mesh = this.blockMesh;
1647
+ this.assign(mesh, "Front", this.book[lo]?.[1] ?? null);
1648
+ this.assign(mesh, "Back", this.book[hi]?.[0] ?? null);
1649
+ this.dressPage(mesh, this.specOf(lo));
1650
+ return mesh;
1651
+ }
1652
+ /** How thick the leaves lo … hi − 1 are together (book units). */
1653
+ blockThickness(lo, hi) {
1654
+ let mm = 0;
1655
+ for (let k = lo; k < hi; k++)
1656
+ mm += this.specOf(k).caliperMm;
1657
+ return Math.max(0.5, mm * (this.appearance.singlePage ? 0.5 : 1) * this.pxPerMm());
1658
+ }
1513
1659
  /** A board leaf's other face and edges, carried with its mesh. */
1514
1660
  boardParts(mesh) {
1515
1661
  let parts = mesh.userData.board;
@@ -1540,11 +1686,15 @@ export class PageFlipper {
1540
1686
  // The first leaves' pages are warm; wait a moment for cold ones.
1541
1687
  this.starting = true;
1542
1688
  const wanted = [];
1543
- for (let i = lo; i <= Math.min(hi, lo + 2); i++)
1544
- for (const src of this.book[i])
1689
+ // A long jump goes over in one block: only the pages it lands on (and
1690
+ // the block's two faces) show, not the ones in between.
1691
+ const blockwise = (hi - lo) * this.pagesPerLeaf() > BLOCK_PAGES;
1692
+ const ends = blockwise ? [lo, hi] : Array.from({ length: Math.min(hi, lo + 2) - lo + 1 }, (_, i) => lo + i);
1693
+ for (const i of ends)
1694
+ for (const src of this.book[i] ?? [])
1545
1695
  if (src)
1546
1696
  wanted.push(src);
1547
- this.preload(Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
1697
+ this.preload(blockwise ? [lo, hi] : Array.from({ length: hi - lo + 1 }, (_, i) => lo + i));
1548
1698
  void Promise.race([Promise.all(wanted.map((s) => this.texture(s))), new Promise((r) => setTimeout(r, 700))]).then(() => {
1549
1699
  this.starting = false;
1550
1700
  if (this.disposed)
@@ -1611,6 +1761,31 @@ export class PageFlipper {
1611
1761
  }
1612
1762
  }
1613
1763
  }
1764
+ // The block in the air: carried over, and laid down once it lands.
1765
+ if (this.block) {
1766
+ const { turn, lo, hi } = this.block;
1767
+ const anim = turn.anim;
1768
+ const t = Math.min(1, (now - anim.start) / anim.duration);
1769
+ turn.P = { u: anim.from.u + (anim.to.u - anim.from.u) * ease(t), v: anim.from.v };
1770
+ if (t >= 1) {
1771
+ for (let k = lo; k < hi; k++)
1772
+ this.turned[k] = turn.forward;
1773
+ this.block = null;
1774
+ }
1775
+ }
1776
+ // A long way to go with no leaf in the air: the leaves in between go
1777
+ // over in one block. (Leaves already turning land first: a run of
1778
+ // clicks that has grown long carries on as a block.)
1779
+ const at = this.settledAt();
1780
+ const away = Math.abs(this.target - at);
1781
+ if (!this.block && this.turns.size === 0 && away > 1 && away * this.pagesPerLeaf() > BLOCK_PAGES) {
1782
+ const forward = this.target > at;
1783
+ const lo = forward ? at : this.target;
1784
+ const hi = forward ? this.target : at;
1785
+ const G = { u: this.W, v: 0 };
1786
+ const duration = BLOCK_DURATION * (1 + 0.25 * smooth(BLOCK_PAGES, 400, away * this.pagesPerLeaf()));
1787
+ this.block = { lo, hi, turn: { forward, rigid: true, G, P: { ...G }, anim: { start: now, duration, from: G, to: { u: -G.u, v: 0 }, arc: 0, lands: true } } };
1788
+ }
1614
1789
  // Lift the next ones: each follows its neighbour a little behind, so a
1615
1790
  // jump of several spreads turns every page in between.
1616
1791
  const pending = Math.abs(this.target - this.settledAt()) || 1;
@@ -1621,14 +1796,14 @@ export class PageFlipper {
1621
1796
  return forward ? this.turned[k] : !this.turned[k];
1622
1797
  return turn.forward === forward && !turn.held && progress(turn) >= gap;
1623
1798
  };
1624
- for (let k = 0; k < this.target && k < n; k++) {
1799
+ for (let k = 0; k < this.target && k < n && !this.block; k++) {
1625
1800
  if (this.turned[k] || this.turns.has(k))
1626
1801
  continue;
1627
1802
  if (k > 0 && !follows(k - 1, true))
1628
1803
  break;
1629
1804
  this.turns.set(k, { ...this.lift(true, now), rigid: this.rigidOf(k) });
1630
1805
  }
1631
- for (let k = n - 1; k >= this.target; k--) {
1806
+ for (let k = n - 1; k >= this.target && !this.block; k--) {
1632
1807
  if (!this.turned[k] || this.turns.has(k))
1633
1808
  continue;
1634
1809
  if (k < n - 1 && !follows(k + 1, false))
@@ -1637,7 +1812,7 @@ export class PageFlipper {
1637
1812
  }
1638
1813
  this.draw();
1639
1814
  this.spread.classList.add("is-turning");
1640
- if (this.turns.size > 0 || this.settledAt() !== this.target)
1815
+ if (this.turns.size > 0 || this.block || this.settledAt() !== this.target)
1641
1816
  this.raf = requestAnimationFrame(this.frame);
1642
1817
  else
1643
1818
  this.settle();
@@ -1672,6 +1847,70 @@ export class PageFlipper {
1672
1847
  return { x: 0, y: 1e9 };
1673
1848
  return { x: this.sign * p.x, y: p.y };
1674
1849
  }
1850
+ /** The pointer's ray, from the eye through the pointer. */
1851
+ rayAt(event) {
1852
+ const rect = this.canvas.getBoundingClientRect();
1853
+ const ndc = new Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -(((event.clientY - rect.top) / rect.height) * 2 - 1));
1854
+ this.scene.updateMatrixWorld();
1855
+ this.camera.updateMatrixWorld();
1856
+ this.raycaster.setFromCamera(ndc, this.camera);
1857
+ }
1858
+ /**
1859
+ * The open page under the pointer, as the book is seen (tilted, orbited,
1860
+ * curving into the gutter): its slot in the spread (0 the verso, 1 the
1861
+ * recto) and where on the printed page, as fractions of its width and
1862
+ * height from the top left. Null off the open pages, or where a leaf in
1863
+ * the air hides them.
1864
+ */
1865
+ pagePoint(event) {
1866
+ if (!this.surfaces)
1867
+ this.layout();
1868
+ this.rayAt(event);
1869
+ const open = [this.left, this.right].filter((m) => m.visible);
1870
+ const air = [...this.leaves.values(), ...(this.blockMesh ? [this.blockMesh] : [])].filter((m) => m.visible);
1871
+ for (const m of air)
1872
+ m.geometry.computeBoundingSphere();
1873
+ const hit = this.raycaster.intersectObjects([...open, ...air], false)[0];
1874
+ if (!hit || !hit.uv || (hit.object !== this.left && hit.object !== this.right))
1875
+ return null;
1876
+ // uv.x runs from the spine (the shader mirrors it for a right-bound
1877
+ // book); the recto shows its front, the verso its back (mirrored).
1878
+ const u = this.sign < 0 ? 1 - hit.uv.x : hit.uv.x;
1879
+ const recto = hit.object === this.right;
1880
+ return { side: recto ? 1 : 0, x: recto ? u : 1 - u, y: 1 - hit.uv.y };
1881
+ }
1882
+ /** Where a point of an open page (`pagePoint`'s terms) lies on screen. */
1883
+ screenPoint(side, x, y) {
1884
+ if (!this.surfaces)
1885
+ this.layout();
1886
+ const mesh = side === 1 ? this.right : this.left;
1887
+ if (!mesh.visible)
1888
+ return null;
1889
+ const u0 = side === 1 ? x : 1 - x;
1890
+ const t = Math.min(1, Math.max(0, this.sign < 0 ? 1 - u0 : u0));
1891
+ // The column the point falls in (the columns are closer near the spine).
1892
+ const pos = mesh.geometry.attributes.position;
1893
+ const uv = mesh.geometry.attributes.uv;
1894
+ const cols = NX_OPEN + 1;
1895
+ let ix = 0;
1896
+ while (ix < NX_OPEN - 1 && uv.getX(ix + 1) < t)
1897
+ ix++;
1898
+ const a = uv.getX(ix);
1899
+ const b = uv.getX(ix + 1);
1900
+ const f = b > a ? Math.min(1, Math.max(0, (t - a) / (b - a))) : 0;
1901
+ const lerp = (i, j, k) => {
1902
+ const p = new Vector3(pos.getX(i), pos.getY(i), pos.getZ(i));
1903
+ return p.lerp(new Vector3(pos.getX(j), pos.getY(j), pos.getZ(j)), k);
1904
+ };
1905
+ const top = lerp(ix, ix + 1, f);
1906
+ const bottom = lerp(cols + ix, cols + ix + 1, f);
1907
+ const p = top.lerp(bottom, y);
1908
+ this.scene.updateMatrixWorld();
1909
+ this.camera.updateMatrixWorld();
1910
+ mesh.localToWorld(p).project(this.camera);
1911
+ const rect = this.canvas.getBoundingClientRect();
1912
+ return { x: rect.left + ((p.x + 1) / 2) * rect.width, y: rect.top + ((1 - p.y) / 2) * rect.height };
1913
+ }
1675
1914
  /** A board turns on the joint at the top of the spine; other leaves on
1676
1915
  * the gutter. */
1677
1916
  rigidOf(k) {
@@ -1870,14 +2109,42 @@ export class PageFlipper {
1870
2109
  if (!mesh.parent)
1871
2110
  this.stage.add(mesh);
1872
2111
  });
2112
+ // The block in the air: a slab as thick as its leaves, turning on the
2113
+ // joint at the top of the spine like a board, its first page on top
2114
+ // and its last underneath.
2115
+ if (this.blockMesh) {
2116
+ const caster = this.blockMesh.userData.caster;
2117
+ this.blockMesh.visible = caster.visible = false;
2118
+ const parts = this.blockMesh.userData.board;
2119
+ if (parts)
2120
+ parts.back.visible = parts.slab.visible = false;
2121
+ }
2122
+ if (this.block) {
2123
+ const { lo, hi, turn } = this.block;
2124
+ const mesh = this.blockLeaf(lo, hi);
2125
+ const q = progressFrom(turn.G, turn.P);
2126
+ const thick = this.blockThickness(lo, hi);
2127
+ const parts = this.boardParts(mesh);
2128
+ const lay = (geometry, face) => layLeaf(geometry, null, turn.forward, W, H, 0.5, book, 1, q, 0, 0, { thick, face });
2129
+ const { phi, pivot } = lay(mesh.geometry, 1);
2130
+ lay(parts.back.geometry, -1);
2131
+ parts.slab.position.set((W / 2) * Math.cos(phi), 0, pivot + (W / 2) * Math.sin(phi));
2132
+ parts.slab.rotation.set(0, -phi, 0);
2133
+ parts.slab.scale.set(W, H, thick * 0.97);
2134
+ const spec = this.specOf(lo);
2135
+ parts.slab.material.color.copy(spec === this.coverSpec ? this.boardColor() : new Color(spec.paper.shade.hex));
2136
+ mesh.visible = parts.back.visible = parts.slab.visible = mesh.userData.caster.visible = true;
2137
+ if (!mesh.parent)
2138
+ this.stage.add(mesh);
2139
+ }
1873
2140
  // The pages lying open: the top leaf of each stack.
1874
2141
  let l = -1;
1875
2142
  for (let j = 0; j < n; j++)
1876
- if (this.turned[j] && !this.turns.has(j))
2143
+ if (this.turned[j] && !this.turns.has(j) && !this.inBlock(j))
1877
2144
  l = j;
1878
2145
  let r = n;
1879
2146
  for (let j = n - 1; j >= 0; j--)
1880
- if (!this.turned[j] && !this.turns.has(j))
2147
+ if (!this.turned[j] && !this.turns.has(j) && !this.inBlock(j))
1881
2148
  r = j;
1882
2149
  const leftSrc = this.book[l + 1]?.[0] ?? null;
1883
2150
  const rightSrc = this.book[r]?.[1] ?? null;
@@ -1942,6 +2209,8 @@ export class PageFlipper {
1942
2209
  this.pmrem?.dispose();
1943
2210
  for (const mesh of this.leaves.values())
1944
2211
  disposeLeaf(mesh);
2212
+ if (this.blockMesh)
2213
+ disposeLeaf(this.blockMesh);
1945
2214
  for (const mesh of [this.left, this.right, this.desk, ...this.stacks]) {
1946
2215
  mesh.geometry.dispose();
1947
2216
  mesh.material.dispose();