postext-folio 0.6.1 → 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,4 +1,4 @@
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
2
  import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
3
3
  import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
4
4
  import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
@@ -8,6 +8,7 @@ import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry, topAt } from
8
8
  import { environment } from "./environments";
9
9
  import { pagePaper, paperSpec } from "./paper";
10
10
  import { loadDeskMaps } from "./deskTextures";
11
+ import { ATLAS_MAX, MAX_PAGE_VIDEOS, VideoAtlas } from "./videoAtlas";
11
12
  import { cachedDesk, cachedRelief } from "./procedural";
12
13
  /** Columns and rows of a leaf's mesh: fine enough for a tight roll. */
13
14
  const NX = 96;
@@ -274,11 +275,11 @@ function pageMaterial(mirror, sign) {
274
275
  uLeafOcc: { value: 0.45 },
275
276
  uOccBias: { value: 6 },
276
277
  uVideo: { value: null },
277
- uVidFace: { value: 0 },
278
- uVidO: { value: new Vector2() },
279
- uVidA: { value: new Vector2(1, 0) },
280
- uVidB: { value: new Vector2(0, 1) },
281
- 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()) },
282
283
  uReflection: { value: null },
283
284
  uReflectionOn: { value: 0 },
284
285
  uReflectionSize: { value: new Vector2(1, 1) },
@@ -323,11 +324,11 @@ function pageMaterial(mirror, sign) {
323
324
  uniform float uLeafOcc;
324
325
  uniform float uSign;
325
326
  uniform sampler2D uVideo;
326
- uniform float uVidFace;
327
- uniform vec2 uVidO;
328
- uniform vec2 uVidA;
329
- uniform vec2 uVidB;
330
- 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}];
331
332
  uniform sampler2D uReflection;
332
333
  uniform float uReflectionOn;
333
334
  uniform vec2 uReflectionSize;
@@ -345,15 +346,22 @@ function pageMaterial(mirror, sign) {
345
346
  vec2 uvB = vec2(1.0 - u, vPageUv.y);
346
347
  vec3 ink = uPaper;
347
348
  if (has > 0.5 && has < 1.5) ink = front ? texture2D(uFront, uvF).rgb : texture2D(uBack, uvB).rgb;
348
- // A video playing on this face, printed over its poster: the
349
- // point of the page (from its top left corner) in the picture's
350
- // own frame, which may lie turned on the page.
351
- if (uVidFace > 0.5 && (uVidFace < 1.5) == front) {
352
- vec2 tuv = front ? uvF : uvB;
353
- vec2 p = vec2(tuv.x, 1.0 - tuv.y) - uVidO;
354
- 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));
355
362
  if (st.x >= 0.0 && st.x <= 1.0 && st.y >= 0.0 && st.y <= 1.0) {
356
- 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);
357
365
  ink = texture2D(uVideo, vec2(v.x, 1.0 - v.y)).rgb;
358
366
  }
359
367
  }
@@ -1123,7 +1131,8 @@ export class PageFlipper {
1123
1131
  setAppearance(appearance) {
1124
1132
  this.appearance = appearance;
1125
1133
  this.resolved = resolveFolioConfig(withStockForExtent(appearance.folio, this.pageCount(appearance)));
1126
- this.bookSpec = paperSpec(this.resolved.paper);
1134
+ const mm = appearance.pageWidthMm;
1135
+ this.bookSpec = paperSpec(this.resolved.paper, mm);
1127
1136
  // The settings now give the view the reader orbited to (it was just
1128
1137
  // saved as the book's): they drive it again, so editing them moves it.
1129
1138
  const { yaw, pitch } = this.orbit;
@@ -1140,20 +1149,22 @@ export class PageFlipper {
1140
1149
  const key = JSON.stringify(own);
1141
1150
  let spec = specs.get(key);
1142
1151
  if (!spec)
1143
- specs.set(key, (spec = paperSpec(pagePaper(appearance.folio, own))));
1152
+ specs.set(key, (spec = paperSpec(pagePaper(appearance.folio, own), mm)));
1144
1153
  return spec;
1145
1154
  });
1146
1155
  const r = this.resolved;
1147
1156
  const covers = appearance.coverLeaves;
1148
1157
  const ownCovers = !!covers && (covers.front !== undefined || covers.back !== undefined);
1149
- 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"
1150
1161
  ? null
1151
1162
  : r.binding.type === "saddleStitch"
1152
1163
  ? // A stapled booklet's cover is a sheet like the others, of the same
1153
1164
  // finish and a little heavier: it bends and turns as a page does.
1154
- 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)
1155
1166
  : {
1156
- ...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),
1157
1168
  // The book's own printed covers: in a case binding a printed
1158
1169
  // (litho-laminated) case, on the case's 2–3 mm board; on a
1159
1170
  // paperback a laminated cover card, about 0.3 mm.
@@ -1201,9 +1212,17 @@ export class PageFlipper {
1201
1212
  c.clearcoat = mat === "paper" ? 0.35 : 0;
1202
1213
  c.normalMap?.dispose();
1203
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
+ }
1204
1223
  c.needsUpdate = true;
