postext-folio 0.2.4 → 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;
@@ -647,6 +722,10 @@ export class PageFlipper {
647
722
  /** Leaf k lies on the left. */
648
723
  turned;
649
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;
650
729
  target;
651
730
  raf = 0;
652
731
  lastFrame = 0;
@@ -789,9 +868,10 @@ export class PageFlipper {
789
868
  paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false })
790
869
  : {
791
870
  ...paperSpec({ ...r.paper, type: "board", grammage: 1250, bulk: 1.6, finish: "silk", texture: "smooth", shade: { hex: "#ffffff", model: "hex" }, showThrough: false }),
792
- // The book's own printed covers: a laminated card board, thinner
793
- // than a case's.
794
- 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,
795
875
  // Board does not bend: it turns on its joint as a plate.
796
876
  rigidity: 1,
797
877
  roughness: 0.45,
@@ -869,6 +949,7 @@ export class PageFlipper {
869
949
  this.key.shadow.intensity = 0.55 + 0.25 * (1 - env.softness);
870
950
  this.renderer.toneMappingExposure = env.exposure * r.lighting.intensity;
871
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;
872
953
  this.surfaceKey = "";
873
954
  for (const mesh of [this.left, this.right, ...this.leaves.values()])
874
955
  mesh.material.userData.spec = null;
@@ -937,8 +1018,13 @@ export class PageFlipper {
937
1018
  /** The paper of leaf k (outside the pages shown: the book's). */
938
1019
  specOf(k) {
939
1020
  const covers = this.appearance.coverLeaves;
940
- if (this.coverSpec && (k === covers?.front || k === covers?.back))
941
- 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
+ }
942
1028
  return this.leafSpecs[k] ?? this.bookSpec;
943
1029
  }
944
1030
  dressPage(mesh, spec) {
@@ -974,11 +1060,20 @@ export class PageFlipper {
974
1060
  const left = [];
975
1061
  const right = [];
976
1062
  this.turned.forEach((t, k) => {
977
- if (!this.turns.has(k))
1063
+ if (!this.turns.has(k) && !this.inBlock(k))
978
1064
  (t ? left : right).push(k);
979
1065
  });
980
1066
  return { left, right };
981
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
+ }
982
1077
  /** Loads the spine picture, then rebuilds the book to print it. */
983
1078
  async loadSpine(src) {
984
1079
  let tex = null;
@@ -1065,6 +1160,7 @@ export class PageFlipper {
1065
1160
  return;
1066
1161
  this.surfaceKey = key;
1067
1162
  const k = this.pxPerMm();
1163
+ edgeUniforms.uEdgeK.value = k;
1068
1164
  const half = this.appearance.singlePage ? 0.5 : 1;
1069
1165
  const thick = (leaves, more) => (leaves.reduce((s, i) => s + this.specOf(i).caliperMm, 0) * half + more * this.bookSpec.caliperMm) * k;
1070
1166
  const tL = thick(left, extra.before);
@@ -1132,7 +1228,7 @@ export class PageFlipper {
1132
1228
  color: this.boardColor(),
1133
1229
  }
1134
1230
  : undefined;
1135
- 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);
1136
1232
  mesh.castShadow = mesh.receiveShadow = true;
1137
1233
  this.stacks.push(mesh);
1138
1234
  this.stage.add(mesh);
@@ -1400,6 +1496,17 @@ export class PageFlipper {
1400
1496
  mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1401
1497
  this.redraw();
1402
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
+ }
1403
1510
  /** Starts loading the pages of these spreads as textures. */
1404
1511
  preload(indexes) {
1405
1512
  for (const i of indexes)
@@ -1448,6 +1555,7 @@ export class PageFlipper {
1448
1555
  cancelAnimationFrame(this.raf);
1449
1556
  this.raf = 0;
1450
1557
  this.turns.clear();
1558
+ this.block = null;
1451
1559
  for (const mesh of this.leaves.values()) {
1452
1560
  mesh.removeFromParent();
1453
1561
  mesh.userData.caster.removeFromParent();
@@ -1507,17 +1615,7 @@ export class PageFlipper {
1507
1615
  leaf(k) {
1508
1616
  let mesh = this.leaves.get(k);
1509
1617
  if (!mesh) {
1510
- const geometry = new PlaneGeometry(1, 1, NX, NY);
1511
- spreadColumns(geometry, NX);
1512
- geometry.setAttribute("ao", new BufferAttribute(new Float32Array(geometry.attributes.position.count).fill(1), 1));
1513
- mesh = new Mesh(geometry, pageMaterial(this.sign < 0, this.sign));
1514
- mesh.material.userData.uniforms.uPaper.value.setRGB(...this.paper, SRGBColorSpace);
1515
- mesh.castShadow = mesh.receiveShadow = true;
1516
- mesh.frustumCulled = false;
1517
- const caster = new Mesh(mesh.geometry, this.leafCaster);
1518
- caster.frustumCulled = false;
1519
- this.shadowScene.add(caster);
1520
- mesh.userData.caster = caster;
1618
+ mesh = this.leafMesh();
1521
1619
  this.assign(mesh, "Front", this.book[k]?.[1] ?? null);
1522
1620
  this.assign(mesh, "Back", this.book[k + 1]?.[0] ?? null);
1523
1621
  this.leaves.set(k, mesh);
@@ -1525,6 +1623,39 @@ export class PageFlipper {
1525
1623
  this.dressPage(mesh, this.specOf(k));
1526
1624
  return mesh;
1527
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
+ }
1528
1659
  /** A board leaf's other face and edges, carried with its mesh. */
1529
1660
  boardParts(mesh) {
1530
1661
  let parts = mesh.userData.board;
@@ -1555,11 +1686,15 @@ export class PageFlipper {
1555
1686
  // The first leaves' pages are warm; wait a moment for cold ones.
1556
1687
  this.starting = true;
1557
1688
  const wanted = [];
1558
- for (let i = lo; i <= Math.min(hi, lo + 2); i++)
1559
- 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] ?? [])
1560
1695
  if (src)
1561
1696
  wanted.push(src);
1562
- 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));
1563
1698
  void Promise.race([Promise.all(wanted.map((s) => this.texture(s))), new Promise((r) => setTimeout(r, 700))]).then(() => {
1564
1699
  this.starting = false;
1565
1700
  if (this.disposed)
@@ -1626,6 +1761,31 @@ export class PageFlipper {
1626
1761
  }
1627
1762
  }
1628
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
+ }
1629
1789
  // Lift the next ones: each follows its neighbour a little behind, so a
1630
1790
  // jump of several spreads turns every page in between.
1631
1791
  const pending = Math.abs(this.target - this.settledAt()) || 1;
@@ -1636,14 +1796,14 @@ export class PageFlipper {
1636
1796
  return forward ? this.turned[k] : !this.turned[k];
1637
1797
  return turn.forward === forward && !turn.held && progress(turn) >= gap;
1638
1798
  };
1639
- for (let k = 0; k < this.target && k < n; k++) {
1799
+ for (let k = 0; k < this.target && k < n && !this.block; k++) {
1640
1800
  if (this.turned[k] || this.turns.has(k))
1641
1801
  continue;
1642
1802
  if (k > 0 && !follows(k - 1, true))
1643
1803
  break;
1644
1804
  this.turns.set(k, { ...this.lift(true, now), rigid: this.rigidOf(k) });
1645
1805
  }
1646
- for (let k = n - 1; k >= this.target; k--) {
1806
+ for (let k = n - 1; k >= this.target && !this.block; k--) {
1647
1807
  if (!this.turned[k] || this.turns.has(k))
1648
1808
  continue;
1649
1809
  if (k < n - 1 && !follows(k + 1, false))
@@ -1652,7 +1812,7 @@ export class PageFlipper {
1652
1812
  }
1653
1813
  this.draw();
1654
1814
  this.spread.classList.add("is-turning");
1655
- if (this.turns.size > 0 || this.settledAt() !== this.target)
1815
+ if (this.turns.size > 0 || this.block || this.settledAt() !== this.target)
1656
1816
  this.raf = requestAnimationFrame(this.frame);
1657
1817
  else
1658
1818
  this.settle();
@@ -1687,6 +1847,70 @@ export class PageFlipper {
1687
1847
  return { x: 0, y: 1e9 };
1688
1848
  return { x: this.sign * p.x, y: p.y };
1689
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
+ }
1690
1914
  /** A board turns on the joint at the top of the spine; other leaves on
1691
1915
  * the gutter. */
1692
1916
  rigidOf(k) {
@@ -1885,14 +2109,42 @@ export class PageFlipper {
1885
2109
  if (!mesh.parent)
1886
2110
  this.stage.add(mesh);
1887
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
+ }
1888
2140
  // The pages lying open: the top leaf of each stack.
1889
2141
  let l = -1;
1890
2142
  for (let j = 0; j < n; j++)
1891
- if (this.turned[j] && !this.turns.has(j))
2143
+ if (this.turned[j] && !this.turns.has(j) && !this.inBlock(j))
1892
2144
  l = j;
1893
2145
  let r = n;
1894
2146
  for (let j = n - 1; j >= 0; j--)
1895
- if (!this.turned[j] && !this.turns.has(j))
2147
+ if (!this.turned[j] && !this.turns.has(j) && !this.inBlock(j))
1896
2148
  r = j;
1897
2149
  const leftSrc = this.book[l + 1]?.[0] ?? null;
1898
2150
  const rightSrc = this.book[r]?.[1] ?? null;
@@ -1957,6 +2209,8 @@ export class PageFlipper {
1957
2209
  this.pmrem?.dispose();
1958
2210
  for (const mesh of this.leaves.values())
1959
2211
  disposeLeaf(mesh);
2212
+ if (this.blockMesh)
2213
+ disposeLeaf(this.blockMesh);
1960
2214
  for (const mesh of [this.left, this.right, this.desk, ...this.stacks]) {
1961
2215
  mesh.geometry.dispose();
1962
2216
  mesh.material.dispose();