postext-folio 0.6.0 → 0.7.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
@@ -1,12 +1,21 @@
1
- import { BoxGeometry, BufferAttribute, BufferGeometry, Color, CustomBlending, DepthTexture, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, Matrix4, NeutralToneMapping, OneFactor, OrthographicCamera, PCFShadowMap, PerspectiveCamera, Plane, PlaneGeometry, PMREMGenerator, Raycaster, Scene, ShaderChunk, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, VideoTexture, WebGLRenderer, WebGLRenderTarget, } from "three";
1
+ import { BoxGeometry, BufferAttribute, BufferGeometry, Color, CustomBlending, DepthTexture, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, Matrix4, NeutralToneMapping, OneFactor, OrthographicCamera, PCFShadowMap, PerspectiveCamera, Plane, PlaneGeometry, PMREMGenerator, Raycaster, Scene, ShaderChunk, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, WebGLRenderer, WebGLRenderTarget, } from "three";
2
+ import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
3
+ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
4
+ import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
5
+ import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
2
6
  import { resolveFolioConfig } from "postext";
3
7
  import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry, topAt } from "./bookGeometry";
4
8
  import { environment } from "./environments";
5
9
  import { pagePaper, paperSpec } from "./paper";
6
10
  import { loadDeskMaps } from "./deskTextures";
11
+ import { ATLAS_MAX, MAX_PAGE_VIDEOS, VideoAtlas } from "./videoAtlas";
7
12
  import { cachedDesk, cachedRelief } from "./procedural";
8
13
  /** Columns and rows of a leaf's mesh: fine enough for a tight roll. */
9
14
  const NX = 96;
15
+ /** How far a leaf's folded paper stays over its own paper beneath it. */
16
+ const OWN_GAP = 1.2;
17
+ /** How much higher than at rest a leaf in the air stays over a block. */
18
+ const BLOCK_GAP = 1.5;
10
19
  const NY = 120;
11
20
  /** Columns of a page lying open (its curve runs across only). */
12
21
  const NX_OPEN = 72;
@@ -23,6 +32,9 @@ export const BLOCK_PAGES = 10;
23
32
  /** How long a block of leaves takes to go over (ms), a little longer for
24
33
  * a thicker one. */
25
34
  const BLOCK_DURATION = 1150;
35
+ /** A page is taken by its outer part only: from this fraction of its
36
+ * width from the spine out to the fore-edge (#494). */
37
+ const GRIP = 0.5;
26
38
  /** A leaf follows the one before it once that one is this far through its turn. */
27
39
  const GAP = 0.14;
28
40
  // Ambient occlusion from the book's own shape: each frame the book is