1205
- // The spine.
1206
- 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);
1207
1226
  if (spine !== this.spineSrc) {
1208
1227
  this.spineSrc = spine;
1209
1228
  this.spineTex?.dispose();
@@ -1463,8 +1482,8 @@ export class PageFlipper {
1463
1482
  const tR = thick(right, extra.after);
1464
1483
  const binding = this.resolved.binding.type;
1465
1484
  // A board on top of a stack lies flat; the document's own covers
1466
- // replace the case.
1467
- const noCase = !!this.coverSpec;
1485
+ // replace the case, and folded sheets have none.
1486
+ const noCase = !!this.coverSpec || binding === "folded";
1468
1487
  const rigid = (leaf) => leaf !== undefined && this.specOf(leaf).rigidity > 0.5;
1469
1488
  const { left: pl, right: pr, board } = profiles(binding, this.W, k, tL, tR, {
1470
1489
  flatLeft: rigid(left[left.length - 1]),
@@ -1529,7 +1548,7 @@ export class PageFlipper {
1529
1548
  this.stacks.push(mesh);
1530
1549
  this.stage.add(mesh);
1531
1550
  }
1532
- if (noCase)
1551
+ if (noCase && binding !== "folded")
1533
1552
  this.coverMaterial.color.copy(this.boardColor());
1534
1553
  // The back of the block: the folds sewn at the spine, under the line
1535
1554
  // where the two open pages meet (nothing shows through the gutter).
@@ -1653,6 +1672,9 @@ export class PageFlipper {
1653
1672
  mesh.material.userData.spec = null;
1654
1673
  }
1655
1674
  this.buildBook();
1675
+ // The videos' slots follow the size the pages are seen at.
1676
+ if (this.videos.length)
1677
+ this.packVideos();
1656
1678
  // The desk: far wider than the view, tiled at its physical scale.
1657
1679
  const k = this.pxPerMm();
1658
1680
  const size = 40 * Math.max(W, H);
@@ -1818,75 +1840,99 @@ export class PageFlipper {
1818
1840
  mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1819
1841
  this.redraw();
1820
1842
  }
1821
- /** The video playing on a page (#477), or none. */
1822
- 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 = "";
1823
1849
  /**
1824
- * Draws a video on the page whose source is `src` (see
1825
- * {@link PageVideoFrame}), over its painting, on whichever face of
1826
- * whichever leaf carries that page: lying open or turning, the picture
1827
- * bends with the paper. `null` takes it off. The book is drawn again at
1828
- * 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.
1829
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}). */
1830
1870
  setVideo(video) {
1831
- const old = this.video;
1832
- if (old && (!video || video.element !== old.element)) {
1833
- old.element.cancelVideoFrameCallback?.(old.callback);
1834
- old.texture.dispose();
1835
- this.video = null;
1836
- }
1837
- if (video) {
1838
- let texture = this.video?.texture;
1839
- if (!texture) {
1840
- texture = new VideoTexture(video.element);
1841
- texture.colorSpace = SRGBColorSpace;
1842
- texture.minFilter = LinearFilter;
1843
- texture.generateMipmaps = false;
1844
- }
1845
- this.video = { ...video, texture, callback: this.video?.callback ?? 0 };
1846
- if (!this.video.callback)
1847
- 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;
1848
1888
  }
1849
- for (const mesh of this.pageMeshes())
1850
- 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();
1851
1899
  this.redraw();
1852
1900
  }
1853
- /** Draws the book again at each new frame of the video (a turning book
1854
- * is drawn by its own frames). */
1855
- watchVideo() {
1856
- const v = this.video;
1857
- if (!v)
1858
- return;
1859
- const el = v.element;
1860
- const next = () => {
1861
- if (this.video?.element !== el || this.disposed)
1862
- return;
1863
- this.redraw();
1864
- v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1865
- if (this.video)
1866
- this.video.callback = v.callback;
1867
- };
1868
- v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1901
+ dressVideos() {
1902
+ for (const mesh of this.pageMeshes())
1903
+ this.dressVideo(mesh);
1869
1904
  }
1870
1905
  /** The meshes that show pages: the open ones, the leaves in the air and
1871
1906
  * the block. */
1872
1907
  pageMeshes() {
1873
1908
  return [this.left, this.right, ...this.leaves.values(), ...(this.blockMesh ? [this.blockMesh] : [])];
1874
1909
  }
1875
- /** Sets a mesh's video uniforms: on the face that shows the video's
1876
- * 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. */
1877
1912
  dressVideo(mesh) {
1878
1913
  const u = mesh.material.userData.uniforms;
1879
- const v = this.video;
1880
- const face = v && v.src ? (mesh.userData.Front === v.src ? 1 : mesh.userData.Back === v.src ? 2 : 0) : 0;
1881
- u.uVidFace.value = face;
1882
- u.uVideo.value = face && v ? v.texture : null;
1883
- if (!face || !v)
1884
- return;
1885
- const { origin, across, down, crop } = v.frame;
1886
- u.uVidO.value.set(origin.x, origin.y);
1887
- u.uVidA.value.set(across.x, across.y);
1888
- u.uVidB.value.set(down.x, down.y);
1889
- 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
+ }
1890
1936
  }
1891
1937
  /** A page source drawn again in place (a canvas repainted): its
1892
1938
  * texture is uploaded again and the book redrawn. */
@@ -2806,7 +2852,7 @@ export class PageFlipper {
2806
2852
  }
2807
2853
  dispose() {
2808
2854
  this.disposed = true;
2809
- this.setVideo(null);
2855
+ this.setVideos([]);
2810
2856
  cancelAnimationFrame(this.raf);
2811
2857
  cancelAnimationFrame(this.still);
2812
2858
  cancelAnimationFrame(this.orbitFrame);