postext-folio 0.2.0 → 0.2.2

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, Color, CustomBlending, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, NeutralToneMapping, OneFactor, OrthographicCamera, PCFShadowMap, PerspectiveCamera, Plane, PlaneGeometry, PMREMGenerator, Raycaster, Scene, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, WebGLRenderer, WebGLRenderTarget, } from "three";
1
+ import { BoxGeometry, BufferAttribute, Color, CustomBlending, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, 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 { resolveFolioConfig } from "postext";
3
3
  import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry } from "./bookGeometry";
4
4
  import { environment } from "./environments";
@@ -58,18 +58,33 @@ const OCCLUSION = /* glsl */ `
58
58
  }
59
59
  return occ / 8.0;
60
60
  }
61
+ // How far the reflection seen at p runs under paper: its ray marched
62
+ // out through the height map (a lifted leaf hides what it would mirror,
63
+ // and so does the leaf's own roll over the inside of its curl). The
64
+ // march starts a few px out, past the paper round p itself.
65
+ float reflectionOcclusion(vec3 p, vec3 r, float channel) {
66
+ if (uShadowOn < 0.5) return 0.0;
67
+ float hidden = 0.0;
68
+ for (int i = 1; i <= 6; i++) {
69
+ float t = 6.0 + float(i * i) / 36.0 * uOccRadius * 2.0;
70
+ vec3 q = p + r * t;
71
+ hidden = max(hidden, smoothstep(0.0, 4.0, casterAt(q.xy, channel) - q.z - uOccBias));
72
+ }
73
+ return hidden;
74
+ }
61
75
  `;
62
76
  /** Declares the book position varying in a vertex shader and sets it:
63
77
  * the world position with the mirror of a right-bound book undone. */
64
78
  const BOOK_POS_PARS = "uniform float uSign;\nvarying vec3 vBookPos;";
65
79
  const BOOK_POS = "vBookPos = (modelMatrix * vec4(transformed, 1.0)).xyz;\nvBookPos.x *= uSign;";
80
+ // The casters lie outside the stage, in the book's own coordinates (as
81
+ // `vBookPos` reads the map): no mirror here (#334).
66
82
  const CASTER_VERTEX = /* glsl */ `
67
- uniform float uSign;
68
83
  varying float vZ;
69
84
  void main() {
70
85
  vec4 w = modelMatrix * vec4(position, 1.0);
71
86
  vZ = w.z;
72
- gl_Position = projectionMatrix * viewMatrix * vec4(w.x * uSign, w.y, 0.0, 1.0);
87
+ gl_Position = projectionMatrix * viewMatrix * vec4(w.xy, 0.0, 1.0);
73
88
  }
74
89
  `;