@@ -263,11 +275,11 @@ function pageMaterial(mirror, sign) {
263
275
  uLeafOcc: { value: 0.45 },
264
276
  uOccBias: { value: 6 },
265
277
  uVideo: { value: null },
266
- uVidFace: { value: 0 },
267
- uVidO: { value: new Vector2() },
268
- uVidA: { value: new Vector2(1, 0) },
269
- uVidB: { value: new Vector2(0, 1) },
270
- uVidCrop: { value: new Vector4(0, 0, 1, 1) },
278
+ uVidTexel: { value: new Vector2(1, 1) },
279
+ uVidCount: { value: 0 },
280
+ uVidOA: { value: Array.from({ length: MAX_PAGE_VIDEOS }, () => new Vector4()) },
281
+ uVidDF: { value: Array.from({ length: MAX_PAGE_VIDEOS }, () => new Vector4()) },
282
+ uVidRect: { value: Array.from({ length: MAX_PAGE_VIDEOS }, () => new Vector4()) },
271
283
  uReflection: { value: null },
272
284
  uReflectionOn: { value: 0 },
273
285
  uReflectionSize: { value: new Vector2(1, 1) },
@@ -312,11 +324,11 @@ function pageMaterial(mirror, sign) {
312
324
  uniform float uLeafOcc;
313
325
  uniform float uSign;
314
326
  uniform sampler2D uVideo;
315
- uniform float uVidFace;
316
- uniform vec2 uVidO;
317
- uniform vec2 uVidA;
318
- uniform vec2 uVidB;
319
- uniform vec4 uVidCrop;
327
+ uniform vec2 uVidTexel;
328
+ uniform float uVidCount;
329
+ uniform vec4 uVidOA[${MAX_PAGE_VIDEOS}];
330
+ uniform vec4 uVidDF[${MAX_PAGE_VIDEOS}];
331
+ uniform vec4 uVidRect[${MAX_PAGE_VIDEOS}];
320
332
  uniform sampler2D uReflection;
321
333
  uniform float uReflectionOn;
322
334
  uniform vec2 uReflectionSize;
@@ -334,15 +346,22 @@ function pageMaterial(mirror, sign) {
334
346
  vec2 uvB = vec2(1.0 - u, vPageUv.y);
335
347
  vec3 ink = uPaper;
336
348
  if (has > 0.5 && has < 1.5) ink = front ? texture2D(uFront, uvF).rgb : texture2D(uBack, uvB).rgb;
337
- // A video playing on this face, printed over its poster: the
338
- // point of the page (from its top left corner) in the picture's
339
- // own frame, which may lie turned on the page.
340
- if (uVidFace > 0.5 && (uVidFace < 1.5) == front) {
341
- vec2 tuv = front ? uvF : uvB;
342
- vec2 p = vec2(tuv.x, 1.0 - tuv.y) - uVidO;
343
- vec2 st = vec2(dot(p, uVidA) / dot(uVidA, uVidA), dot(p, uVidB) / dot(uVidB, uVidB));
349
+ // The videos playing on this face, printed over their posters:
350
+ // the point of the page (from its top left corner) in each
351
+ // picture's own frame, which may lie turned on the page, then in
352
+ // its slot of the atlas (kept half a texel inside it, clear of
353
+ // the next slot).
354
+ vec2 tuv = front ? uvF : uvB;
355
+ for (int i = 0; i < ${MAX_PAGE_VIDEOS}; i++) {
356
+ if (float(i) >= uVidCount) break;
357
+ vec4 oa = uVidOA[i];
358
+ vec4 df = uVidDF[i];
359
+ if ((df.z < 1.5) != front) continue;
360
+ vec2 p = vec2(tuv.x, 1.0 - tuv.y) - oa.xy;
361
+ vec2 st = vec2(dot(p, oa.zw) / dot(oa.zw, oa.zw), dot(p, df.xy) / dot(df.xy, df.xy));
344
362
  if (st.x >= 0.0 && st.x <= 1.0 && st.y >= 0.0 && st.y <= 1.0) {
345
- vec2 v = uVidCrop.xy + st * uVidCrop.zw;
363
+ vec4 r = uVidRect[i];
364
+ vec2 v = clamp(r.xy + st * r.zw, r.xy + 0.5 * uVidTexel, r.xy + r.zw - 0.5 * uVidTexel);
346
365
  ink = texture2D(uVideo, vec2(v.x, 1.0 - v.y)).rgb;
347
366
  }
348
367
  }
@@ -623,12 +642,33 @@ export function foldOf(G, P, W, H, roll = 1) {
623
642
  // straight, lands over the hand: R sinθ + (c − Rθ) cosθ = c − D.
624
643
  const arc = R * theta;
625
644
  const k = arc > 0 ? Math.min(1, D / arc) : 0;
626
- let c = (D + k * (R * Math.sin(theta) - arc * Math.cos(theta))) / (1 - Math.cos(theta));
645
+ const free = (D + k * (R * Math.sin(theta) - arc * Math.cos(theta))) / (1 - Math.cos(theta));
646
+ let c = free;
627
647
  for (const S of [-H / 2, H / 2])
628
648
  c = Math.min(c, G.u * n.u + (G.v - S) * n.v);
629
649
  if (c <= 0)
630
650
  return null;
631
- return { F: { u: G.u - n.u * c, v: G.v - n.v * c }, n, R, theta };
651
+ const F = { u: G.u - n.u * c, v: G.v - n.v * c };
652
+ // Never all at once (#502). Paper lying beyond the point taken, along
653
+ // the pull (a page taken by the middle of an edge and pulled across),
654
+ // turns over as the hand carries it away, not the moment it moves (a
655
+ // pixel's pull stood half the page up). A fold held off the spine short
656
+ // of the hand turns the less the nearer it comes to the point taken, so
657
+ // that it is gone, not dropped, when it reaches it.
658
+ let beyond = 0;
659
+ for (const u of [0, W])
660
+ for (const v of [-H / 2, H / 2])
661
+ beyond = Math.max(beyond, (u - G.u) * n.u + (v - G.v) * n.v);
662
+ const turns = (c / free) * (beyond > 0 ? smooth(0, 0.5 * beyond, D) : 1);
663
+ return { F, n, R, theta: theta * turns };
664
+ }
665
+ /** Paper `d` past the fold, round a roll of radius R and straight on at
666
+ * θ: how far it reaches back across the page, and how high it stands. */
667
+ function rollOf(d, R, theta) {
668
+ if (R > 1e-3 && d < R * theta)
669
+ return [R * Math.sin(d / R), R * (1 - Math.cos(d / R))];
670
+ const rest = d - R * theta;
671
+ return [R * Math.sin(theta) + rest * Math.cos(theta), R * (1 - Math.cos(theta)) + rest * Math.sin(theta)];
632
672
  }
633
673
  /** How far through its turn a leaf is: the point taken, from where it
634
674
  * was to its mirror image across the spine. */
@@ -704,13 +744,17 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
704
744
  // corner barely lifted, a few degrees through the middle of the turn.
705
745
  const inAir = smooth(0.02, 0.22, q) * smooth(0.02, 0.22, 1 - q);
706
746
  const lean = LEAN * inAir;
707
- const cosA = Math.cos(lean);
708
- const sinA = Math.sin(lean);
709
747
  // Each side's chord, from the spine to its fore-edge: a side hanging
710
748
  // from a standing spine slopes down to the desk, and a leaf leaning off
711
749
  // it leans off that slope, not off a level plane over it.
712
750
  const chordR = (zr - zr0) / (xr || 1);
713
751
  const chordL = (zl - zl0) / (xl || 1);
752
+ // The plane a leaf lifts off along, by side: the chord turned up by the
753
+ // lean. A leaf keeps its width along it (stepping the chord's rise per
754
+ // unit across, on a side sloping to the desk, stretched it by a tenth
755
+ // and more, #495).
756
+ const tiltR = Math.atan(chordR) + lean;
757
+ const tiltL = Math.atan(chordL) + lean;
714
758
  // Onto the book. At rest (and as it lands) the leaf lies on the open
715
759
  // book's surface, into the gutter and out. In the air it is lifted off
716
760
  // at the spine: its own plane rises from the gutter's floor at a small
@@ -721,8 +765,9 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
721
765
  let px = rx;
722
766
  let pz = rz + z + lift;
723
767
  if (inAir > 0) {
724
- const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * cosA;
725
- const plane = (s >= 0 ? zr0 + s * chordR : zl0 - s * chordL) + Math.abs(s) * sinA + z + lift;
768
+ const tilt = s >= 0 ? tiltR : tiltL;
769
+ const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * Math.cos(tilt);
770
+ const plane = (s >= 0 ? zr0 : zl0) + Math.abs(s) * Math.sin(tilt) + z + lift;
726
771
  const under = surfaceAt(book, fx)[1] + lift + 0.6;
727
772
  const flying = smoothMax(plane, under, 0.006 * W);
728
773
  px += (fx - px) * inAir;
@@ -730,6 +775,33 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
730
775
  }
731
776
  return [px, pz];
732
777
  };
778
+ // Thin paper ripples in the air, more towards its free edge. Never
779
+ // down into the pages under it: the ripple dies away where the leaf
780
+ // lies close over them (it stays at least `lift` above them), or a
781
+ // leaf held low over its stack dipped into the page under it, whose
782
+ // print then showed through (#447).
783
+ const ripple = (u, v, px, pz) => {
784
+ const amp = flutter * W * (u / W) * (u / W);
785
+ if (flutter <= 0 || amp <= 1e-6)
786
+ return 0;
787
+ const clear = pz - surfaceAt(book, px)[1] - lift;
788
+ return amp * smooth(0, 2 * amp, clear) * Math.sin(2 * Math.PI * (time / 520) - (3 * u) / W + (2 * v) / H);
789
+ };
790
+ // Where the leaf's paper lies unfolded, column by column (before it
791
+ // ripples): [out from the spine, u, height].
792
+ const clearing = BLOCK_GAP * smooth(0, 0.06, q) * smooth(0, 0.06, 1 - q);
793
+ const own = [];
794
+ if (fold) {
795
+ for (let ix = 0; ix <= NX; ix++) {
796
+ const u = column(ix / NX) * W;
797
+ let [ox, oz] = onBook(sx * u, 0);
798
+ if (r > 0) {
799
+ ox += (u * Math.cos(phi) - offset * Math.sin(phi) - ox) * r;
800
+ oz += (hinge + lift + u * Math.sin(phi) + offset * Math.cos(phi) - oz) * r;
801
+ }
802
+ own.push([sx * ox, u, oz]);
803
+ }
804
+ }
733
805
  for (let iy = 0; iy <= NY; iy++) {
734
806
  const v = (0.5 - iy / NY) * H;
735
807
  for (let ix = 0; ix <= NX; ix++) {
@@ -740,22 +812,13 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
740
812
  let d = 0;
741
813
  let dd = 0;
742
814
  if (fold) {
743
- const { F, n, R } = fold;
815
+ const { F, n } = fold;
744
816
  d = (u - F.u) * n.u + (v - F.v) * n.v;
745
817
  if (d > 0) {
746
818
  // Round the roll, then straight on at the fold's angle.
747
- const theta = fold.theta;
748
- if (R > 1e-3 && d < R * theta) {
749
- dd = R * Math.sin(d / R);
750
- z = R * (1 - Math.cos(d / R));
751
- }
752
- else {
753
- const rest = d - R * theta;
754
- dd = R * Math.sin(theta) + rest * Math.cos(theta);
755
- z = R * (1 - Math.cos(theta)) + rest * Math.sin(theta);
756
- }
757
- x = u - n.u * (d - dd);
819
+ [dd, z] = rollOf(d, fold.R, fold.theta);
758
820
  y = v - n.v * (d - dd);
821
+ x = u - n.u * (d - dd);
759
822
  }
760
823
  }
761
824
  let [px, pz] = onBook(sx * x, z);
@@ -775,24 +838,42 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
775
838
  y += (v - y) * r;
776
839
  pz += (hinge + lift + u * Math.sin(phi) + offset * Math.cos(phi) - pz) * r;
777
840
  }
778
- // Thin paper ripples in the air, more towards its free edge. Never
779
- // down into the pages under it: the ripple dies away where the leaf
780
- // lies close over them (it stays at least `lift` above them), or a
781
- // leaf held low over its stack dipped into the page under it, whose
782
- // print then showed through (#447).
783
- if (flutter > 0) {
784
- const amp = flutter * W * (u / W) * (u / W);
785
- const clear = pz - surfaceAt(book, px)[1] - lift;
786
- if (amp > 1e-6)
787
- pz += amp * smooth(0, 2 * amp, clear) * Math.sin(2 * Math.PI * (time / 520) - (3 * u) / W + (2 * v) / H);
841
+ pz += ripple(u, v, px, pz);
842
+ // Never through the leaf's own paper not yet folded, as it ripples
843
+ // too: carried level from a fold low on a side sloping up to the
844
+ // spine, the paper folded back over it ran under it, crossing it.
845
+ if (fold && d > 0) {
846
+ const at = sx * px;
847
+ // (A row past the head and foot too: a triangle reaching past them
848
+ // from paper kept over sagged through.)
849
+ if (at > own[0][0] && at < own[NX][0] && Math.abs(y) <= H / 2 + (1.5 * H) / NY) {
850
+ let lo = 0;
851
+ let hi = NX;
852
+ while (hi - lo > 1) {
853
+ const mid = (lo + hi) >> 1;
854
+ if (own[mid][0] <= at)
855
+ lo = mid;
856
+ else
857
+ hi = mid;
858
+ }
859
+ const f = (at - own[lo][0]) / (own[hi][0] - own[lo][0] || 1);
860
+ const ou = own[lo][1] + (own[hi][1] - own[lo][1]) * f;
861
+ if ((ou - fold.F.u) * fold.n.u + (y - fold.F.v) * fold.n.v < 0) {
862
+ const oz = own[lo][2] + (own[hi][2] - own[lo][2]) * f;
863
+ pz = Math.max(pz, oz + ripple(ou, y, px, oz) + OWN_GAP);
864
+ }
865
+ }
788
866
  }
789
867
  // Never into the book: over a block (and past its head or foot) a
790
868
  // leaf stands at least just over its top. A fold's flap is carried
791
869
  // level from the foot of its fold, and on a thin side hanging from
792
870
  // the spine that foot lies low: the flap went through the tall block
793
871
  // on the other side (#489).
872
+ // In the air a little higher still (until it lands): half a pixel
873
+ // over the block is hardly any depth at the view's distance, and a
874
+ // leaf lying over a tall block's top drew through its page in teeth.
794
875
  if (!board && Math.abs(px) < (px >= 0 ? xr : xl))
795
- pz = Math.max(pz, topAt(px >= 0 ? book.right : book.left, Math.abs(px)) + Math.min(lift, 0.5));
876
+ pz = Math.max(pz, topAt(px >= 0 ? book.right : book.left, Math.abs(px)) + Math.min(lift, 0.5) + clearing);
796
877
  pos.setXYZ(iy * (NX + 1) + ix, px, y, pz);
797
878
  }
798
879
  }
@@ -857,6 +938,7 @@ export class PageFlipper {
857
938
  renderer;
858
939
  scene = new Scene();
859
940
  camera = new PerspectiveCamera(FOV, 1, 1, 10000);
941
+ composer;
860
942
  left;
861
943
  right;
862
944
  desk;
@@ -965,10 +1047,20 @@ export class PageFlipper {
965
1047
  this.staticCaster = casterMaterial(false);
966
1048
  // `__postextFolioPreserve`: keep the drawing buffer (to read the canvas back while debugging).
967
1049
  const preserveDrawingBuffer = !!globalThis.__postextFolioPreserve;
968
- this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true, preserveDrawingBuffer });
1050
+ this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: false, premultipliedAlpha: true, preserveDrawingBuffer });
969
1051
  this.renderer.outputColorSpace = SRGBColorSpace;
970
1052
  this.renderer.toneMapping = NeutralToneMapping;
971
1053
  this.renderer.setClearColor(0x000000, 0);
1054
+ // Edges smoothed by SMAA over the finished picture (#503), not by the
1055
+ // canvas's own four samples a pixel: those left steps on long, nearly
1056
+ // level edges of strong contrast (a light page over the dark block)
1057
+ // and cost the most of a frame (resolved into a preserved drawing
1058
+ // buffer); SMAA smooths them better for a fraction of it. The scene
1059
+ // is drawn into a float picture, toned for the screen, then smoothed.
1060
+ this.composer = new EffectComposer(this.renderer, new WebGLRenderTarget(1, 1, { type: HalfFloatType }));
1061
+ this.composer.addPass(new RenderPass(this.scene, this.camera));
1062
+ this.composer.addPass(new OutputPass());
1063
+ this.composer.addPass(new SMAAPass());
972
1064
  if (this.renderer.shadowMap) {
973
1065
  this.renderer.shadowMap.enabled = true;
974
1066
  this.renderer.shadowMap.type = PCFShadowMap;
@@ -1039,7 +1131,8 @@ export class PageFlipper {
1039
1131
  setAppearance(appearance) {
1040
1132
  this.appearance = appearance;
1041
1133
  this.resolved = resolveFolioConfig(withStockForExtent(appearance.folio, this.pageCount(appearance)));
1042
- this.bookSpec = paperSpec(this.resolved.paper);
1134
+ const mm = appearance.pageWidthMm;
1135
+ this.bookSpec = paperSpec(this.resolved.paper, mm);
1043
1136
  // The settings now give the view the reader orbited to (it was just
1044
1137
  // saved as the book's): they drive it again, so editing them moves it.
1045
1138
  const { yaw, pitch } = this.orbit;
@@ -1056,20 +1149,22 @@ export class PageFlipper {
1056
1149
  const key = JSON.stringify(own);
1057
1150
  let spec = specs.get(key);
1058
1151
  if (!spec)
1059
- specs.set(key, (spec = paperSpec(pagePaper(appearance.folio, own))));
1152
+ specs.set(key, (spec = paperSpec(pagePaper(appearance.folio, own), mm)));
1060
1153
  return spec;
1061
1154
  });
1062
1155
  const r = this.resolved;
1063
1156
  const covers = appearance.coverLeaves;
1064
1157
  const ownCovers = !!covers && (covers.front !== undefined || covers.back !== undefined);
1065
- this.coverSpec = !ownCovers
1158
+ // A newspaper's front page is the outer sheet, no heavier than the
1159
+ // others: it turns as a page does.
1160
+ this.coverSpec = !ownCovers || r.binding.type === "folded"
1066
1161
  ? null
1067
1162
  : r.binding.type === "saddleStitch"
1068
1163
  ? // A stapled booklet's cover is a sheet like the others, of the same
1069
1164
  // finish and a little heavier: it bends and turns as a page does.
1070
- paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false })
1165
+ paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false }, mm)
1071
1166
  : {
1072
- ...paperSpec({ ...r.paper, type: "board", grammage: 1250, bulk: 1.6, finish: "silk", texture: "smooth", shade: { hex: "#ffffff", model: "hex" }, showThrough: false }),
1167
+ ...paperSpec({ ...r.paper, type: "board", grammage: 1250, bulk: 1.6, finish: "silk", texture: "smooth", shade: { hex: "#ffffff", model: "hex" }, showThrough: false }, mm),
1073
1168
  // The book's own printed covers: in a case binding a printed
1074
1169
  // (litho-laminated) case, on the case's 2–3 mm board; on a
1075
1170
  // paperback a laminated cover card, about 0.3 mm.
@@ -1117,9 +1212,17 @@ export class PageFlipper {
1117
1212
  c.clearcoat = mat === "paper" ? 0.35 : 0;
1118
1213
  c.normalMap?.dispose();
1119
1214
  c.normalMap = mat === "cloth" ? cachedRelief("linen").normal.clone() : mat === "leather" ? cachedDesk("leather").normal.clone() : null;
1215
+ if (r.binding.type === "folded") {
1216
+ // No cover at all: the back of the fold is the outer sheet's paper.
1217
+ c.color.set(r.paper.shade.hex);
1218
+ c.roughness = this.bookSpec.roughness;
1219
+ c.sheen = this.bookSpec.sheen;
1220
+ c.sheenColor.set(1, 1, 1);
1221
+ c.clearcoat = this.bookSpec.clearcoat;
1222
+ }
1120
1223
  c.needsUpdate = true;
1121
- // The spine.
1122
- const spine = r.binding.type === "saddleStitch" ? null : (appearance.spineImage ?? null);
1224
+ // The spine: none on a stapled or folded book.
1225
+ const spine = r.binding.type === "saddleStitch" || r.binding.type === "folded" ? null : (appearance.spineImage ?? null);
1123
1226
  if (spine !== this.spineSrc) {
1124
1227
  this.spineSrc = spine;
1125
1228
  this.spineTex?.dispose();
@@ -1379,8 +1482,8 @@ export class PageFlipper {
1379
1482
  const tR = thick(right, extra.after);
1380
1483
  const binding = this.resolved.binding.type;
1381
1484
  // A board on top of a stack lies flat; the document's own covers
1382
- // replace the case.
1383
- const noCase = !!this.coverSpec;
1485
+ // replace the case, and folded sheets have none.
1486
+ const noCase = !!this.coverSpec || binding === "folded";
1384
1487
  const rigid = (leaf) => leaf !== undefined && this.specOf(leaf).rigidity > 0.5;
1385
1488
  const { left: pl, right: pr, board } = profiles(binding, this.W, k, tL, tR, {
1386
1489
  flatLeft: rigid(left[left.length - 1]),
@@ -1445,7 +1548,7 @@ export class PageFlipper {
1445
1548
  this.stacks.push(mesh);
1446
1549
  this.stage.add(mesh);
1447
1550
  }
1448
- if (noCase)
1551
+ if (noCase && binding !== "folded")
1449
1552
  this.coverMaterial.color.copy(this.boardColor());
1450
1553
  // The back of the block: the folds sewn at the spine, under the line
1451
1554
  // where the two open pages meet (nothing shows through the gutter).
@@ -1555,6 +1658,8 @@ export class PageFlipper {
1555
1658
  const dpr = Math.min(window.devicePixelRatio || 1, 3);
1556
1659
  this.renderer.setPixelRatio(1);
1557
1660
  this.renderer.setSize(Math.round(cw * dpr), Math.round(ch * dpr), false);
1661
+ this.composer.setPixelRatio(1);
1662
+ this.composer.setSize(Math.round(cw * dpr), Math.round(ch * dpr));
1558
1663
  this.buffer.set(Math.round(cw * dpr), Math.round(ch * dpr));
1559
1664
  this.dpr = dpr;
1560
1665
  const spread = this.spread.getBoundingClientRect();
@@ -1567,6 +1672,9 @@ export class PageFlipper {
1567
1672
  mesh.material.userData.spec = null;
1568
1673
  }
1569
1674
  this.buildBook();
1675
+ // The videos' slots follow the size the pages are seen at.
1676
+ if (this.videos.length)
1677
+ this.packVideos();
1570
1678
  // The desk: far wider than the view, tiled at its physical scale.
1571
1679
  const k = this.pxPerMm();
1572
1680
  const size = 40 * Math.max(W, H);
@@ -1732,75 +1840,99 @@ export class PageFlipper {
1732
1840
  mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1733
1841
  this.redraw();
1734
1842
  }
1735
- /** The video playing on a page (#477), or none. */
1736
- video = null;
1843
+ /** The videos playing on the pages (#477, #507). */
1844
+ videos = [];
1845
+ /** Their pictures, drawn into one texture. */
1846
+ atlas = null;
1847
+ /** The slot sizes the atlas was packed for. */
1848
+ atlasKey = "";
1737
1849
  /**
1738
- * Draws a video on the page whose source is `src` (see
1739
- * {@link PageVideoFrame}), over its painting, on whichever face of
1740
- * whichever leaf carries that page: lying open or turning, the picture
1741
- * bends with the paper. `null` takes it off. The book is drawn again at
1742
- * every new frame of the video.
1850
+ * Draws videos on the pages whose sources are their `src` (see
1851
+ * {@link PageVideoFrame}), over their paintings, on whichever face of
1852
+ * whichever leaf carries each page: lying open or turning, a picture
1853
+ * bends with the paper. Any number play together, up to
1854
+ * `MAX_PAGE_VIDEOS` (8) drawn at once; an empty list takes them all off.
1855
+ * The book is drawn again at every new frame of any of them.
1743
1856
  */
1857
+ setVideos(videos) {
1858
+ const crop = (v) => JSON.stringify(v.frame.crop ?? null);
1859
+ // The same pictures (a page painted on a new canvas, a layout that
1860
+ // moved one): the atlas stays as it is unless their sizes changed.
1861
+ const same = videos.length === this.videos.length && videos.every((v, i) => v.element === this.videos[i].element && crop(v) === crop(this.videos[i]));
1862
+ this.videos = videos.slice();
1863
+ if (!same)
1864
+ this.atlasKey = "";
1865
+ this.packVideos();
1866
+ this.dressVideos();
1867
+ this.redraw();
1868
+ }
1869
+ /** One video on a page, or none (see {@link setVideos}). */
1744
1870
  setVideo(video) {
1745
- const old = this.video;
1746
- if (old && (!video || video.element !== old.element)) {
1747
- old.element.cancelVideoFrameCallback?.(old.callback);
1748
- old.texture.dispose();
1749
- this.video = null;
1750
- }
1751
- if (video) {
1752
- let texture = this.video?.texture;
1753
- if (!texture) {
1754
- texture = new VideoTexture(video.element);
1755
- texture.colorSpace = SRGBColorSpace;
1756
- texture.minFilter = LinearFilter;
1757
- texture.generateMipmaps = false;
1758
- }
1759
- this.video = { ...video, texture, callback: this.video?.callback ?? 0 };
1760
- if (!this.video.callback)
1761
- this.watchVideo();
1871
+ this.setVideos(video ? [video] : []);
1872
+ }
1873
+ /** Packs the videos' pictures into the atlas, each at about the size it
1874
+ * is seen on screen at rest, again when the book's size changes. */
1875
+ packVideos() {
1876
+ const sizes = this.videos.map(({ frame }) => {
1877
+ const w = Math.hypot(frame.across.x * this.W, frame.across.y * this.H) * this.dpr;
1878
+ const h = Math.hypot(frame.down.x * this.W, frame.down.y * this.H) * this.dpr;
1879
+ return { width: Math.max(16, Math.round(w)), height: Math.max(16, Math.round(h)) };
1880
+ });
1881
+ const key = this.videos.map((v, i) => `${sizes[i].width}x${sizes[i].height}`).join(",") + `|${this.videos.length}`;
1882
+ if (key === this.atlasKey && (this.atlas || !this.videos.length))
1883
+ return;
1884
+ this.atlasKey = key;
1885
+ if (!this.videos.length) {
1886
+ this.atlas?.dispose();
1887
+ this.atlas = null;
1762
1888
  }
1763
- for (const mesh of this.pageMeshes())
1764
- this.dressVideo(mesh);
1889
+ else {
1890
+ const max = Math.min(ATLAS_MAX, this.renderer.capabilities.maxTextureSize ?? ATLAS_MAX);
1891
+ this.atlas ??= new VideoAtlas((first) => {
1892
+ if (first)
1893
+ this.dressVideos();
1894
+ this.redraw();
1895
+ }, max);
1896
+ this.atlas.set(this.videos.map((v, i) => ({ element: v.element, ...(v.frame.crop ? { crop: v.frame.crop } : {}), ...sizes[i] })));
1897
+ }
1898
+ this.dressVideos();
1765
1899
  this.redraw();
1766
1900
  }
1767
- /** Draws the book again at each new frame of the video (a turning book
1768
- * is drawn by its own frames). */
1769
- watchVideo() {
1770
- const v = this.video;
1771
- if (!v)
1772
- return;
1773
- const el = v.element;
1774
- const next = () => {
1775
- if (this.video?.element !== el || this.disposed)
1776
- return;
1777
- this.redraw();
1778
- v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1779
- if (this.video)
1780
- this.video.callback = v.callback;
1781
- };
1782
- v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1901
+ dressVideos() {
1902
+ for (const mesh of this.pageMeshes())
1903
+ this.dressVideo(mesh);
1783
1904
  }
1784
1905
  /** The meshes that show pages: the open ones, the leaves in the air and
1785
1906
  * the block. */
1786
1907
  pageMeshes() {
1787
1908
  return [this.left, this.right, ...this.leaves.values(), ...(this.blockMesh ? [this.blockMesh] : [])];
1788
1909
  }
1789
- /** Sets a mesh's video uniforms: on the face that shows the video's
1790
- * page, off elsewhere. */
1910
+ /** Sets a mesh's video uniforms: the videos on the faces that show their
1911
+ * pages (once their pictures are in the atlas), none elsewhere. */
1791
1912
  dressVideo(mesh) {
1792
1913
  const u = mesh.material.userData.uniforms;
1793
- const v = this.video;
1794
- const face = v && v.src ? (mesh.userData.Front === v.src ? 1 : mesh.userData.Back === v.src ? 2 : 0) : 0;
1795
- u.uVidFace.value = face;
1796
- u.uVideo.value = face && v ? v.texture : null;
1797
- if (!face || !v)
1798
- return;
1799
- const { origin, across, down, crop } = v.frame;
1800
- u.uVidO.value.set(origin.x, origin.y);
1801
- u.uVidA.value.set(across.x, across.y);
1802
- u.uVidB.value.set(down.x, down.y);
1803
- u.uVidCrop.value.set(crop?.x ?? 0, crop?.y ?? 0, crop?.width ?? 1, crop?.height ?? 1);
1914
+ const atlas = this.atlas;
1915
+ let n = 0;
1916
+ for (const v of atlas ? this.videos : []) {
1917
+ if (n >= MAX_PAGE_VIDEOS)
1918
+ break;
1919
+ // A blank page ("") is no page of its own: it shows no video.
1920
+ const face = v.src ? (mesh.userData.Front === v.src ? 1 : mesh.userData.Back === v.src ? 2 : 0) : 0;
1921
+ const rect = face ? atlas.rectOf(v.element) : null;
1922
+ if (!rect)
1923
+ continue;
1924
+ const { origin, across, down } = v.frame;
1925
+ u.uVidOA.value[n].set(origin.x, origin.y, across.x, across.y);
1926
+ u.uVidDF.value[n].set(down.x, down.y, face, 0);
1927
+ u.uVidRect.value[n].set(rect.x, rect.y, rect.width, rect.height);
1928
+ n++;
1929
+ }
1930
+ u.uVidCount.value = n;
1931
+ u.uVideo.value = n ? atlas.texture : null;
1932
+ if (n) {
1933
+ const size = atlas.size;
1934
+ u.uVidTexel.value.set(1 / size.width, 1 / size.height);
1935
+ }
1804
1936
  }
1805
1937
  /** A page source drawn again in place (a canvas repainted): its
1806
1938
  * texture is uploaded again and the book redrawn. */
@@ -2266,13 +2398,14 @@ export class PageFlipper {
2266
2398
  rigidOf(k) {
2267
2399
  return this.specOf(k).rigidity > 0.5;
2268
2400
  }
2269
- /** The page under the pointer: +1 the one a forward turn takes (the
2270
- * recto), −1 the other, 0 none. */
2401
+ /** The page under the pointer, by its outer part (where a hand takes
2402
+ * it): +1 the one a forward turn takes (the recto), −1 the other, 0
2403
+ * none (off the pages, or nearer the spine). */
2271
2404
  hit(event) {
2272
2405
  if (!this.surfaces)
2273
2406
  this.layout();
2274
2407
  const { x, y } = this.toWorld(event);
2275
- if (Math.abs(y) > this.H / 2 || Math.abs(x) > this.W * 1.05)
2408
+ if (Math.abs(y) > this.H / 2 || Math.abs(x) > this.W * 1.05 || Math.abs(x) < GRIP * this.W)
2276
2409
  return 0;
2277
2410
  return x > 0 ? 1 : -1;
2278
2411
  }
@@ -2286,7 +2419,7 @@ export class PageFlipper {
2286
2419
  const at = this.settledAt();
2287
2420
  const forward = x > 0;
2288
2421
  const k = forward ? at : at - 1;
2289
- if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W * 1.05 || Math.abs(y) > this.H / 2)
2422
+ if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W * 1.05 || Math.abs(x) < GRIP * this.W || Math.abs(y) > this.H / 2)
2290
2423
  return false;
2291
2424
  const G = { u: Math.max(0.15 * this.W, Math.min(this.W, Math.abs(x))), v: y };
2292
2425
  this.turns.set(k, { forward, G, P: { ...G }, held: { aim: { ...G }, samples: [] }, rigid: this.rigidOf(k) });
@@ -2531,7 +2664,7 @@ export class PageFlipper {
2531
2664
  }
2532
2665
  this.mirror(air.length > 0 || this.block !== null);
2533
2666
  this.behind(air.map((a) => a.mesh));
2534
- this.renderer.render(this.scene, this.camera);
2667
+ this.composer.render();
2535
2668
  }
2536
2669
  /**
2537
2670
  * Thin paper lets light through (#484). While translucent leaves are in
@@ -2719,13 +2852,14 @@ export class PageFlipper {
2719
2852
  }
2720
2853
  dispose() {
2721
2854
  this.disposed = true;
2722
- this.setVideo(null);
2855
+ this.setVideos([]);
2723
2856
  cancelAnimationFrame(this.raf);
2724
2857
  cancelAnimationFrame(this.still);
2725
2858
  cancelAnimationFrame(this.orbitFrame);
2726
2859
  for (const tex of this.ready.values())
2727
2860
  tex.dispose();
2728
2861
  this.shadowTarget?.dispose();
2862
+ this.composer.dispose();
2729
2863
  this.mirrors.left?.dispose();
2730
2864
  this.mirrors.right?.dispose();
2731
2865
  this.behindTarget?.depthTexture?.dispose();