75
90
  const CASTER_FRAGMENT = /* glsl */ `
@@ -87,7 +102,67 @@ const occlusionUniforms = {
87
102
  uOccRadius: { value: 60 },
88
103
  uOccBias: { value: 1 },
89
104
  };
90
- function casterMaterial(leaf, sign) {
105
+ // The key light's soft shadow, each of its samples compared against the
106
+ // receiver's own plane carried to that sample (receiver-plane depth bias):
107
+ // three.js compares them all at the receiver's depth, so a sheet steep to
108
+ // the light, a leaf in the air, found itself nearer the light a few texels
109
+ // away and fell in its own shadow (#330). The shadow is kept (`keyShadow`)
110
+ // so the reflections can be cut with it too.
111
+ const SHADOW_PCF_HEAD = "float getShadow( sampler2DShadow shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) {";
112
+ const SHADOW_PCF = /* glsl */ `
113
+ ${SHADOW_PCF_HEAD}
114
+ shadowCoord.xyz /= shadowCoord.w;
115
+ shadowCoord.z += shadowBias;
116
+ // How the receiver's depth in the map runs with the map's uv.
117
+ vec3 sx = dFdx(shadowCoord.xyz);
118
+ vec3 sy = dFdy(shadowCoord.xyz);
119
+ float det = sx.x * sy.y - sx.y * sy.x;
120
+ vec2 slope = abs(det) > 1e-12 ? vec2(sy.y * sx.z - sx.y * sy.z, sx.x * sy.z - sy.x * sx.z) / det : vec2(0.0);
121
+ float shadow = 1.0;
122
+ if (shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0 && shadowCoord.z <= 1.0) {
123
+ float radius = shadowRadius / shadowMapSize.x;
124
+ float phi = interleavedGradientNoise(gl_FragCoord.xy) * PI2;
125
+ shadow = 0.0;
126
+ for (int i = 0; i < 5; i++) {
127
+ vec2 o = vogelDiskSample(i, 5, phi) * radius;
128
+ // Held in where the plane runs nearly along the light.
129
+ float dz = clamp(dot(slope, o), -0.05, 0.05);
130
+ shadow += texture(shadowMap, vec3(shadowCoord.xy + o, shadowCoord.z + dz));
131
+ }
132
+ shadow *= 0.2;
133
+ }
134
+ keyShadow = shadow;
135
+ return mix(1.0, shadow, shadowIntensity);
136
+ }
137
+ `;
138
+ const SHADOW_PARS = (() => {
139
+ const chunk = ShaderChunk.shadowmap_pars_fragment;
140
+ const start = chunk.indexOf(SHADOW_PCF_HEAD);
141
+ const end = chunk.indexOf("#elif defined( SHADOWMAP_TYPE_VSM )", start);
142
+ // Another three.js: its own shadows (no receiver plane).
143
+ const pars = start < 0 || end < 0 ? chunk : chunk.slice(0, start) + SHADOW_PCF + chunk.slice(end);
144
+ return `float keyShadow = 1.0;\n${pars}`;
145
+ })();
146
+ /** How much of the environment's reflection the key's shadow takes away:
147
+ * the key stands where the environment's brightest emitter is, so what
148
+ * hides it from a point hides that emitter's reflection too. */
149
+ function keySpecular(occlusion = "1.0") {
150
+ return /* glsl */ `{
151
+ float keySpec = mix(1.0, keyShadow, 0.85) * (${occlusion});
152
+ reflectedLight.indirectSpecular *= keySpec;
153
+ #ifdef USE_CLEARCOAT
154
+ clearcoatSpecularIndirect *= keySpec;
155
+ #endif
156
+ #ifdef USE_SHEEN
157
+ sheenSpecularIndirect *= keySpec;
158
+ #endif
159
+ }`;
160
+ }
161
+ /** Gives a material the key's shadow on its own plane. */
162
+ function keyShadowed(fragmentShader) {
163
+ return fragmentShader.replace("#include <shadowmap_pars_fragment>", SHADOW_PARS);
164
+ }
165
+ function casterMaterial(leaf) {
91
166
  return new ShaderMaterial({
92
167
  vertexShader: CASTER_VERTEX,
93
168
  fragmentShader: CASTER_FRAGMENT,
@@ -99,7 +174,7 @@ function casterMaterial(leaf, sign) {
99
174
  blendDst: OneFactor,
100
175
  depthTest: false,
101
176
  depthWrite: false,
102
- uniforms: { uLeaf: { value: leaf ? 1 : 0 }, uSign: { value: sign } },
177
+ uniforms: { uLeaf: { value: leaf ? 1 : 0 } },
103
178
  });
104
179
  }
105
180
  /** A standard material (the desk, the covers) with the book's occlusion
@@ -110,9 +185,9 @@ function occluded(m, sign) {
110
185
  shader.vertexShader = shader.vertexShader
111
186
  .replace("#include <common>", `#include <common>\n${BOOK_POS_PARS}`)
112
187
  .replace("#include <begin_vertex>", `#include <begin_vertex>\n${BOOK_POS}`);
113
- shader.fragmentShader = shader.fragmentShader
188
+ shader.fragmentShader = keyShadowed(shader.fragmentShader)
114
189
  .replace("#include <common>", `#include <common>\nvarying vec3 vBookPos;\n${OCCLUSION}`)
115
- .replace("#include <aomap_fragment>", "#include <aomap_fragment>\n{ float occ = 1.0 - bookOcclusion(vBookPos, 0.0); reflectedLight.indirectDiffuse *= occ; reflectedLight.indirectSpecular *= occ; }");
190
+ .replace("#include <aomap_fragment>", "#include <aomap_fragment>\n{ float occ = 1.0 - bookOcclusion(vBookPos, 0.0); reflectedLight.indirectDiffuse *= occ; reflectedLight.indirectSpecular *= occ; }\n" + keySpecular());
116
191
  };
117
192
  return m;
118
193
  }
@@ -151,7 +226,7 @@ function pageMaterial(mirror, sign) {
151
226
  vAo = ao;
152
227
  vPageUv = uv;
153
228
  ${BOOK_POS}`);
154
- shader.fragmentShader = shader.fragmentShader
229
+ shader.fragmentShader = keyShadowed(shader.fragmentShader)
155
230
  .replace("#include <common>", `#include <common>
156
231
  uniform sampler2D uFront;
157
232
  uniform sampler2D uBack;
@@ -161,6 +236,7 @@ function pageMaterial(mirror, sign) {
161
236
  uniform float uShowThrough;
162
237
  uniform vec3 uPaper;
163
238
  uniform float uLeafOcc;
239
+ uniform float uSign;
164
240
  varying float vAo;
165
241
  varying vec2 vPageUv;
166
242
  varying vec3 vBookPos;
@@ -190,7 +266,11 @@ function pageMaterial(mirror, sign) {
190
266
  float occ = vAo * (1.0 - uLeafOcc * bookOcclusion(vBookPos, 1.0));
191
267
  reflectedLight.indirectDiffuse *= occ;
192
268
  reflectedLight.indirectSpecular *= mix(1.0, occ, 0.85);
193
- }`);
269
+ }
270
+ // The reflection's ray, in book coordinates.
271
+ vec3 reflected = (vec4(reflect(-geometryViewDir, normal), 0.0) * viewMatrix).xyz;
272
+ reflected.x *= uSign;
273
+ ${keySpecular("1.0 - 0.85 * reflectionOcclusion(vBookPos, reflected, 1.0)")}`);
194
274
  };
195
275
  return m;
196
276
  }
@@ -204,7 +284,7 @@ function edgeMaterial(sign, bands) {
204
284
  shader.vertexShader = shader.vertexShader
205
285
  .replace("#include <common>", `#include <common>\nattribute float layer;\nattribute float lambda;\nvarying float vLayer;\nvarying float vLambda;\n${BOOK_POS_PARS}`)
206
286
  .replace("#include <begin_vertex>", `#include <begin_vertex>\nvLayer = layer;\nvLambda = lambda;\n${BOOK_POS}`);
207
- shader.fragmentShader = shader.fragmentShader
287
+ shader.fragmentShader = keyShadowed(shader.fragmentShader)
208
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}`)
209
289
  .replace("#include <map_fragment>", `{
210
290
  float w = fwidth(vLayer);
@@ -276,8 +356,10 @@ function edgeColorOf(image) {
276
356
  }
277
357
  /** The lowest the reader may orbit the view: 70° from straight above. */
278
358
  const MAX_PITCH = (70 * Math.PI) / 180;
279
- /** How far (radians) a leaf in mid-turn stands off the pages at the spine. */
280
- const LEAN = 0.12;
359
+ /** How far (radians) a leaf in mid-turn stands off the pages at the spine:
360
+ * barely; it is sewn there, and more reads as the leaf lifting off the
361
+ * book parallel to it and dropping back. */
362
+ const LEAN = 0.02;
281
363
  /** Where column `t` (0 … 1) of a page's mesh lies across it: closer
282
364
  * together near the spine, where the gutter bends tightest. */
283
365
  const column = (t) => Math.pow(t, 1.45);
@@ -360,24 +442,39 @@ function reach(G, P, H) {
360
442
  * the spine the left one's. A stiff leaf (`rigidity`) turns more as a
361
443
  * plate on its hinge: a board does not bend at all.
362
444
  */
363
- function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter = 0, time = 0, hingeZ) {
445
+ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter = 0, time = 0, board) {
364
446
  const pos = geometry.attributes.position;
365
447
  const sx = forward ? 1 : -1;
366
448
  const [xr, zr] = along(book.right, W);
367
449
  const [xl, zl] = along(book.left, W);
450
+ const zr0 = along(book.right, 0)[1];
451
+ const zl0 = along(book.left, 0)[1];
368
452
  const qa = forward ? q : 1 - q;
369
453
  let hinge;
370
454
  let phi;
371
455
  let r;
372
- if (hingeZ !== undefined) {
373
- // A board (a case's cover): it turns on the joint at the top of the
374
- // block, and as it comes down to the lower side the joint comes down
375
- // the spine with it (the cloth flexes), so it lands flat on that side
376
- // instead of dropping. Flat on either side, it is exactly its rest.
377
- const descend = zl < zr ? smooth(0.4, 1, qa) : 1 - smooth(0.4, 1, 1 - qa);
378
- hinge = zr + (zl - zr) * descend;
379
- phi = Math.PI * ease(qa);
380
- r = rigidity;
456
+ let offset = 0;
457
+ if (board) {
458
+ // A board (a cover): a rigid slab `thick` deep, turning on the joint at
459
+ // the top of the spine. It leaves the block it lay on (its chord, from
460
+ // the spine to the fore-edge) and lands on the other side's, however
461
+ // that lies: flat on an open book, hanging down to the desk from a
462
+ // spine still standing. As it comes down to the lower side the joint
463
+ // comes down the spine with it (the cloth flexes), so it lands instead
464
+ // of dropping. The pivot is its middle plane: it lies with its inner
465
+ // face on the block under it and lands with its outer face on the
466
+ // other, and `face` sets which of its faces this mesh draws.
467
+ const descend = zl0 < zr0 ? smooth(0.4, 1, qa) : 1 - smooth(0.4, 1, 1 - qa);
468
+ hinge = zr0 + (zl0 - zr0) * descend + board.thick / 2;
469
+ const a0 = Math.atan2(zr - zr0, xr);
470
+ const a1 = Math.PI - Math.atan2(zl - zl0, xl);
471
+ // The hand holds the point it took over the desk: a rigid plate turned
472
+ // by φ brings it to u·cos φ, so the board stands where the hand has
473
+ // carried that point (q = (1 − cos)/2), not on an eased curve that laid
474
+ // it level in the air while the hand was still over the slope.
475
+ phi = a0 + ((a1 - a0) * Math.acos(Math.min(1, Math.max(-1, 1 - 2 * qa)))) / Math.PI;
476
+ r = 1;
477
+ offset = (board.face * board.thick) / 2;
381
478
  }
382
479
  else {
383
480
  // A stiff leaf rests on its stack's fore-edge and swings over the gutter.
@@ -393,7 +490,11 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
393
490
  const lean = LEAN * inAir;
394
491
  const cosA = Math.cos(lean);
395
492
  const sinA = Math.sin(lean);
396
- const floor = along(book.right, 0)[1];
493
+ // Each side's chord, from the spine to its fore-edge: a side hanging
494
+ // from a standing spine slopes down to the desk, and a leaf leaning off
495
+ // it leans off that slope, not off a level plane over it.
496
+ const chordR = (zr - zr0) / (xr || 1);
497
+ const chordL = (zl - zl0) / (xl || 1);
397
498
  for (let iy = 0; iy <= NY; iy++) {
398
499
  const v = (0.5 - iy / NY) * H;
399
500
  for (let ix = 0; ix <= NX; ix++) {
@@ -432,16 +533,16 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
432
533
  let pz = rz + z + lift;
433
534
  if (inAir > 0) {
434
535
  const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * cosA;
435
- const plane = floor + Math.abs(s) * sinA + z + lift;
536
+ const plane = (s >= 0 ? zr0 + s * chordR : zl0 - s * chordL) + Math.abs(s) * sinA + z + lift;
436
537
  const under = surfaceAt(book, fx)[1] + lift + 0.6;
437
- const flying = smoothMax(plane, under, 0.02 * W);
538
+ const flying = smoothMax(plane, under, 0.006 * W);
438
539
  px += (fx - px) * inAir;
439
540
  pz += (flying - pz) * inAir;
440
541
  }
441
542
  if (r > 0) {
442
- px += (u * Math.cos(phi) - px) * r;
543
+ px += (u * Math.cos(phi) - offset * Math.sin(phi) - px) * r;
443
544
  y += (v - y) * r;
444
- pz += (hinge + lift + u * Math.sin(phi) - pz) * r;
545
+ pz += (hinge + lift + u * Math.sin(phi) + offset * Math.cos(phi) - pz) * r;
445
546
  }
446
547
  // Thin paper ripples in the air, more towards its free edge.
447
548
  if (flutter > 0)
@@ -451,6 +552,18 @@ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter
451
552
  }
452
553
  pos.needsUpdate = true;
453
554
  geometry.computeVertexNormals();
555
+ return { phi, pivot: hinge + lift };
556
+ }
557
+ /** Frees a leaf's mesh and a board's parts. */
558
+ function disposeLeaf(mesh) {
559
+ const parts = mesh.userData.board;
560
+ if (parts) {
561
+ parts.back.geometry.dispose();
562
+ parts.slab.geometry.dispose();
563
+ parts.slab.material.dispose();
564
+ }
565
+ mesh.geometry.dispose();
566
+ mesh.material.dispose();
454
567
  }
455
568
  /**
456
569
  * Turns the pages of the viewer's DOM spread in a 3D scene: the book lies
@@ -566,8 +679,8 @@ export class PageFlipper {
566
679
  const mirror = this.sign < 0;
567
680
  this.coverMaterial = occluded(new MeshPhysicalMaterial({ roughness: 0.9, polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 8 }), this.sign);
568
681
  this.edges = edgeMaterial(this.sign);
569
- this.leafCaster = casterMaterial(true, this.sign);
570
- this.staticCaster = casterMaterial(false, this.sign);
682
+ this.leafCaster = casterMaterial(true);
683
+ this.staticCaster = casterMaterial(false);
571
684
  // `__postextFolioPreserve`: keep the drawing buffer (to read the canvas back while debugging).
572
685
  const preserveDrawingBuffer = !!globalThis.__postextFolioPreserve;
573
686
  this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true, preserveDrawingBuffer });
@@ -661,9 +774,11 @@ export class PageFlipper {
661
774
  paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false })
662
775
  : {
663
776
  ...paperSpec({ ...r.paper, type: "board", grammage: 1250, bulk: 1.6, finish: "silk", texture: "smooth", shade: { hex: "#ffffff", model: "hex" }, showThrough: false }),
664
- caliperMm: BINDINGS[r.binding.type].boardMm || 2,
665
- // Board bends a little at the joint as it opens, no more.
666
- rigidity: 0.88,
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),
780
+ // Board does not bend: it turns on its joint as a plate.
781
+ rigidity: 1,
667
782
  roughness: 0.45,
668
783
  clearcoat: 0.35,
669
784
  clearcoatRoughness: 0.2,
@@ -778,22 +893,30 @@ export class PageFlipper {
778
893
  }
779
894
  /** The colour the book's own boards show at their edges and spine: the
780
895
  * setting's cover colour when it names one, else the printed cover's
781
- * ground. */
896
+ * ground (the front's, else the back's). The pages far from the spread
897
+ * open are not drawn, so the colour once sampled is kept: the boards
898
+ * don't turn the default cloth colour near the end of the book. */
782
899
  boardColor() {
783
900
  const set = new Color(this.resolved.binding.coverColor.hex);
784
901
  if (this.appearance.folio?.binding?.coverColor)
785
902
  return set;
786
- const front = this.appearance.coverLeaves?.front;
787
- const src = front !== undefined ? this.book[front]?.[1] : null;
788
- if (!src)
789
- return set;
790
- const tex = this.ready.get(src);
791
- const image = (tex?.image ?? (typeof src === "string" ? null : src));
792
- if (!image || !image.width)
793
- return set;
794
- if (this.sampled?.image !== image)
795
- this.sampled = { image, color: edgeColorOf(image) };
796
- return this.sampled.color ?? set;
903
+ const covers = this.appearance.coverLeaves;
904
+ for (const src of [covers?.front !== undefined ? this.book[covers.front]?.[1] : null, covers?.back !== undefined ? this.book[covers.back + 1]?.[0] : null]) {
905
+ if (!src)
906
+ continue;
907
+ const tex = this.ready.get(src);
908
+ const image = (tex?.image ?? (typeof src === "string" ? null : src));
909
+ if (!image || !image.width)
910
+ continue;
911
+ if (this.sampled?.image !== image) {
912
+ const color = edgeColorOf(image);
913
+ if (!color)
914
+ continue;
915
+ this.sampled = { image, color };
916
+ }
917
+ return this.sampled.color;
918
+ }
919
+ return this.sampled?.color ?? set;
797
920
  }
798
921
  sampled = null;
799
922
  /** The paper of leaf k (outside the pages shown: the book's). */
@@ -1009,7 +1132,9 @@ export class PageFlipper {
1009
1132
  // It reaches the higher of the two sides at the spine (a board lying
1010
1133
  // flat on one side meets it low; the block on the other stands tall).
1011
1134
  const meet = lowSide < 0.5 ? Math.max(tL, tR) + board : Math.max(along(pl, 0)[1], along(pr, 0)[1]);
1012
- const back = Math.min(0.04 * W, Math.max(2, 0.25 * (tL + tR)));
1135
+ // As thick as the cover wrapped round it (the case's board, or the
1136
+ // book's own cover), no more.
1137
+ const back = Math.min(0.04 * W, Math.max(1.5, (this.coverSpec?.caliperMm ?? BINDINGS[binding].boardMm) * k));
1013
1138
  this.clearSpine();
1014
1139
  if (meet > 0.4) {
1015
1140
  // In a case the back is wrapped in the case's spine (the wall beside
@@ -1035,17 +1160,22 @@ export class PageFlipper {
1035
1160
  const reachL = along(pl, W)[0];
1036
1161
  // The boards stop a hair under the block they carry (and give way in
1037
1162
  // depth): a page lying on an empty side never sinks into them.
1038
- const cover = (x0, x1, z1, h) => {
1163
+ // A side hanging from a standing spine takes its board with it, down
1164
+ // the slope to the desk.
1165
+ const cover = (x0, x1, z1, h, p) => {
1039
1166
  const top = Math.max(0.1, z1 - 0.6);
1040
1167
  const m = new Mesh(new BoxGeometry(x1 - x0, h, top), this.coverMaterial);
1041
- m.position.set((x0 + x1) / 2, 0, top / 2);
1168
+ const mid = (x0 + x1) / 2;
1169
+ const slope = p && p.rise > 0 ? Math.atan2(p.rise, p.run) : 0;
1170
+ m.position.set(mid, 0, top / 2 + (p && p.rise > 0 ? p.rise * Math.max(0, 1 - Math.abs(mid) / p.run) : 0));
1171
+ m.rotation.y = mid > 0 ? slope : -slope;
1042
1172
  m.castShadow = m.receiveShadow = true;
1043
1173
  this.covers.add(m);
1044
1174
  };
1045
1175
  const hh = H + 2 * sq;
1046
1176
  if (!noCase) {
1047
- cover(joint, reachR + sq, board, hh);
1048
- cover(-(reachL + sq), -joint, board, hh);
1177
+ cover(joint, reachR + sq, board, hh, pr);
1178
+ cover(-(reachL + sq), -joint, board, hh, pl);
1049
1179
  // The spine under the gutter (a hollow back, a little lower).
1050
1180
  if (joint > 0)
1051
1181
  cover(-joint, joint, board * 0.7, hh);
@@ -1076,7 +1206,10 @@ export class PageFlipper {
1076
1206
  const { W, H } = this;
1077
1207
  const d = 4 * Math.max(W, H);
1078
1208
  const dir = env.key;
1079
- this.key.position.set(dir.x * d, dir.y * d, dir.z * d);
1209
+ // The key stands where the environment's brightest emitter is, and the
1210
+ // environment is not mirrored: in a right-bound book's mirrored stage
1211
+ // the key's x is turned back (#334).
1212
+ this.key.position.set(this.sign * dir.x * d, dir.y * d, dir.z * d);
1080
1213
  this.key.target.position.set(0, 0, 0);
1081
1214
  const cam = this.key.shadow.camera;
1082
1215
  const span = 0.62 * Math.hypot(2 * W, H) + 0.1 * W;
@@ -1192,6 +1325,11 @@ export class PageFlipper {
1192
1325
  const p = this.surfaces?.[side];
1193
1326
  return p ? p.zs[p.zs.length - 1] : 0;
1194
1327
  }
1328
+ /** The height of a side's open page halfway across it. */
1329
+ midZ(side) {
1330
+ const p = this.surfaces?.[side];
1331
+ return p ? along(p, this.W / 2)[1] : 0;
1332
+ }
1195
1333
  /** Turns the view round the book (`dx`, px of a drag) and tilts it
1196
1334
  * (`dy`), never below a low angle over the table. */
1197
1335
  orbitBy(dx, dy) {
@@ -1202,7 +1340,12 @@ export class PageFlipper {
1202
1340
  yaw: this.orbit.yaw - dx * 0.006,
1203
1341
  pitch: Math.min(MAX_PITCH, Math.max(0, pitch - dy * 0.006)),
1204
1342
  };
1205
- this.redraw();
1343
+ // Pages in the air (a run of them still turning): the camera moves at
1344
+ // once and their own frames draw it; at rest, a frame of its own.
1345
+ if (this.raf || this.starting)
1346
+ this.layout();
1347
+ else
1348
+ this.redraw();
1206
1349
  }
1207
1350
  /** Back to the view the settings give, easing there (the reader let go
1208
1351
  * of the view). */
@@ -1293,8 +1436,7 @@ export class PageFlipper {
1293
1436
  for (const mesh of this.leaves.values()) {
1294
1437
  mesh.removeFromParent();
1295
1438
  mesh.userData.caster.removeFromParent();
1296
- mesh.geometry.dispose();
1297
- mesh.material.dispose();
1439
+ disposeLeaf(mesh);
1298
1440
  }
1299
1441
  this.leaves.clear();
1300
1442
  }
@@ -1368,6 +1510,24 @@ export class PageFlipper {
1368
1510
  this.dressPage(mesh, this.specOf(k));
1369
1511
  return mesh;
1370
1512
  }
1513
+ /** A board leaf's other face and edges, carried with its mesh. */
1514
+ boardParts(mesh) {
1515
+ let parts = mesh.userData.board;
1516
+ if (!parts) {
1517
+ const geometry = new PlaneGeometry(1, 1, NX, NY);
1518
+ spreadColumns(geometry, NX);
1519
+ geometry.setAttribute("ao", new BufferAttribute(new Float32Array(geometry.attributes.position.count).fill(1), 1));
1520
+ const back = new Mesh(geometry, mesh.material);
1521
+ back.castShadow = back.receiveShadow = true;
1522
+ back.frustumCulled = false;
1523
+ const slab = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ roughness: 0.8 }));
1524
+ slab.castShadow = slab.receiveShadow = true;
1525
+ mesh.add(back, slab);
1526
+ parts = { back, slab };
1527
+ mesh.userData.board = parts;
1528
+ }
1529
+ return parts;
1530
+ }
1371
1531
  /** Turns the pages until spread `index` lies open. */
1372
1532
  go(index) {
1373
1533
  this.target = Math.max(0, Math.min(this.book.length - 1, index));
@@ -1466,14 +1626,14 @@ export class PageFlipper {
1466
1626
  continue;
1467
1627
  if (k > 0 && !follows(k - 1, true))
1468
1628
  break;
1469
- this.turns.set(k, { ...this.lift(true, now), hinge: this.hingeOf(k, true) });
1629
+ this.turns.set(k, { ...this.lift(true, now), rigid: this.rigidOf(k) });
1470
1630
  }
1471
1631
  for (let k = n - 1; k >= this.target; k--) {
1472
1632
  if (!this.turned[k] || this.turns.has(k))
1473
1633
  continue;
1474
1634
  if (k < n - 1 && !follows(k + 1, false))
1475
1635
  break;
1476
- this.turns.set(k, { ...this.lift(false, now), hinge: this.hingeOf(k, false) });
1636
+ this.turns.set(k, { ...this.lift(false, now), rigid: this.rigidOf(k) });
1477
1637
  }
1478
1638
  this.draw();
1479
1639
  this.spread.classList.add("is-turning");
@@ -1503,8 +1663,8 @@ export class PageFlipper {
1503
1663
  this.raycaster.setFromCamera(ndc, this.camera);
1504
1664
  const hit = new Vector3();
1505
1665
  const at = (z) => (this.raycaster.ray.intersectPlane(new Plane(new Vector3(0, 0, 1), -z), hit) ? { x: hit.x, y: hit.y } : null);
1506
- const zl = this.topZ("left");
1507
- const zr = this.topZ("right");
1666
+ const zl = this.midZ("left");
1667
+ const zr = this.midZ("right");
1508
1668
  let p = at((zl + zr) / 2);
1509
1669
  if (p)
1510
1670
  p = at(p.x * this.sign > 0 ? zr : zl) ?? p;
@@ -1512,12 +1672,10 @@ export class PageFlipper {
1512
1672
  return { x: 0, y: 1e9 };
1513
1673
  return { x: this.sign * p.x, y: p.y };
1514
1674
  }
1515
- /** A board turns on the top of the block it leaves (read before it
1516
- * leaves it); other leaves on the gutter. */
1517
- hingeOf(k, forward) {
1518
- if (this.specOf(k).rigidity <= 0.5)
1519
- return undefined;
1520
- return this.topZ(forward ? "right" : "left");
1675
+ /** A board turns on the joint at the top of the spine; other leaves on
1676
+ * the gutter. */
1677
+ rigidOf(k) {
1678
+ return this.specOf(k).rigidity > 0.5;
1521
1679
  }
1522
1680
  /** The page under the pointer: +1 the one a forward turn takes (the
1523
1681
  * recto), −1 the other, 0 none. */
@@ -1542,7 +1700,7 @@ export class PageFlipper {
1542
1700
  if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W * 1.05 || Math.abs(y) > this.H / 2)
1543
1701
  return false;
1544
1702
  const G = { u: Math.max(0.15 * this.W, Math.min(this.W, Math.abs(x))), v: y };
1545
- this.turns.set(k, { forward, G, P: { ...G }, held: { aim: { ...G }, samples: [] }, hinge: this.hingeOf(k, forward) });
1703
+ this.turns.set(k, { forward, G, P: { ...G }, held: { aim: { ...G }, samples: [] }, rigid: this.rigidOf(k) });
1546
1704
  this.lastFrame = performance.now();
1547
1705
  this.raf = requestAnimationFrame(this.frame);
1548
1706
  return true;
@@ -1553,12 +1711,68 @@ export class PageFlipper {
1553
1711
  if (!turn?.held)
1554
1712
  return;
1555
1713
  const { x, y } = this.toWorld(event);
1556
- turn.held.aim = reach(turn.G, { u: turn.forward ? x : -x, v: y }, this.H);
1714
+ turn.held.aim = turn.rigid && this.surfaces ? this.boardAim(turn, { x }) : reach(turn.G, { u: turn.forward ? x : -x, v: y }, this.H);
1557
1715
  const t = performance.now();
1558
1716
  turn.held.samples.push({ t, p: { ...turn.held.aim } });
1559
1717
  while (turn.held.samples.length > 2 && t - turn.held.samples[0].t > 100)
1560
1718
  turn.held.samples.shift();
1561
1719
  }
1720
+ /** Where the hand holds a board: the angle that puts the point taken on
1721
+ * the pointer's ray (read by `toWorld`), as the hand would hold a plate
1722
+ * turning on its joint; the board then stands under the pointer, high
1723
+ * over the spine or low over the slope, wherever it is seen from. */
1724
+ boardAim(turn, hand) {
1725
+ const book = this.surfaces;
1726
+ const { W } = this;
1727
+ const o = this.raycaster.ray.origin.clone();
1728
+ const d = this.raycaster.ray.direction.clone();
1729
+ o.x *= this.sign;
1730
+ d.x *= this.sign;
1731
+ // The board's two rests and its joint, as `layLeaf` lays them.
1732
+ const [xr, zr] = along(book.right, W);
1733
+ const [xl, zl] = along(book.left, W);
1734
+ const zr0 = along(book.right, 0)[1];
1735
+ const zl0 = along(book.left, 0)[1];
1736
+ const a0 = Math.atan2(zr - zr0, xr);
1737
+ const a1 = Math.PI - Math.atan2(zl - zl0, xl);
1738
+ const hinge = (zr0 + zl0) / 2;
1739
+ const { u, v } = turn.G;
1740
+ // The ray meets the upright plane across the spine through the point
1741
+ // taken (y = v): the board points there from its joint. Seen straight
1742
+ // along that plane, the nearest of its positions to the ray.
1743
+ // Near the joint the ray says little (every angle passes close by):
1744
+ // there the board follows the hand across the desk, the point taken
1745
+ // over the hand (u·cos φ = x).
1746
+ const x = turn.forward ? hand.x : -hand.x;
1747
+ const across = Math.acos(Math.min(1, Math.max(-1, x / u))) / Math.PI;
1748
+ let best;
1749
+ if (Math.abs(d.y) > 1e-4) {
1750
+ const t = (v - o.y) / d.y;
1751
+ const px = o.x + d.x * t;
1752
+ const pz = o.z + d.z * t - hinge;
1753
+ const phi = Math.atan2(pz, px);
1754
+ const ray = (Math.min(Math.max(phi < -Math.PI / 2 ? phi + 2 * Math.PI : phi, a0), a1) - a0) / (a1 - a0 || 1);
1755
+ const sure = smooth(0.15 * u, 0.45 * u, Math.hypot(px, pz));
1756
+ best = across + (ray - across) * sure;
1757
+ }
1758
+ else {
1759
+ const p = new Vector3();
1760
+ let bestD = Infinity;
1761
+ best = 0;
1762
+ for (let i = 0; i <= 180; i++) {
1763
+ const phi = a0 + ((a1 - a0) * i) / 180;
1764
+ const dist = p.set(u * Math.cos(phi), v, hinge + u * Math.sin(phi)).sub(o).cross(d).lengthSq();
1765
+ if (dist < bestD) {
1766
+ bestD = dist;
1767
+ best = i / 180;
1768
+ }
1769
+ }
1770
+ }
1771
+ // Back to the turn's progress (`layLeaf` turns it by acos(1 − 2q)).
1772
+ const qa = (1 - Math.cos(Math.PI * best)) / 2;
1773
+ const q = turn.forward ? qa : 1 - qa;
1774
+ return { u: u * (1 - 2 * q), v };
1775
+ }
1562
1776
  /** Lets go. A click turns the page over from where it was taken; a drag
1563
1777
  * carries on to the nearer side (the throw's speed counted, as if it
1564
1778
  * went on a quarter of a second), the paper easing there from the hand's
@@ -1631,7 +1845,27 @@ export class PageFlipper {
1631
1845
  const lift = (0.5 + 0.6 * ((1 - side) * (air.length - 1 - i) + side * i)) * Math.max(1, spec.caliperMm * k);
1632
1846
  // A floppy leaf flutters in flight (none at rest, none for card).
1633
1847
  const flutter = 0.012 * Math.max(0, 1 / spec.roll - 0.75) * Math.sin(Math.PI * q) * (1 - spec.rigidity);
1634
- layLeaf(mesh.geometry, fold, forward, W, H, lift, book, spec.rigidity, q, flutter, performance.now(), this.turns.get(leafK)?.hinge);
1848
+ const now = performance.now();
1849
+ if (this.turns.get(leafK)?.rigid) {
1850
+ // A board keeps its thickness in the air: its two printed faces
1851
+ // and, between them, its edges.
1852
+ const thick = Math.max(0.5, spec.caliperMm * k);
1853
+ const parts = this.boardParts(mesh);
1854
+ const lay = (geometry, face) => layLeaf(geometry, fold, forward, W, H, Math.min(lift, 0.6), book, 1, q, 0, now, { thick, face });
1855
+ const { phi, pivot } = lay(mesh.geometry, 1);
1856
+ lay(parts.back.geometry, -1);
1857
+ parts.slab.position.set((W / 2) * Math.cos(phi), 0, pivot + (W / 2) * Math.sin(phi));
1858
+ parts.slab.rotation.set(0, -phi, 0);
1859
+ parts.slab.scale.set(W, H, thick * 0.97);
1860
+ parts.slab.material.color.copy(spec === this.coverSpec ? this.boardColor() : new Color(spec.paper.shade.hex));
1861
+ parts.back.visible = parts.slab.visible = true;
1862
+ }
1863
+ else {
1864
+ layLeaf(mesh.geometry, fold, forward, W, H, lift, book, spec.rigidity, q, flutter, now);
1865
+ const parts = mesh.userData.board;
1866
+ if (parts)
1867
+ parts.back.visible = parts.slab.visible = false;
1868
+ }
1635
1869
  mesh.visible = mesh.userData.caster.visible = true;
1636
1870
  if (!mesh.parent)
1637
1871
  this.stage.add(mesh);
@@ -1706,7 +1940,9 @@ export class PageFlipper {
1706
1940
  this.staticCaster.dispose();
1707
1941
  this.scene.environment?.dispose();
1708
1942
  this.pmrem?.dispose();
1709
- for (const mesh of [this.left, this.right, this.desk, ...this.stacks, ...this.leaves.values()]) {
1943
+ for (const mesh of this.leaves.values())
1944
+ disposeLeaf(mesh);
1945
+ for (const mesh of [this.left, this.right, this.desk, ...this.stacks]) {
1710
1946
  mesh.geometry.dispose();
1711
1947
  mesh.material.dispose();
1712
1948
  }