postext-folio 0.1.2 → 0.2.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.
Files changed (42) hide show
  1. package/README.md +59 -4
  2. package/dist/bookGeometry.d.ts +77 -0
  3. package/dist/bookGeometry.d.ts.map +1 -0
  4. package/dist/bookGeometry.js +204 -0
  5. package/dist/bookGeometry.js.map +1 -0
  6. package/dist/deskTextures.d.ts +24 -0
  7. package/dist/deskTextures.d.ts.map +1 -0
  8. package/dist/deskTextures.js +88 -0
  9. package/dist/deskTextures.js.map +1 -0
  10. package/dist/environments.d.ts +27 -0
  11. package/dist/environments.d.ts.map +1 -0
  12. package/dist/environments.js +90 -0
  13. package/dist/environments.js.map +1 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/pageFlip.d.ts +160 -26
  19. package/dist/pageFlip.d.ts.map +1 -1
  20. package/dist/pageFlip.js +1195 -342
  21. package/dist/pageFlip.js.map +1 -1
  22. package/dist/pageFlipFlat.d.ts +131 -0
  23. package/dist/pageFlipFlat.d.ts.map +1 -0
  24. package/dist/pageFlipFlat.js +870 -0
  25. package/dist/pageFlipFlat.js.map +1 -0
  26. package/dist/paper.d.ts +40 -0
  27. package/dist/paper.d.ts.map +1 -0
  28. package/dist/paper.js +65 -0
  29. package/dist/paper.js.map +1 -0
  30. package/dist/postext.d.ts +12 -2
  31. package/dist/postext.d.ts.map +1 -1
  32. package/dist/postext.js +167 -6
  33. package/dist/postext.js.map +1 -1
  34. package/dist/procedural.d.ts +35 -0
  35. package/dist/procedural.d.ts.map +1 -0
  36. package/dist/procedural.js +390 -0
  37. package/dist/procedural.js.map +1 -0
  38. package/dist/viewer.d.ts +41 -1
  39. package/dist/viewer.d.ts.map +1 -1
  40. package/dist/viewer.js +133 -16
  41. package/dist/viewer.js.map +1 -1
  42. package/package.json +3 -3
package/dist/pageFlip.js CHANGED
@@ -1,239 +1,313 @@
1
- import { CustomBlending, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, LinearSRGBColorSpace, MaxEquation, Mesh, NearestFilter, NoColorSpace, OneFactor, OrthographicCamera, PerspectiveCamera, PlaneGeometry, Scene, ShaderMaterial, 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, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, WebGLRenderer, WebGLRenderTarget, } from "three";
2
+ import { resolveFolioConfig } from "postext";
3
+ import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry } from "./bookGeometry";
4
+ import { environment } from "./environments";
5
+ import { pagePaper, paperSpec } from "./paper";
6
+ import { loadDeskMaps } from "./deskTextures";
7
+ import { cachedDesk, cachedRelief } from "./procedural";
2
8
  /** Columns and rows of a leaf's mesh: fine enough for a tight roll. */
3
9
  const NX = 96;
4
10
  const NY = 120;
5
- /** Vertical field of view: low, so a page at rest maps 1:1 onto the DOM spread. */
6
- const FOV = 26;
7
- /** Radius of the roll at mid-turn, relative to the page's width. */
11
+ /** Columns of a page lying open (its curve runs across only). */
12
+ const NX_OPEN = 72;
13
+ /** Vertical field of view. */
14
+ const FOV = 24;
15
+ /** Radius of the roll at mid-turn for 90 g/m² offset, against the page's width. */
8
16
  const ROLL = 0.11;
9
17
  /** Milliseconds one leaf takes to turn. */
10
18
  const DURATION = 1000;
11
19
  /** A leaf follows the one before it once that one is this far through its turn. */
12
20
  const GAP = 0.14;
13
- /** Towards the light: from above, a little to the upper left. */
14
- const LIGHT = new Vector3(-0.22, 0.3, 1).normalize();
15
- const VERTEX = /* glsl */ `
16
- varying vec2 vUv;
17
- varying vec3 vNormal;
18
- varying vec3 vPos;
19
- void main() {
20
- vUv = uv;
21
- vNormal = normal;
22
- vPos = position;
23
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
24
- }
25
- `;
26
- // Shadows are cast for real: each frame the leaves in the air are drawn,
27
- // seen from the light, into a height map (the highest paper over each
28
- // point, drawn in `draw`). A surface is in shadow where paper lies between
29
- // it and the light; the penumbra widens with the gap, as a real one does
30
- // (a blocker search, then a filter of that width). Paper lifted nearby
31
- // also hides part of the sky from it (ambient occlusion): the soft
32
- // darkening along a roll and round a flap.
33
- const SHADOW = /* glsl */ `
21
+ // Ambient occlusion from the book's own shape: each frame the book is
22
+ // drawn, seen from straight above, into a height map: channel r the
23
+ // highest of everything (blocks, covers, open pages, leaves), channel g the
24
+ // leaves in the air alone. A point is occluded by paper rising round it,
25
+ // more the nearer and higher it is: the book's contact shadow on the desk
26
+ // and the dark under a lifted leaf. The open pages read only the leaves'
27
+ // channel (their gutter is worked out from the cross-section).
28
+ const OCCLUSION = /* glsl */ `
34
29
  uniform sampler2D uShadowMap;
35
30
  uniform vec4 uShadowBox;
36
- uniform vec3 uLd;
37
31
  uniform float uShadowOn;
38
-
39
- float casterAt(vec2 s) {
32
+ uniform float uOccRadius;
33
+ uniform float uOccBias;
34
+ float casterAt(vec2 s, float channel) {
40
35
  vec2 uv = (s - uShadowBox.xy) / uShadowBox.zw;
41
36
  if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) return -1e4;
42
- return texture2D(uShadowMap, uv).r - 10.0;
43
- }
44
-
45
- // How much of the sky paper lifted nearby hides: 0 … 1.
46
- float occlusionAt(vec3 p, vec2 s, float spin) {
47
- float occ = 0.0;
48
- for (int i = 0; i < 16; i++) {
49
- float a = float(i) * 2.39996 + spin;
50
- float r = sqrt((float(i) + 0.5) / 16.0) * 72.0;
51
- float h = casterAt(s + r * vec2(cos(a), sin(a)));
52
- occ += clamp((h - p.z - 2.5) / (r + 14.0), 0.0, 1.0);
53
- }
54
- return occ / 16.0;
37
+ vec4 t = texture2D(uShadowMap, uv);
38
+ return (channel > 0.5 ? t.g : t.r) - 10.0;
55
39
  }
56
-
57
- // The light lost at p: 0 … about 0.6.
58
- float shadowAt(vec3 p) {
40
+ // Horizon-based: in each of 8 directions, the highest paper seen from p
41
+ // (as an elevation angle) hides that much of the sky: sin(horizon) of a
42
+ // cosine-weighted slice.
43
+ float bookOcclusion(vec3 p, float channel) {
59
44
  if (uShadowOn < 0.5) return 0.0;
60
- vec2 s = p.xy - uLd.xy / uLd.z * p.z;
61
- float spin = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) * 6.2831;
62
- float ambient = 0.34 * occlusionAt(p, s, spin);
63
- const float bias = 2.5;
64
- float sum = 0.0;
65
- float found = 0.0;
66
- for (int i = 0; i < 12; i++) {
67
- float a = float(i) * 2.39996 + spin;
68
- vec2 o = sqrt((float(i) + 0.5) / 12.0) * vec2(cos(a), sin(a));
69
- float h = casterAt(s + o * 16.0);
70
- if (h > p.z + bias) {
71
- sum += h;
72
- found += 1.0;
45
+ float spin = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
46
+ float occ = 0.0;
47
+ for (int d = 0; d < 8; d++) {
48
+ float a = (float(d) + spin) * 0.785398;
49
+ vec2 dir = vec2(cos(a), sin(a));
50
+ float horizon = 0.0;
51
+ for (int i = 0; i < 4; i++) {
52
+ float t = (float(i) + 0.25 + 0.5 * spin) / 4.0;
53
+ float r = t * t * uOccRadius + 1.0;
54
+ float h = casterAt(p.xy + r * dir, channel) - p.z - uOccBias;
55
+ horizon = max(horizon, h / sqrt(h * h + r * r));
73
56
  }
57
+ occ += horizon;
74
58
  }
75
- if (found < 0.5) return ambient;
76
- float gap = sum / found - p.z;
77
- float spread = clamp(gap * 0.3, 1.5, 40.0);
78
- float hit = 0.0;
79
- for (int i = 0; i < 16; i++) {
80
- float a = float(i) * 2.39996 + spin;
81
- vec2 o = sqrt((float(i) + 0.5) / 16.0) * vec2(cos(a), sin(a));
82
- if (casterAt(s + o * spread) > p.z + bias) hit += 1.0;
83
- }
84
- // Paper high above casts a paler shadow (more light gets round it).
85
- float direct = 0.4 * hit / 16.0 * (1.0 - 0.4 * smoothstep(30.0, 300.0, gap));
86
- return min(0.6, 1.0 - (1.0 - direct) * (1.0 - ambient));
87
- }
88
- `;
89
- // Colours are passed through untouched (textures and output both without
90
- // colour management) so a page at rest matches the <img> pixel for pixel,
91
- // spine gradient included (the CSS one, blended the way CSS blends it).
92
- // Lighting never brightens: a page facing the reader is as the <img> is,
93
- // turned from the light it darkens. On a mirrored stage (a right-bound
94
- // book) the images are read back the right way round.
95
- const FRAGMENT = /* glsl */ `
96
- uniform sampler2D uFront;
97
- uniform sampler2D uBack;
98
- // 0 no page, 1 its image, 2 blank paper.
99
- uniform float uHasFront;
100
- uniform float uHasBack;
101
- uniform vec3 uPaper;
102
- uniform float uBlank;
103
- uniform float uLit;
104
- uniform float uMirror;
105
- varying vec2 vUv;
106
- varying vec3 vNormal;
107
- varying vec3 vPos;
108
- ${SHADOW}
109
-
110
- float spine(float u, float a0, float a1) {
111
- if (u < 0.03) return mix(a0, a1, u / 0.03);
112
- if (u < 0.09) return mix(a1, 0.0, (u - 0.03) / 0.06);
113
- return 0.0;
114
- }
115
-
116
- void main() {
117
- bool front = gl_FrontFacing;
118
- float has = front ? uHasFront : uHasBack;
119
- vec3 col;
120
- if (has < 0.5 && uBlank < 0.5) discard;
121
- if (has < 0.5 || has > 1.5) {
122
- col = uPaper;
123
- } else {
124
- float u = uMirror > 0.5 ? 1.0 - vUv.x : vUv.x;
125
- col = front ? texture2D(uFront, vec2(u, vUv.y)).rgb : texture2D(uBack, vec2(1.0 - u, vUv.y)).rgb;
126
- }
127
- // uv.x is the distance from the spine on both faces.
128
- col *= 1.0 - (front ? spine(vUv.x, 0.18, 0.05) : spine(vUv.x, 0.16, 0.04));
129
-
130
- vec3 p = vPos;
131
- if (uLit > 0.5) {
132
- vec3 n = normalize(vNormal) * (front ? 1.0 : -1.0);
133
- col *= mix(0.6, 1.0, clamp(dot(n, uLd) / uLd.z, 0.0, 1.0));
134
- // Look the shadow up a little off the surface (no self-shadow acne).
135
- p += n * 2.0;
136
- }
137
- col *= 1.0 - shadowAt(p);
138
- gl_FragColor = vec4(col, 1.0);
59
+ return occ / 8.0;
139
60
  }
140
61
  `;
141
- // The desk under the book: the pages' drop shadow (the CSS box-shadow of
142
- // `.cb-lt-page`, a gaussian-blurred rectangle) and the leaves'.
143
- const DESK = /* glsl */ `
144
- uniform float uW;
145
- uniform float uH;
146
- uniform vec2 uPages;
147
- varying vec2 vUv;
148
- varying vec3 vNormal;
149
- varying vec3 vPos;
150
- ${SHADOW}
151
-
152
- float erf(float x) {
153
- float a = abs(x);
154
- float t = 1.0 / (1.0 + 0.3275911 * a);
155
- float y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-a * a);
156
- return sign(x) * y;
157
- }
158
- float band(float x, float lo, float hi, float sigma) {
159
- return 0.5 * (erf((x - lo) / (sigma * 1.4142)) - erf((x - hi) / (sigma * 1.4142)));
160
- }
161
- // box-shadow: 0 1px 1px .35, 0 16px 36px -8px .65 (CSS y runs down).
162
- float page(vec2 p, float x0, float x1) {
163
- float h = uH / 2.0;
164
- float soft = 0.65 * band(p.x, x0 + 8.0, x1 - 8.0, 18.0) * band(p.y + 16.0, -h + 8.0, h - 8.0, 18.0);
165
- float hard = 0.35 * band(p.x, x0, x1, 0.5) * band(p.y + 1.0, -h, h, 0.5);
166
- return 1.0 - (1.0 - soft) * (1.0 - hard);
167
- }
168
-
169
- void main() {
170
- vec2 p = vPos.xy;
171
- float clear = 1.0;
172
- if (uPages.x > 0.5) clear *= 1.0 - page(p, -uW, 0.0);
173
- if (uPages.y > 0.5) clear *= 1.0 - page(p, 0.0, uW);
174
- clear *= 1.0 - 1.3 * shadowAt(vPos);
175
- gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - clear);
176
- }
177
- `;
178
- // Draws the leaves as the light sees them: each point where its shadow
179
- // falls on the desk plane, keeping the highest paper (max blending).
62
+ /** Declares the book position varying in a vertex shader and sets it:
63
+ * the world position with the mirror of a right-bound book undone. */
64
+ const BOOK_POS_PARS = "uniform float uSign;\nvarying vec3 vBookPos;";
65
+ const BOOK_POS = "vBookPos = (modelMatrix * vec4(transformed, 1.0)).xyz;\nvBookPos.x *= uSign;";
180
66
  const CASTER_VERTEX = /* glsl */ `
181
- uniform vec3 uLd;
67
+ uniform float uSign;
182
68
  varying float vZ;
183
69
  void main() {
184
- vZ = position.z;
185
- vec2 s = position.xy - uLd.xy / uLd.z * position.z;
186
- gl_Position = projectionMatrix * viewMatrix * vec4(s, 0.0, 1.0);
70
+ vec4 w = modelMatrix * vec4(position, 1.0);
71
+ vZ = w.z;
72
+ gl_Position = projectionMatrix * viewMatrix * vec4(w.x * uSign, w.y, 0.0, 1.0);
187
73
  }
188
74
  `;
189
75
  const CASTER_FRAGMENT = /* glsl */ `
76
+ uniform float uLeaf;
190
77
  varying float vZ;
191
78
  void main() {
192
- gl_FragColor = vec4(vZ + 10.0, 0.0, 0.0, 1.0);
79
+ gl_FragColor = vec4(vZ + 10.0, uLeaf * (vZ + 10.0), 0.0, 1.0);
193
80
  }
194
81
  `;
195
- /** The shadow uniforms every receiving material shares (same objects). */
196
- const shadowUniforms = {
82
+ /** The occlusion uniforms every receiving material shares (same objects). */
83
+ const occlusionUniforms = {
197
84
  uShadowMap: { value: null },
198
85
  uShadowBox: { value: new Vector4(0, 0, 1, 1) },
199
- uLd: { value: LIGHT },
200
86
  uShadowOn: { value: 0 },
87
+ uOccRadius: { value: 60 },
88
+ uOccBias: { value: 1 },
201
89
  };
202
- /** The book's paper, for its blank pages (shared too). */
203
- const paperUniform = { uPaper: { value: new Vector3(1, 1, 1) } };
204
- function material(lit, mirror) {
90
+ function casterMaterial(leaf, sign) {
205
91
  return new ShaderMaterial({
206
- vertexShader: VERTEX,
207
- fragmentShader: FRAGMENT,
92
+ vertexShader: CASTER_VERTEX,
93
+ fragmentShader: CASTER_FRAGMENT,
208
94
  side: DoubleSide,
209
- uniforms: {
210
- uFront: { value: null },
211
- uBack: { value: null },
212
- uHasFront: { value: 0 },
213
- uHasBack: { value: 0 },
214
- uBlank: { value: lit ? 1 : 0 },
215
- uLit: { value: lit ? 1 : 0 },
216
- uMirror: { value: mirror ? 1 : 0 },
217
- ...shadowUniforms,
218
- ...paperUniform,
219
- },
95
+ blending: CustomBlending,
96
+ blendEquation: MaxEquation,
97
+ blendEquationAlpha: MaxEquation,
98
+ blendSrc: OneFactor,
99
+ blendDst: OneFactor,
100
+ depthTest: false,
101
+ depthWrite: false,
102
+ uniforms: { uLeaf: { value: leaf ? 1 : 0 }, uSign: { value: sign } },
220
103
  });
221
104
  }
105
+ /** A standard material (the desk, the covers) with the book's occlusion
106
+ * on its ambient light. */
107
+ function occluded(m, sign) {
108
+ m.onBeforeCompile = (shader) => {
109
+ Object.assign(shader.uniforms, occlusionUniforms, { uSign: { value: sign } });
110
+ shader.vertexShader = shader.vertexShader
111
+ .replace("#include <common>", `#include <common>\n${BOOK_POS_PARS}`)
112
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\n${BOOK_POS}`);
113
+ shader.fragmentShader = shader.fragmentShader
114
+ .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; }");
116
+ };
117
+ return m;
118
+ }
119
+ /**
120
+ * A page's material: three.js's physical (PBR) material lit by the
121
+ * environment and the key light, the printed page on each face, the
122
+ * paper's shade, finish (roughness, a clear coat on coated stock, a fibre
123
+ * sheen on uncoated) and relief (a normal map), and the print on the other
124
+ * side showing faintly through thin paper. Ambient light is cut by the
125
+ * gutter's occlusion (a vertex attribute, from the book's cross-section)
126
+ * and by lifted leaves nearby.
127
+ */
128
+ function pageMaterial(mirror, sign) {
129
+ const m = new MeshPhysicalMaterial({ side: DoubleSide, roughness: 0.85 });
130
+ const uniforms = {
131
+ uFront: { value: null },
132
+ uBack: { value: null },
133
+ uHasFront: { value: 0 },
134
+ uHasBack: { value: 0 },
135
+ uMirror: { value: mirror ? 1 : 0 },
136
+ uShowThrough: { value: 0 },
137
+ uPaper: { value: new Color(1, 1, 1) },
138
+ uLeafOcc: { value: 0.45 },
139
+ uOccBias: { value: 6 },
140
+ };
141
+ m.userData.uniforms = uniforms;
142
+ m.onBeforeCompile = (shader) => {
143
+ Object.assign(shader.uniforms, occlusionUniforms, uniforms, { uSign: { value: sign } });
144
+ shader.vertexShader = shader.vertexShader
145
+ .replace("#include <common>", `#include <common>
146
+ attribute float ao;
147
+ varying float vAo;
148
+ varying vec2 vPageUv;
149
+ ${BOOK_POS_PARS}`)
150
+ .replace("#include <begin_vertex>", `#include <begin_vertex>
151
+ vAo = ao;
152
+ vPageUv = uv;
153
+ ${BOOK_POS}`);
154
+ shader.fragmentShader = shader.fragmentShader
155
+ .replace("#include <common>", `#include <common>
156
+ uniform sampler2D uFront;
157
+ uniform sampler2D uBack;
158
+ uniform float uHasFront;
159
+ uniform float uHasBack;
160
+ uniform float uMirror;
161
+ uniform float uShowThrough;
162
+ uniform vec3 uPaper;
163
+ uniform float uLeafOcc;
164
+ varying float vAo;
165
+ varying vec2 vPageUv;
166
+ varying vec3 vBookPos;
167
+ ${OCCLUSION}`)
168
+ .replace("#include <map_fragment>", `{
169
+ // 0 no page, 1 its image, 2 blank paper; uv.x runs from the spine.
170
+ bool front = gl_FrontFacing;
171
+ float has = front ? uHasFront : uHasBack;
172
+ float u = uMirror > 0.5 ? 1.0 - vPageUv.x : vPageUv.x;
173
+ vec2 uvF = vec2(u, vPageUv.y);
174
+ vec2 uvB = vec2(1.0 - u, vPageUv.y);
175
+ vec3 ink = uPaper;
176
+ if (has > 0.5 && has < 1.5) ink = front ? texture2D(uFront, uvF).rgb : texture2D(uBack, uvB).rgb;
177
+ // The other side's print, seen through the sheet (the same point
178
+ // of paper, so mirrored as it should be).
179
+ float other = front ? uHasBack : uHasFront;
180
+ if (uShowThrough > 0.0 && other > 0.5 && other < 1.5) {
181
+ vec3 through = front ? texture2D(uBack, uvB).rgb : texture2D(uFront, uvF).rgb;
182
+ ink *= mix(vec3(1.0), through, uShowThrough);
183
+ }
184
+ diffuseColor.rgb *= ink;
185
+ }`)
186
+ .replace("#include <aomap_fragment>", `#include <aomap_fragment>
187
+ {
188
+ // A leaf's own roll counts among the leaves it is occluded by:
189
+ // held to what its open side would let in.
190
+ float occ = vAo * (1.0 - uLeafOcc * bookOcclusion(vBookPos, 1.0));
191
+ reflectedLight.indirectDiffuse *= occ;
192
+ reflectedLight.indirectSpecular *= mix(1.0, occ, 0.85);
193
+ }`);
194
+ };
195
+ return m;
196
+ }
197
+ /** The page block's edges: paper with a fine line between leaves (fading
198
+ * to their average where the lines are finer than a pixel). */
199
+ function edgeMaterial(sign, bands) {
200
+ const m = new MeshStandardMaterial({ side: DoubleSide, roughness: 0.92 });
201
+ const band = { uBoardLo: { value: bands?.lo ?? -1 }, uBoardHi: { value: bands?.hi ?? 2 }, uBoardColor: { value: bands?.color ?? new Color(0, 0, 0) } };
202
+ m.onBeforeCompile = (shader) => {
203
+ Object.assign(shader.uniforms, occlusionUniforms, band, { uSign: { value: sign } });
204
+ shader.vertexShader = shader.vertexShader
205
+ .replace("#include <common>", `#include <common>\nattribute float layer;\nattribute float lambda;\nvarying float vLayer;\nvarying float vLambda;\n${BOOK_POS_PARS}`)
206
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\nvLayer = layer;\nvLambda = lambda;\n${BOOK_POS}`);
207
+ shader.fragmentShader = shader.fragmentShader
208
+ .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
+ .replace("#include <map_fragment>", `{
210
+ float w = fwidth(vLayer);
211
+ float d = abs(fract(vLayer) - 0.5) * 2.0;
212
+ float line = smoothstep(0.55 - w, 0.95 + w, d);
213
+ float fine = clamp(w * 1.5, 0.0, 1.0);
214
+ diffuseColor.rgb *= 1.0 - mix(0.22 * line, 0.08, fine);
215
+ // A board lying in the block (the book's own cover) shows its
216
+ // own edge, not paper.
217
+ if (vLambda < uBoardLo || vLambda > uBoardHi) diffuseColor.rgb = uBoardColor;
218
+ }`)
219
+ .replace("#include <aomap_fragment>", "#include <aomap_fragment>\nreflectedLight.indirectDiffuse *= 1.0 - bookOcclusion(vBookPos, 0.0);");
220
+ };
221
+ return m;
222
+ }
222
223
  const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
223
224
  const easeOut = (t) => 1 - Math.pow(1 - t, 3);
224
225
  const smooth = (a, b, x) => {
225
226
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
226
227
  return t * t * (3 - 2 * t);
227
228
  };
229
+ /**
230
+ * The stock a publisher would print a book of `pages` pages on, when its
231
+ * settings name none: a long book goes on lighter paper, a very long one
232
+ * on bible paper (2,000 pages make a volume about 4.5 cm thick, not 11).
233
+ * Settings that name a stock, a grammage or a bulk are kept as they are.
234
+ */
235
+ export function withStockForExtent(folio, pages) {
236
+ const paper = folio?.paper;
237
+ if (paper?.type !== undefined || paper?.grammage !== undefined || paper?.bulk !== undefined)
238
+ return folio;
239
+ if (pages > 1000)
240
+ return { ...folio, paper: { ...paper, type: "bible" } };
241
+ if (pages > 600)
242
+ return { ...folio, paper: { ...paper, grammage: 70 } };
243
+ return folio;
244
+ }
245
+ /** The colour round the edge of a printed cover (its ground), averaged: the
246
+ * colour its boards and spine show when the book carries its own covers.
247
+ * Null when the image cannot be read. */
248
+ function edgeColorOf(image) {
249
+ try {
250
+ const c = document.createElement("canvas");
251
+ c.width = c.height = 32;
252
+ const g = c.getContext("2d");
253
+ if (!g)
254
+ return null;
255
+ g.drawImage(image, 0, 0, 32, 32);
256
+ const d = g.getImageData(0, 0, 32, 32).data;
257
+ let r = 0;
258
+ let gr = 0;
259
+ let b = 0;
260
+ let n = 0;
261
+ for (let y = 0; y < 32; y++)
262
+ for (let x = 0; x < 32; x++) {
263
+ if (x > 1 && x < 30 && y > 1 && y < 30)
264
+ continue;
265
+ const i = (y * 32 + x) * 4;
266
+ r += d[i];
267
+ gr += d[i + 1];
268
+ b += d[i + 2];
269
+ n++;
270
+ }
271
+ return new Color().setRGB(r / n / 255, gr / n / 255, b / n / 255, SRGBColorSpace);
272
+ }
273
+ catch {
274
+ return null;
275
+ }
276
+ }
277
+ /** The lowest the reader may orbit the view: 70° from straight above. */
278
+ 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;
281
+ /** Where column `t` (0 … 1) of a page's mesh lies across it: closer
282
+ * together near the spine, where the gutter bends tightest. */
283
+ const column = (t) => Math.pow(t, 1.45);
284
+ /** Sets a page mesh's u coordinates to its columns. */
285
+ function spreadColumns(geometry, nx) {
286
+ const uv = geometry.attributes.uv;
287
+ for (let i = 0; i < uv.count; i++)
288
+ uv.setX(i, column((i % (nx + 1)) / nx));
289
+ uv.needsUpdate = true;
290
+ }
291
+ /** max(a, b), rounded over a band `k` wide (no crease where they meet). */
292
+ function smoothMax(a, b, k) {
293
+ return (a + b + Math.sqrt((a - b) * (a - b) + k * k)) / 2;
294
+ }
295
+ /** Where arc length `signed` from the spine lies on the open book's
296
+ * surface (right page positive): [x, z]. */
297
+ function surfaceAt(book, signed) {
298
+ const [bx, bz] = along(signed >= 0 ? book.right : book.left, Math.abs(signed));
299
+ return [(signed >= 0 ? 1 : -1) * bx, bz];
300
+ }
228
301
  /**
229
302
  * How a leaf folds when the point `G` of it has been carried to `P`: the
230
303
  * paper stays flat up to a fold line, rolls round a cylinder of radius R
231
304
  * and lies over itself beyond it, back up (the page-curl cylinder). The
232
305
  * fold is square to the pull, placed so the point taken lands under the
233
306
  * hand, and never crosses the spine the leaf is sewn to. R grows as the
234
- * page lifts and shrinks to nothing as it lands on the other side.
307
+ * page lifts and shrinks to nothing as it lands on the other side; a
308
+ * stiffer paper (`roll`) bends in a wider roll.
235
309
  */
236
- function foldOf(G, P, W, H) {
310
+ function foldOf(G, P, W, H, roll = 1) {
237
311
  const du = G.u - P.u;
238
312
  const dv = G.v - P.v;
239
313
  const D = Math.hypot(du, dv);
@@ -241,14 +315,22 @@ function foldOf(G, P, W, H) {
241
315
  return null;
242
316
  const n = { u: du / D, v: dv / D };
243
317
  const q = progressFrom(G, P);
244
- const R = ROLL * W * (0.3 + 0.7 * smooth(0, 0.2, q)) * (1 - smooth(0.62, 1, q)) * Math.min(1, D / (0.1 * W));
245
- const roll = Math.PI * R;
246
- let c = (D + roll * Math.min(1, roll > 0 ? D / roll : 0)) / 2;
318
+ const R = Math.min(0.5, ROLL * roll) * W * (0.3 + 0.7 * smooth(0, 0.2, q)) * (1 - smooth(0.62, 1, q)) * Math.min(1, D / (0.1 * W));
319
+ // How far the paper past the fold has turned: a leaf held up by the hand
320
+ // stands in the air (about 110° early in the turn), and only lies back
321
+ // flat (180°) as it lands. Folding it flat over itself all the way (the
322
+ // table-paper curl) made a tent of a leaf dragged across the spine.
323
+ const theta = Math.PI * (0.62 + 0.38 * smooth(0.05, 1, q));
324
+ // The fold sits where the point taken, carried round the roll and on
325
+ // straight, lands over the hand: R sinθ + (c − Rθ) cosθ = c − D.
326
+ const arc = R * theta;
327
+ const k = arc > 0 ? Math.min(1, D / arc) : 0;
328
+ let c = (D + k * (R * Math.sin(theta) - arc * Math.cos(theta))) / (1 - Math.cos(theta));
247
329
  for (const S of [-H / 2, H / 2])
248
330
  c = Math.min(c, G.u * n.u + (G.v - S) * n.v);
249
331
  if (c <= 0)
250
332
  return null;
251
- return { F: { u: G.u - n.u * c, v: G.v - n.v * c }, n, R };
333
+ return { F: { u: G.u - n.u * c, v: G.v - n.v * c }, n, R, theta };
252
334
  }
253
335
  /** How far through its turn a leaf is: the point taken, from where it
254
336
  * was to its mirror image across the spine. */
@@ -271,14 +353,51 @@ function reach(G, P, H) {
271
353
  }
272
354
  return { u, v };
273
355
  }
274
- /** Lays a leaf's mesh out for a fold (flat when there is none). */
275
- function layLeaf(geometry, fold, forward, W, H, z0) {
356
+ /**
357
+ * Lays a leaf's mesh out. The curl is worked out on the flat page (u from
358
+ * the spine), then wrapped onto the book: the part on the right follows
359
+ * the right stack's top (into the gutter and out), the part carried over
360
+ * the spine the left one's. A stiff leaf (`rigidity`) turns more as a
361
+ * plate on its hinge: a board does not bend at all.
362
+ */
363
+ function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q, flutter = 0, time = 0, hingeZ) {
276
364
  const pos = geometry.attributes.position;
277
365
  const sx = forward ? 1 : -1;
366
+ const [xr, zr] = along(book.right, W);
367
+ const [xl, zl] = along(book.left, W);
368
+ const qa = forward ? q : 1 - q;
369
+ let hinge;
370
+ let phi;
371
+ 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;
381
+ }
382
+ else {
383
+ // A stiff leaf rests on its stack's fore-edge and swings over the gutter.
384
+ r = rigidity * smooth(0, 0.06, q) * smooth(0, 0.06, 1 - q);
385
+ hinge = along(book.right, 0)[1];
386
+ const a0 = Math.atan2(zr - hinge, xr);
387
+ const a1 = Math.PI - Math.atan2(zl - hinge, xl);
388
+ phi = a0 + (a1 - a0) * qa;
389
+ }
390
+ // How far a turning leaf stands off the pages: none at rest or for a
391
+ // corner barely lifted, a few degrees through the middle of the turn.
392
+ const inAir = smooth(0.02, 0.22, q) * smooth(0.02, 0.22, 1 - q);
393
+ const lean = LEAN * inAir;
394
+ const cosA = Math.cos(lean);
395
+ const sinA = Math.sin(lean);
396
+ const floor = along(book.right, 0)[1];
278
397
  for (let iy = 0; iy <= NY; iy++) {
279
398
  const v = (0.5 - iy / NY) * H;
280
399
  for (let ix = 0; ix <= NX; ix++) {
281
- const u = (ix / NX) * W;
400
+ const u = column(ix / NX) * W;
282
401
  let x = u;
283
402
  let y = v;
284
403
  let z = 0;
@@ -286,41 +405,73 @@ function layLeaf(geometry, fold, forward, W, H, z0) {
286
405
  const { F, n, R } = fold;
287
406
  const d = (u - F.u) * n.u + (v - F.v) * n.v;
288
407
  if (d > 0) {
408
+ // Round the roll, then straight on at the fold's angle.
409
+ const theta = fold.theta;
289
410
  let dd;
290
- if (R < 1e-3)
291
- dd = -d;
292
- else if (d < Math.PI * R) {
411
+ if (R > 1e-3 && d < R * theta) {
293
412
  dd = R * Math.sin(d / R);
294
413
  z = R * (1 - Math.cos(d / R));
295
414
  }
296
415
  else {
297
- dd = -(d - Math.PI * R);
298
- z = 2 * R;
416
+ const rest = d - R * theta;
417
+ dd = R * Math.sin(theta) + rest * Math.cos(theta);
418
+ z = R * (1 - Math.cos(theta)) + rest * Math.sin(theta);
299
419
  }
300
420
  x = u - n.u * (d - dd);
301
421
  y = v - n.v * (d - dd);
302
422
  }
303
423
  }
304
- pos.setXYZ(iy * (NX + 1) + ix, sx * x, y, z + z0);
424
+ // Onto the book. At rest (and as it lands) the leaf lies on the open
425
+ // book's surface, into the gutter and out. In the air it is lifted
426
+ // off at the spine: its own plane rises from the gutter's floor at a
427
+ // small angle, and it touches the pages only where they stand higher
428
+ // (the gutter's walls), joined smoothly.
429
+ const s = sx * x;
430
+ const [rx, rz] = surfaceAt(book, s);
431
+ let px = rx;
432
+ let pz = rz + z + lift;
433
+ if (inAir > 0) {
434
+ const fx = (s >= 0 ? 1 : -1) * Math.abs(s) * cosA;
435
+ const plane = floor + Math.abs(s) * sinA + z + lift;
436
+ const under = surfaceAt(book, fx)[1] + lift + 0.6;
437
+ const flying = smoothMax(plane, under, 0.02 * W);
438
+ px += (fx - px) * inAir;
439
+ pz += (flying - pz) * inAir;
440
+ }
441
+ if (r > 0) {
442
+ px += (u * Math.cos(phi) - px) * r;
443
+ y += (v - y) * r;
444
+ pz += (hinge + lift + u * Math.sin(phi) - pz) * r;
445
+ }
446
+ // Thin paper ripples in the air, more towards its free edge.
447
+ if (flutter > 0)
448
+ pz += flutter * W * (u / W) * (u / W) * Math.sin(2 * Math.PI * (time / 520) - (3 * u) / W + (2 * v) / H);
449
+ pos.setXYZ(iy * (NX + 1) + ix, px, y, pz);
305
450
  }
306
451
  }
307
452
  pos.needsUpdate = true;
308
453
  geometry.computeVertexNormals();
309
454
  }
310
455
  /**
311
- * Turns the pages of the light table's DOM spread. The book is a stack of
312
- * leaves: leaf k has spread k's recto on its front and spread k + 1's verso
313
- * on its back. `go(n)` sends every leaf before spread n to the left and
314
- * the rest to the right; each turns on its own, following the one before
315
- * it at a short remove, so several pages can be in the air at once. At
316
- * rest the canvas is transparent and the <img> pages show; while leaves
317
- * move it draws the spread, lit, with the lifted leaves' shadows.
456
+ * Turns the pages of the viewer's DOM spread in a 3D scene: the book lies
457
+ * open on a desk, seen from in front and above (tilted by `folio.tilt`),
458
+ * lit by an environment (image-based light, reflected by glossy paper) and
459
+ * a key light that casts the shadows. The pages already read and those
460
+ * still to come form two blocks of real thickness (leaf count × the
461
+ * paper's caliper, the book's other chapters included), bound in a case or
462
+ * a soft cover; the open pages curve down into the gutter, whose darkness
463
+ * is the occlusion of that shape.
464
+ *
465
+ * The book is a stack of leaves: leaf k has spread k's recto on its front
466
+ * and spread k + 1's verso on its back. `go(n)` sends every leaf before
467
+ * spread n to the left and the rest to the right; each turns on its own,
468
+ * following the one before it at a short remove, so several pages can be
469
+ * in the air at once.
318
470
  *
319
471
  * The book is modelled bound on the left. A right-bound one is the same
320
472
  * book seen in a mirror (the DOM spread's `dir="rtl"`): the stage is
321
473
  * mirrored, so the verso lies on the right and the leaves turn from left
322
- * to right, while the page images, the pointer and the light are mirrored
323
- * back to read as they are on the desk.
474
+ * to right, while the page images and the pointer are mirrored back.
324
475
  */
325
476
  export class PageFlipper {
326
477
  canvas;
@@ -334,29 +485,31 @@ export class PageFlipper {
334
485
  left;
335
486
  right;
336
487
  desk;
488
+ stacks = [];
489
+ covers = new Group();
490
+ coverMaterial;
491
+ /** The spine picture, once loaded, and the faces it is printed on. */
492
+ spineSrc = null;
493
+ spineTex = null;
494
+ spineFaces = [];
495
+ edges;
496
+ key = new DirectionalLight(0xffffff, 1);
497
+ pmrem = null;
498
+ envKind = null;
499
+ env = null;
337
500
  /** Everything on the desk, in the book's own coordinates (mirrored for a
338
501
  * right-bound book). */
339
502
  stage = new Group();
340
503
  /** 1 for a left-bound book, −1 for a right-bound one. */
341
504
  sign;
342
- /** Towards the light, in the book's coordinates. */
343
- light;
344
505
  shadowScene = new Scene();
345
- shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 1000);
506
+ shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 4000);
346
507
  shadowTarget = null;
347
- casterMaterial = new ShaderMaterial({
348
- vertexShader: CASTER_VERTEX,
349
- fragmentShader: CASTER_FRAGMENT,
350
- side: DoubleSide,
351
- blending: CustomBlending,
352
- blendEquation: MaxEquation,
353
- blendEquationAlpha: MaxEquation,
354
- blendSrc: OneFactor,
355
- blendDst: OneFactor,
356
- depthTest: false,
357
- depthWrite: false,
358
- uniforms: { uLd: { value: LIGHT } },
359
- });
508
+ /** Draw the leaves in the air, and the rest of the book, into the
509
+ * height map. */
510
+ leafCaster;
511
+ staticCaster;
512
+ staticCasters = [];
360
513
  leaves = new Map();
361
514
  loader = new TextureLoader();
362
515
  textures = new Map();
@@ -368,6 +521,7 @@ export class PageFlipper {
368
521
  turns = new Map();
369
522
  target;
370
523
  raf = 0;
524
+ lastFrame = 0;
371
525
  starting = false;
372
526
  disposed = false;
373
527
  /** The spread last reported settled (the one the DOM shows). */
@@ -376,6 +530,21 @@ export class PageFlipper {
376
530
  turnAt;
377
531
  /** A redraw of the book at rest is queued. */
378
532
  still = 0;
533
+ appearance = {};
534
+ resolved = resolveFolioConfig(undefined);
535
+ bookSpec = paperSpec(this.resolved.paper);
536
+ leafSpecs = [];
537
+ /** The book's own covers, as stiff laminated board. */
538
+ coverSpec = null;
539
+ surfaces = null;
540
+ /** What the surfaces were built for. */
541
+ surfaceKey = "";
542
+ raycaster = new Raycaster();
543
+ /** The reader's own view (right-drag): turned round the book by `yaw`,
544
+ * tilted by `pitch` (radians from straight above; null: the setting's
545
+ * tilt). */
546
+ orbit = { yaw: 0, pitch: null };
547
+ paper = [1, 1, 1];
379
548
  constructor(canvas, spread, book, at, onSettle,
380
549
  /** The reader's hand turned the book to another spread. */
381
550
  onTarget,
@@ -383,10 +552,9 @@ export class PageFlipper {
383
552
  binding = "left",
384
553
  /** `persistent`: the canvas draws the book at rest too (the DOM pages
385
554
  * are hidden by the host and serve only as texture sources and text
386
- * alternatives), so a page looks the same lying still and turning. */
387
- /** `turnAt`: how far through its turn (0 … 1) a leaf let go of turns
388
- * over rather than falling back. Default 0.5. */
389
- { persistent = false, turnAt = 0.5 } = {}) {
555
+ * alternatives). `turnAt`: how far through its turn (0 … 1) a leaf
556
+ * let go of turns over rather than falling back. Default 0.5. */
557
+ { persistent = false, turnAt = 0.5, appearance = {} } = {}) {
390
558
  this.canvas = canvas;
391
559
  this.spread = spread;
392
560
  this.book = book;
@@ -395,35 +563,54 @@ export class PageFlipper {
395
563
  this.persistent = persistent;
396
564
  this.turnAt = turnAt;
397
565
  this.sign = binding === "right" ? -1 : 1;
398
- this.light = new Vector3(this.sign * LIGHT.x, LIGHT.y, LIGHT.z);
399
- this.casterMaterial.uniforms.uLd.value = this.light;
400
566
  const mirror = this.sign < 0;
401
- this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true });
402
- this.renderer.outputColorSpace = LinearSRGBColorSpace;
567
+ this.coverMaterial = occluded(new MeshPhysicalMaterial({ roughness: 0.9, polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 8 }), this.sign);
568
+ this.edges = edgeMaterial(this.sign);
569
+ this.leafCaster = casterMaterial(true, this.sign);
570
+ this.staticCaster = casterMaterial(false, this.sign);
571
+ // `__postextFolioPreserve`: keep the drawing buffer (to read the canvas back while debugging).
572
+ const preserveDrawingBuffer = !!globalThis.__postextFolioPreserve;
573
+ this.renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true, preserveDrawingBuffer });
574
+ this.renderer.outputColorSpace = SRGBColorSpace;
575
+ this.renderer.toneMapping = NeutralToneMapping;
403
576
  this.renderer.setClearColor(0x000000, 0);
404
- this.left = new Mesh(new PlaneGeometry(1, 1, 1, 1), material(false, mirror));
405
- this.right = new Mesh(new PlaneGeometry(1, 1, 1, 1), material(false, mirror));
406
- this.desk = new Mesh(new PlaneGeometry(1, 1), new ShaderMaterial({
407
- vertexShader: VERTEX,
408
- fragmentShader: DESK,
409
- transparent: true,
410
- depthWrite: false,
411
- uniforms: {
412
- uW: { value: 1 },
413
- uH: { value: 1 },
414
- uPages: { value: new Vector2() },
415
- ...shadowUniforms,
416
- },
417
- }));
577
+ if (this.renderer.shadowMap) {
578
+ this.renderer.shadowMap.enabled = true;
579
+ this.renderer.shadowMap.type = PCFShadowMap;
580
+ }
581
+ this.left = new Mesh(this.openGeometry(), pageMaterial(mirror, this.sign));
582
+ this.right = new Mesh(this.openGeometry(), pageMaterial(mirror, this.sign));
583
+ for (const m of [this.left, this.right]) {
584
+ m.receiveShadow = m.castShadow = true;
585
+ // A leaf lifting off an open page lies a hair above it: the page
586
+ // under it gives way in depth, so it never shows through.
587
+ m.material.polygonOffset = true;
588
+ m.material.polygonOffsetFactor = 1;
589
+ m.material.polygonOffsetUnits = 4;
590
+ // Shaded under a lifted leaf, never blacked out: light still comes
591
+ // in under its edges.
592
+ m.material.userData.uniforms.uLeafOcc.value = 0.7;
593
+ m.material.userData.uniforms.uOccBias.value = 1;
594
+ }
595
+ this.desk = new Mesh(new PlaneGeometry(1, 1), new MeshStandardMaterial());
596
+ this.desk.receiveShadow = true;
418
597
  this.desk.renderOrder = -1;
419
- this.shadowCamera.position.z = 100;
598
+ // The desk lies a hair under the book (whose pages rest on it when the
599
+ // book carries its own covers) and gives way in depth: seen at a grazing
600
+ // angle the two never fight.
601
+ this.desk.position.z = -1;
602
+ this.key.castShadow = true;
603
+ this.key.shadow.mapSize.set(2048, 2048);
604
+ this.key.shadow.bias = -0.0004;
605
+ this.key.shadow.normalBias = 0.6;
606
+ this.shadowCamera.position.z = 2000;
420
607
  // The height map needs a float colour buffer (and max blending).
421
608
  const ext = this.renderer.extensions;
422
609
  if (ext.has("EXT_color_buffer_float") || ext.has("EXT_color_buffer_half_float")) {
423
610
  this.shadowTarget = new WebGLRenderTarget(1, 1, {
424
611
  type: HalfFloatType,
425
- minFilter: NearestFilter,
426
- magFilter: NearestFilter,
612
+ minFilter: LinearFilter,
613
+ magFilter: LinearFilter,
427
614
  depthBuffer: false,
428
615
  generateMipmaps: false,
429
616
  });
@@ -431,17 +618,475 @@ export class PageFlipper {
431
618
  // A mirror turns three.js's face culling round with it (the front of a
432
619
  // page stays its front).
433
620
  this.stage.scale.x = this.sign;
434
- this.stage.add(this.desk, this.left, this.right);
621
+ this.stage.add(this.desk, this.covers, this.left, this.right, this.key, this.key.target);
435
622
  this.scene.add(this.stage);
436
623
  this.target = at;
437
624
  this.reported = at;
438
625
  this.turned = Array.from({ length: Math.max(0, book.length - 1) }, (_, k) => k < at);
626
+ this.setAppearance(appearance);
627
+ if (preserveDrawingBuffer)
628
+ globalThis.__postextFolioFlipper = this;
629
+ }
630
+ openGeometry() {
631
+ const g = new PlaneGeometry(1, 1, NX_OPEN, 1);
632
+ spreadColumns(g, NX_OPEN);
633
+ g.setAttribute("ao", new BufferAttribute(new Float32Array(g.attributes.position.count).fill(1), 1));
634
+ return g;
635
+ }
636
+ // ── Appearance ──
637
+ /** Changes how the book is presented (paper, binding, desk, light,
638
+ * tilt, the leaves round the pages shown). */
639
+ setAppearance(appearance) {
640
+ this.appearance = appearance;
641
+ this.resolved = resolveFolioConfig(withStockForExtent(appearance.folio, this.pageCount(appearance)));
642
+ this.bookSpec = paperSpec(this.resolved.paper);
643
+ const specs = new Map();
644
+ this.leafSpecs = (appearance.leafPapers ?? []).map((own) => {
645
+ if (!own)
646
+ return this.bookSpec;
647
+ const key = JSON.stringify(own);
648
+ let spec = specs.get(key);
649
+ if (!spec)
650
+ specs.set(key, (spec = paperSpec(pagePaper(appearance.folio, own))));
651
+ return spec;
652
+ });
653
+ const r = this.resolved;
654
+ const covers = appearance.coverLeaves;
655
+ const ownCovers = !!covers && (covers.front !== undefined || covers.back !== undefined);
656
+ this.coverSpec = !ownCovers
657
+ ? null
658
+ : r.binding.type === "saddleStitch"
659
+ ? // A stapled booklet's cover is a sheet like the others, of the same
660
+ // finish and a little heavier: it bends and turns as a page does.
661
+ paperSpec({ ...r.paper, grammage: Math.min(350, Math.round(r.paper.grammage * 1.5)), showThrough: false })
662
+ : {
663
+ ...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,
667
+ roughness: 0.45,
668
+ clearcoat: 0.35,
669
+ clearcoatRoughness: 0.2,
670
+ showThrough: 0,
671
+ };
672
+ // The desk.
673
+ const old = this.desk.material;
674
+ if (r.surface.type === "none") {
675
+ this.desk.material = new ShadowMaterial({ opacity: 0.32, transparent: true, depthWrite: false });
676
+ }
677
+ else {
678
+ const kind = r.surface.type;
679
+ const d = cachedDesk(kind);
680
+ const m = occluded(new MeshStandardMaterial({ map: d.color, normalMap: d.normal, roughnessMap: d.roughness, roughness: d.roughnessScale }), this.sign);
681
+ m.userData.tileMm = d.tileMm;
682
+ if (r.surface.color)
683
+ m.color.set(r.surface.color.hex);
684
+ this.desk.material = m;
685
+ // The scanned surface, once it has loaded (the procedural one meanwhile).
686
+ const base = appearance.textureBaseUrl;
687
+ if (base) {
688
+ void loadDeskMaps(kind, base).then((maps) => {
689
+ if (!maps || this.disposed || this.desk.material !== m)
690
+ return;
691
+ this.dressDesk(m, maps);
692
+ });
693
+ }
694
+ }
695
+ old.dispose();
696
+ Object.assign(this.desk.material, { polygonOffset: true, polygonOffsetFactor: 4, polygonOffsetUnits: 16 });
697
+ // The cover.
698
+ const c = this.coverMaterial;
699
+ c.color.set(r.binding.coverColor.hex);
700
+ const mat = r.binding.coverMaterial;
701
+ c.roughness = mat === "cloth" ? 0.95 : mat === "leather" ? 0.5 : 0.55;
702
+ c.sheen = mat === "cloth" ? 1 : 0;
703
+ c.sheenRoughness = 0.7;
704
+ c.sheenColor.set(r.binding.coverColor.hex).lerp(new Color(1, 1, 1), 0.35);
705
+ c.clearcoat = mat === "paper" ? 0.35 : 0;
706
+ c.normalMap?.dispose();
707
+ c.normalMap = mat === "cloth" ? cachedRelief("linen").normal.clone() : mat === "leather" ? cachedDesk("leather").normal.clone() : null;
708
+ c.needsUpdate = true;
709
+ // The spine.
710
+ const spine = r.binding.type === "saddleStitch" ? null : (appearance.spineImage ?? null);
711
+ if (spine !== this.spineSrc) {
712
+ this.spineSrc = spine;
713
+ this.spineTex?.dispose();
714
+ this.spineTex = null;
715
+ if (spine)
716
+ void this.loadSpine(spine);
717
+ }
718
+ // The light.
719
+ if (this.envKind !== r.lighting.environment) {
720
+ this.envKind = r.lighting.environment;
721
+ this.env = environment(r.lighting.environment);
722
+ const before = this.scene.environment;
723
+ try {
724
+ this.pmrem ??= new PMREMGenerator(this.renderer);
725
+ this.scene.environment = this.pmrem.fromScene(this.env.scene, 0.02).texture;
726
+ }
727
+ catch {
728
+ // No WebGL (tests): no image-based light.
729
+ this.scene.environment = null;
730
+ }
731
+ before?.dispose();
732
+ }
733
+ const env = this.env;
734
+ this.key.color.copy(env.keyColor);
735
+ this.key.intensity = env.keyIntensity;
736
+ this.key.castShadow = r.lighting.shadows;
737
+ this.key.shadow.radius = 6 + env.softness * 18;
738
+ // Soft light round the key fills its shadows: never black.
739
+ this.key.shadow.intensity = 0.55 + 0.25 * (1 - env.softness);
740
+ this.renderer.toneMappingExposure = env.exposure * r.lighting.intensity;
741
+ this.edges.color.set(r.paper.shade.hex);
742
+ this.surfaceKey = "";
743
+ for (const mesh of [this.left, this.right, ...this.leaves.values()])
744
+ mesh.material.userData.spec = null;
439
745
  this.redraw();
440
746
  }
441
- /** Fits the camera so the z = 0 plane maps onto the DOM spread 1:1.
442
- * Sizes are read unrounded and the drawing buffer covers the canvas's
443
- * device pixels exactly, so a page lying flat is drawn texel for pixel,
444
- * as the DOM shows it. */
747
+ /** Puts a scanned surface on the desk: colour (tinted by the surface's
748
+ * colour or the scan's own suggestion), relief, roughness and ambient
749
+ * occlusion, tiled at the scan's physical size. */
750
+ dressDesk(m, maps) {
751
+ m.map = maps.color;
752
+ m.normalMap = maps.normal;
753
+ m.roughnessMap = maps.roughness;
754
+ m.aoMap = maps.ao ?? null;
755
+ m.aoMapIntensity = 0.8;
756
+ m.roughness = 1;
757
+ m.normalScale.set(1, 1);
758
+ m.color.set(this.resolved.surface.color?.hex ?? maps.tint ?? "#ffffff");
759
+ // A scan's tile is shown no larger than about 0.3 mm a texel: a big
760
+ // tile at its true size would read soft and blocky this close; the
761
+ // scans tile seamlessly, so they simply repeat more often.
762
+ const texels = maps.color.image?.width ?? 1024;
763
+ m.userData.tileMm = Math.min(maps.tileMm, texels * 0.3);
764
+ const aniso = this.renderer.capabilities.getMaxAnisotropy();
765
+ for (const t of [maps.color, maps.normal, maps.roughness, maps.ao])
766
+ if (t)
767
+ t.anisotropy = aniso;
768
+ m.needsUpdate = true;
769
+ this.surfaceKey = "";
770
+ this.layout();
771
+ this.redraw();
772
+ }
773
+ /** Pages in the whole book (the ones shown and those round them). */
774
+ pageCount(appearance) {
775
+ const extra = appearance.extraLeaves ?? { before: 0, after: 0 };
776
+ const leaves = Math.max(0, this.book.length - 1) + extra.before + extra.after;
777
+ return appearance.singlePage ? leaves : 2 * leaves;
778
+ }
779
+ /** The colour the book's own boards show at their edges and spine: the
780
+ * setting's cover colour when it names one, else the printed cover's
781
+ * ground. */
782
+ boardColor() {
783
+ const set = new Color(this.resolved.binding.coverColor.hex);
784
+ if (this.appearance.folio?.binding?.coverColor)
785
+ 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;
797
+ }
798
+ sampled = null;
799
+ /** The paper of leaf k (outside the pages shown: the book's). */
800
+ specOf(k) {
801
+ const covers = this.appearance.coverLeaves;
802
+ if (this.coverSpec && (k === covers?.front || k === covers?.back))
803
+ return this.coverSpec;
804
+ return this.leafSpecs[k] ?? this.bookSpec;
805
+ }
806
+ dressPage(mesh, spec) {
807
+ const m = mesh.material;
808
+ if (m.userData.spec === spec)
809
+ return;
810
+ m.userData.spec = spec;
811
+ const p = spec.paper;
812
+ m.color.set(p.shade.hex);
813
+ m.roughness = spec.roughness;
814
+ m.clearcoat = spec.clearcoat;
815
+ m.clearcoatRoughness = spec.clearcoatRoughness;
816
+ m.sheen = spec.sheen;
817
+ m.sheenRoughness = 0.8;
818
+ m.sheenColor.set(1, 1, 1);
819
+ const relief = cachedRelief(p.texture);
820
+ const tex = relief.normal;
821
+ const mm = this.appearance.pageWidthMm ?? 150;
822
+ tex.repeat.set(mm / relief.tileMm, (mm * (this.H / this.W)) / relief.tileMm);
823
+ m.normalMap = p.textureStrength > 0 ? tex : null;
824
+ const coated = p.finish !== "uncoated";
825
+ const s = p.textureStrength * (coated ? 0.35 : 1) * 0.6;
826
+ m.normalScale.set(s, s);
827
+ m.userData.uniforms.uShowThrough.value = spec.showThrough;
828
+ m.needsUpdate = true;
829
+ }
830
+ // ── Layout ──
831
+ pxPerMm() {
832
+ return this.W / (this.appearance.pageWidthMm ?? 150);
833
+ }
834
+ /** The leaves lying on each side now (in the air: neither). */
835
+ stackLeaves() {
836
+ const left = [];
837
+ const right = [];
838
+ this.turned.forEach((t, k) => {
839
+ if (!this.turns.has(k))
840
+ (t ? left : right).push(k);
841
+ });
842
+ return { left, right };
843
+ }
844
+ /** Loads the spine picture, then rebuilds the book to print it. */
845
+ async loadSpine(src) {
846
+ let tex = null;
847
+ if (typeof src === "string") {
848
+ tex = await this.loader.loadAsync(src).catch(() => null);
849
+ }
850
+ else {
851
+ const decoded = src instanceof HTMLCanvasElement ? Promise.resolve() : src.decode();
852
+ tex = await decoded.then(() => new Texture(src), () => null);
853
+ }
854
+ if (!tex)
855
+ return;
856
+ if (this.disposed || this.spineSrc !== src) {
857
+ tex.dispose();
858
+ return;
859
+ }
860
+ tex.colorSpace = SRGBColorSpace;
861
+ tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
862
+ tex.needsUpdate = true;
863
+ this.spineTex = tex;
864
+ this.surfaceKey = "";
865
+ this.buildBook();
866
+ this.redraw();
867
+ }
868
+ /**
869
+ * The spine picture on the two outer faces of the back of the block
870
+ * (the one beside an empty side shows: the front cover is up when the
871
+ * pages lie on the right, down when they lie on the left), fitted to
872
+ * cover a face `depth` thick and `height` long, centred.
873
+ */
874
+ printSpine(back, depth, height) {
875
+ const src = this.spineTex;
876
+ if (!src)
877
+ return;
878
+ const image = src.image;
879
+ const a = image.width / image.height;
880
+ const f = depth / height;
881
+ const rx = a > f ? f / a : 1;
882
+ const ry = a > f ? 1 : a / f;
883
+ const c = this.coverMaterial;
884
+ for (const side of [-1, 1]) {
885
+ const tex = src.clone();
886
+ // u runs across the block from the back cover to the front one; a
887
+ // right-bound book is drawn mirrored, so its picture is turned back.
888
+ tex.repeat.set(this.sign * rx, ry);
889
+ tex.offset.set(this.sign > 0 ? (1 - rx) / 2 : (1 + rx) / 2, (1 - ry) / 2);
890
+ tex.needsUpdate = true;
891
+ const m = occluded(new MeshPhysicalMaterial({
892
+ map: tex,
893
+ roughness: c.roughness,
894
+ sheen: c.sheen,
895
+ sheenRoughness: c.sheenRoughness,
896
+ clearcoat: c.clearcoat,
897
+ normalMap: c.normalMap,
898
+ polygonOffset: true,
899
+ polygonOffsetFactor: 1,
900
+ polygonOffsetUnits: 4,
901
+ }), this.sign);
902
+ const face = new Mesh(new PlaneGeometry(depth, height), m);
903
+ // The plane's +x (the picture's width) runs up the block on the face
904
+ // that looks to −x, down it on the one that looks to +x.
905
+ face.rotation.y = side * (Math.PI / 2);
906
+ face.position.set(side * (back / 2 + 0.05), 0, depth / 2);
907
+ face.receiveShadow = true;
908
+ this.spineFaces.push(face);
909
+ this.stage.add(face);
910
+ }
911
+ }
912
+ clearSpine() {
913
+ for (const face of this.spineFaces) {
914
+ face.removeFromParent();
915
+ face.geometry.dispose();
916
+ face.material.map?.dispose();
917
+ face.material.dispose();
918
+ }
919
+ this.spineFaces = [];
920
+ }
921
+ /** Rebuilds the book's surfaces, blocks and covers when the stacks change. */
922
+ buildBook() {
923
+ const { left, right } = this.stackLeaves();
924
+ const extra = this.appearance.extraLeaves ?? { before: 0, after: 0 };
925
+ const key = `${left.length}|${right.length}|${this.W}|${this.H}|${extra.before}|${extra.after}`;
926
+ if (key === this.surfaceKey && this.surfaces)
927
+ return;
928
+ this.surfaceKey = key;
929
+ const k = this.pxPerMm();
930
+ const half = this.appearance.singlePage ? 0.5 : 1;
931
+ const thick = (leaves, more) => (leaves.reduce((s, i) => s + this.specOf(i).caliperMm, 0) * half + more * this.bookSpec.caliperMm) * k;
932
+ const tL = thick(left, extra.before);
933
+ const tR = thick(right, extra.after);
934
+ const binding = this.resolved.binding.type;
935
+ // A board on top of a stack lies flat; the document's own covers
936
+ // replace the case.
937
+ const noCase = !!this.coverSpec;
938
+ const rigid = (leaf) => leaf !== undefined && this.specOf(leaf).rigidity > 0.5;
939
+ const { left: pl, right: pr, board } = profiles(binding, this.W, k, tL, tR, {
940
+ flatLeft: rigid(left[left.length - 1]),
941
+ flatRight: rigid(right[0]),
942
+ noCase,
943
+ });
944
+ this.surfaces = { left: pl, right: pr };
945
+ const { W, H } = this;
946
+ // The open pages, with the gutter's occlusion.
947
+ for (const [mesh, own, other, side] of [
948
+ [this.right, pr, pl, 1],
949
+ [this.left, pl, pr, -1],
950
+ ]) {
951
+ const ao = gutterOcclusion(own, other, W);
952
+ const pos = mesh.geometry.attributes.position;
953
+ const aoAttr = mesh.geometry.attributes.ao;
954
+ for (let i = 0; i < pos.count; i++) {
955
+ const ix = i % (NX_OPEN + 1);
956
+ const row = Math.floor(i / (NX_OPEN + 1));
957
+ const t = column(ix / NX_OPEN);
958
+ const [x, z] = along(own, t * W);
959
+ pos.setXYZ(i, side * x, row === 0 ? H / 2 : -H / 2, z + 0.02);
960
+ aoAttr.setX(i, ao[Math.round(t * (ao.length - 1))]);
961
+ }
962
+ pos.needsUpdate = true;
963
+ aoAttr.needsUpdate = true;
964
+ mesh.geometry.computeVertexNormals();
965
+ mesh.geometry.computeBoundingSphere();
966
+ }
967
+ // The blocks of leaves.
968
+ for (const s of this.stacks) {
969
+ s.removeFromParent();
970
+ s.geometry.dispose();
971
+ if (s.material !== this.edges && s.material !== this.coverMaterial)
972
+ s.material.dispose();
973
+ }
974
+ this.stacks = [];
975
+ for (const [p, side, n] of [
976
+ [pr, 1, right.length + extra.after],
977
+ [pl, -1, left.length + extra.before],
978
+ ]) {
979
+ if (p.t < 0.05)
980
+ continue;
981
+ // The book's own boards in this block: at its base (the cover turned
982
+ // under the pages) and on top (the book closed).
983
+ const leaves = side > 0 ? right : left;
984
+ const before = side > 0 ? 0 : extra.before;
985
+ const after = side > 0 ? extra.after : 0;
986
+ const baseLeaf = side > 0 ? (after ? undefined : leaves[leaves.length - 1]) : before ? undefined : leaves[0];
987
+ const topLeaf = side > 0 ? leaves[0] : leaves[leaves.length - 1];
988
+ const isBoard = (leaf) => leaf !== undefined && this.coverSpec !== null && this.specOf(leaf) === this.coverSpec;
989
+ const boardPx = (this.coverSpec?.caliperMm ?? 0) * k;
990
+ const bands = this.coverSpec
991
+ ? {
992
+ lo: isBoard(baseLeaf) ? boardPx / p.t : -1,
993
+ hi: isBoard(topLeaf) && topLeaf !== baseLeaf ? 1 - boardPx / p.t : 2,
994
+ color: this.boardColor(),
995
+ }
996
+ : undefined;
997
+ const mesh = new Mesh(stackGeometry(p, side, W, H, Math.round(n * half)), bands ? edgeMaterial(this.sign, bands) : this.edges);
998
+ mesh.castShadow = mesh.receiveShadow = true;
999
+ this.stacks.push(mesh);
1000
+ this.stage.add(mesh);
1001
+ }
1002
+ if (noCase)
1003
+ this.coverMaterial.color.copy(this.boardColor());
1004
+ // The back of the block: the folds sewn at the spine, under the line
1005
+ // where the two open pages meet (nothing shows through the gutter).
1006
+ // Beside an empty side (the book closed, or opened at its first or
1007
+ // last page) the back stands as the spine, the block's full height.
1008
+ const lowSide = Math.min(tL, tR);
1009
+ // It reaches the higher of the two sides at the spine (a board lying
1010
+ // flat on one side meets it low; the block on the other stands tall).
1011
+ 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)));
1013
+ this.clearSpine();
1014
+ if (meet > 0.4) {
1015
+ // In a case the back is wrapped in the case's spine (the wall beside
1016
+ // an empty side, the headcap at head and tail); without one, the
1017
+ // folds of the sections show.
1018
+ const height = H + (noCase ? 0 : 2 * BINDINGS[binding].squareMm * k);
1019
+ const fold = new Mesh(new BoxGeometry(back, height, meet - 0.3), this.coverMaterial);
1020
+ fold.position.set(0, 0, (meet - 0.3) / 2);
1021
+ fold.castShadow = fold.receiveShadow = true;
1022
+ this.stacks.push(fold);
1023
+ this.stage.add(fold);
1024
+ this.printSpine(back, meet - 0.3, height);
1025
+ }
1026
+ // The covers.
1027
+ for (const c of [...this.covers.children]) {
1028
+ c.removeFromParent();
1029
+ c.geometry.dispose();
1030
+ }
1031
+ const b = BINDINGS[binding];
1032
+ const sq = b.squareMm * k;
1033
+ const joint = b.jointMm * k;
1034
+ const reachR = along(pr, W)[0];
1035
+ const reachL = along(pl, W)[0];
1036
+ // The boards stop a hair under the block they carry (and give way in
1037
+ // depth): a page lying on an empty side never sinks into them.
1038
+ const cover = (x0, x1, z1, h) => {
1039
+ const top = Math.max(0.1, z1 - 0.6);
1040
+ const m = new Mesh(new BoxGeometry(x1 - x0, h, top), this.coverMaterial);
1041
+ m.position.set((x0 + x1) / 2, 0, top / 2);
1042
+ m.castShadow = m.receiveShadow = true;
1043
+ this.covers.add(m);
1044
+ };
1045
+ const hh = H + 2 * sq;
1046
+ if (!noCase) {
1047
+ cover(joint, reachR + sq, board, hh);
1048
+ cover(-(reachL + sq), -joint, board, hh);
1049
+ // The spine under the gutter (a hollow back, a little lower).
1050
+ if (joint > 0)
1051
+ cover(-joint, joint, board * 0.7, hh);
1052
+ }
1053
+ const tex = this.coverMaterial.normalMap;
1054
+ if (tex) {
1055
+ const tile = this.resolved.binding.coverMaterial === "cloth" ? 6 : 60;
1056
+ tex.repeat.set(W / k / tile, H / k / tile);
1057
+ }
1058
+ // The book at rest in the height map.
1059
+ for (const c of this.staticCasters)
1060
+ c.removeFromParent();
1061
+ this.staticCasters = [this.left, this.right, ...this.stacks, ...this.covers.children].map((m) => {
1062
+ const c = new Mesh(m.geometry, this.staticCaster);
1063
+ c.position.copy(m.position);
1064
+ c.frustumCulled = false;
1065
+ this.shadowScene.add(c);
1066
+ return c;
1067
+ });
1068
+ occlusionUniforms.uOccRadius.value = 0.14 * W;
1069
+ this.layoutLight();
1070
+ }
1071
+ /** The key light over the book, its shadow covering the book and round. */
1072
+ layoutLight() {
1073
+ const env = this.env;
1074
+ if (!env)
1075
+ return;
1076
+ const { W, H } = this;
1077
+ const d = 4 * Math.max(W, H);
1078
+ const dir = env.key;
1079
+ this.key.position.set(dir.x * d, dir.y * d, dir.z * d);
1080
+ this.key.target.position.set(0, 0, 0);
1081
+ const cam = this.key.shadow.camera;
1082
+ const span = 0.62 * Math.hypot(2 * W, H) + 0.1 * W;
1083
+ Object.assign(cam, { left: -span, right: span, top: span, bottom: -span, near: d * 0.2, far: d * 2 });
1084
+ cam.updateProjectionMatrix();
1085
+ }
1086
+ /** Sizes the drawing buffer to the canvas's device pixels and frames
1087
+ * the tilted book onto the DOM spread: the camera stands in front of
1088
+ * the book and above it, as far back as the whole book needs to fit the
1089
+ * spread's box, the projection shifted so the book sits centred in it. */
445
1090
  layout() {
446
1091
  const box = this.canvas.getBoundingClientRect();
447
1092
  const cw = box.width;
@@ -451,49 +1096,151 @@ export class PageFlipper {
451
1096
  const dpr = Math.min(window.devicePixelRatio || 1, 3);
452
1097
  this.renderer.setPixelRatio(1);
453
1098
  this.renderer.setSize(Math.round(cw * dpr), Math.round(ch * dpr), false);
454
- this.camera.aspect = cw / ch;
455
- this.camera.position.set(0, 0, ch / 2 / Math.tan((FOV * Math.PI) / 360));
456
- this.camera.near = this.camera.position.z / 20;
457
- this.camera.far = this.camera.position.z * 4;
458
- this.camera.updateProjectionMatrix();
459
1099
  const spread = this.spread.getBoundingClientRect();
460
- this.W = spread.width / 2;
461
- this.H = spread.height;
462
- this.flat(this.right.geometry, 1);
463
- this.flat(this.left.geometry, -1);
464
- // The desk plane fills the canvas (its vertices are world positions).
465
- const pos = this.desk.geometry.attributes.position;
466
- for (let i = 0; i < 4; i++)
467
- pos.setXYZ(i, (i % 2 ? 1 : -1) * this.W * 2, (i < 2 ? 1 : -1) * this.H, -1);
468
- pos.needsUpdate = true;
469
- this.desk.material.uniforms.uW.value = this.W;
470
- this.desk.material.uniforms.uH.value = this.H;
471
- // The height map covers the canvas (1 texel ≈ 1 px at the desk).
1100
+ const W = spread.width / 2 || 1;
1101
+ const H = spread.height || 1;
1102
+ if (W !== this.W || H !== this.H) {
1103
+ this.W = W;
1104
+ this.H = H;
1105
+ for (const mesh of [this.left, this.right, ...this.leaves.values()])
1106
+ mesh.material.userData.spec = null;
1107
+ }
1108
+ this.buildBook();
1109
+ // The desk: far wider than the view, tiled at its physical scale.
1110
+ const k = this.pxPerMm();
1111
+ const size = 40 * Math.max(W, H);
1112
+ if (this.desk.userData.size !== size) {
1113
+ this.desk.userData.size = size;
1114
+ this.desk.geometry.dispose();
1115
+ this.desk.geometry = new PlaneGeometry(size, size);
1116
+ }
1117
+ const dm = this.desk.material;
1118
+ if (dm.map && this.resolved.surface.type !== "none") {
1119
+ const tile = (dm.userData.tileMm ?? cachedDesk(this.resolved.surface.type).tileMm) * k;
1120
+ for (const t of [dm.map, dm.normalMap, dm.roughnessMap, dm.aoMap])
1121
+ t?.repeat.set(size / tile, size / tile);
1122
+ }
1123
+ // The camera, tilted about the book's horizontal axis and turned round
1124
+ // it as the reader has orbited it.
1125
+ const tilt = this.orbit.pitch ?? (this.resolved.tilt * Math.PI) / 180;
1126
+ const yaw = this.orbit.yaw;
1127
+ const sq = BINDINGS[this.resolved.binding.type].squareMm * k;
1128
+ const zTop = Math.max(this.topZ("left"), this.topZ("right"));
1129
+ const corners = [];
1130
+ for (const x of [-(W + sq), W + sq])
1131
+ for (const y of [-(H / 2 + sq), H / 2 + sq])
1132
+ for (const z of [0, zTop])
1133
+ corners.push(new Vector3(x, y, z));
1134
+ const cam = this.camera;
1135
+ cam.aspect = cw / ch;
1136
+ cam.clearViewOffset();
1137
+ const fx = spread.width / cw;
1138
+ const fy = spread.height / ch;
1139
+ const place = (dist) => {
1140
+ cam.position.set(Math.sin(yaw) * Math.sin(tilt) * dist, -Math.cos(yaw) * Math.sin(tilt) * dist, zTop / 2 + Math.cos(tilt) * dist);
1141
+ cam.up.set(-Math.sin(yaw), Math.cos(yaw), 0);
1142
+ cam.lookAt(0, 0, zTop / 2);
1143
+ // A tight near plane: the book is the nearest thing to the eye, and
1144
+ // depth precision is what keeps a leaf lying on the page under it
1145
+ // from flickering through.
1146
+ cam.near = dist * 0.35;
1147
+ cam.far = dist * 4 + size;
1148
+ cam.updateProjectionMatrix();
1149
+ cam.updateMatrixWorld();
1150
+ let x0 = Infinity;
1151
+ let x1 = -Infinity;
1152
+ let y0 = Infinity;
1153
+ let y1 = -Infinity;
1154
+ for (const c of corners) {
1155
+ const p = c.clone().project(cam);
1156
+ x0 = Math.min(x0, p.x);
1157
+ x1 = Math.max(x1, p.x);
1158
+ y0 = Math.min(y0, p.y);
1159
+ y1 = Math.max(y1, p.y);
1160
+ }
1161
+ return { x0, x1, y0, y1 };
1162
+ };
1163
+ let lo = (ch / 2 / Math.tan((FOV * Math.PI) / 360)) * 0.3;
1164
+ let hi = lo * 20;
1165
+ for (let i = 0; i < 32; i++) {
1166
+ const mid = (lo + hi) / 2;
1167
+ const e = place(mid);
1168
+ if ((e.x1 - e.x0) / 2 <= fx && (e.y1 - e.y0) / 2 <= fy)
1169
+ hi = mid;
1170
+ else
1171
+ lo = mid;
1172
+ }
1173
+ const e = place(hi);
1174
+ const w = Math.round(cw * dpr);
1175
+ const h = Math.round(ch * dpr);
1176
+ cam.setViewOffset(w, h, (((e.x0 + e.x1) / 2) * w) / 2, (-((e.y0 + e.y1) / 2) * h) / 2, w, h);
1177
+ cam.updateProjectionMatrix();
1178
+ // The height map covers the book and round it.
472
1179
  if (this.shadowTarget) {
473
- const hw = cw / 2 + 60;
474
- const hh = ch / 2 + 60;
1180
+ const hw = W * 1.6 + 80;
1181
+ const hh = H * 0.9 + 80;
475
1182
  Object.assign(this.shadowCamera, { left: -hw, right: hw, top: hh, bottom: -hh });
476
1183
  this.shadowCamera.updateProjectionMatrix();
477
- const scale = Math.min(1, 2048 / (2 * hw));
1184
+ const scale = Math.min(1, 1536 / (2 * hw));
478
1185
  this.shadowTarget.setSize(Math.round(2 * hw * scale), Math.round(2 * hh * scale));
479
- shadowUniforms.uShadowBox.value.set(-hw, -hh, 2 * hw, 2 * hh);
480
- shadowUniforms.uShadowMap.value = this.shadowTarget.texture;
1186
+ occlusionUniforms.uShadowBox.value.set(-hw, -hh, 2 * hw, 2 * hh);
1187
+ occlusionUniforms.uShadowMap.value = this.shadowTarget.texture;
481
1188
  }
482
1189
  }
483
- flat(geometry, side) {
484
- const pos = geometry.attributes.position;
485
- // PlaneGeometry(1, 1, 1, 1): TL, TR, BL, BR; uv.x = 0 at the spine.
486
- const { W, H } = this;
487
- pos.setXYZ(0, 0, H / 2, 0);
488
- pos.setXYZ(1, side * W, H / 2, 0);
489
- pos.setXYZ(2, 0, -H / 2, 0);
490
- pos.setXYZ(3, side * W, -H / 2, 0);
491
- pos.needsUpdate = true;
492
- geometry.computeVertexNormals();
493
- }
494
- /** The colour of the book's paper (0 … 1), for its blank pages. */
1190
+ /** The height of a side's open page away from the gutter. */
1191
+ topZ(side) {
1192
+ const p = this.surfaces?.[side];
1193
+ return p ? p.zs[p.zs.length - 1] : 0;
1194
+ }
1195
+ /** Turns the view round the book (`dx`, px of a drag) and tilts it
1196
+ * (`dy`), never below a low angle over the table. */
1197
+ orbitBy(dx, dy) {
1198
+ cancelAnimationFrame(this.orbitFrame);
1199
+ this.orbitFrame = 0;
1200
+ const pitch = this.orbit.pitch ?? (this.resolved.tilt * Math.PI) / 180;
1201
+ this.orbit = {
1202
+ yaw: this.orbit.yaw - dx * 0.006,
1203
+ pitch: Math.min(MAX_PITCH, Math.max(0, pitch - dy * 0.006)),
1204
+ };
1205
+ this.redraw();
1206
+ }
1207
+ /** Back to the view the settings give, easing there (the reader let go
1208
+ * of the view). */
1209
+ resetOrbit() {
1210
+ cancelAnimationFrame(this.orbitFrame);
1211
+ const home = (this.resolved.tilt * Math.PI) / 180;
1212
+ // The shorter way round.
1213
+ const yaw0 = Math.atan2(Math.sin(this.orbit.yaw), Math.cos(this.orbit.yaw));
1214
+ const pitch0 = this.orbit.pitch ?? home;
1215
+ if (Math.abs(yaw0) < 1e-3 && Math.abs(pitch0 - home) < 1e-3) {
1216
+ this.orbit = { yaw: 0, pitch: null };
1217
+ return this.redraw();
1218
+ }
1219
+ const start = performance.now();
1220
+ const duration = 450 + 250 * Math.min(1, Math.hypot(yaw0, pitch0 - home));
1221
+ const step = (now) => {
1222
+ this.orbitFrame = 0;
1223
+ if (this.disposed)
1224
+ return;
1225
+ const t = Math.min(1, (now - start) / duration);
1226
+ const e = ease(t);
1227
+ this.orbit = t < 1 ? { yaw: yaw0 * (1 - e), pitch: pitch0 + (home - pitch0) * e } : { yaw: 0, pitch: null };
1228
+ this.layout();
1229
+ // Leaves in the air draw in their own frames.
1230
+ if (!this.raf && !this.starting)
1231
+ this.draw();
1232
+ if (t < 1)
1233
+ this.orbitFrame = requestAnimationFrame(step);
1234
+ };
1235
+ this.orbitFrame = requestAnimationFrame(step);
1236
+ }
1237
+ orbitFrame = 0;
1238
+ /** The colour of the book's blank pages (0 … 1, as CSS gives it). */
495
1239
  setPaper(r, g, b) {
496
- paperUniform.uPaper.value.set(r, g, b);
1240
+ this.paper = [r, g, b];
1241
+ for (const mesh of [this.left, this.right, ...this.leaves.values()])
1242
+ mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1243
+ this.redraw();
497
1244
  }
498
1245
  /** Starts loading the pages of these spreads as textures. */
499
1246
  preload(indexes) {
@@ -506,7 +1253,7 @@ export class PageFlipper {
506
1253
  let hit = this.textures.get(src);
507
1254
  if (!hit) {
508
1255
  const prepare = (tex) => {
509
- tex.colorSpace = NoColorSpace;
1256
+ tex.colorSpace = SRGBColorSpace;
510
1257
  tex.minFilter = LinearMipmapLinearFilter;
511
1258
  tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
512
1259
  this.ready.set(src, tex);
@@ -554,6 +1301,9 @@ export class PageFlipper {
554
1301
  this.turned = Array.from({ length: Math.max(0, book.length - 1) }, (_, k) => k < open);
555
1302
  this.target = open;
556
1303
  this.reported = open;
1304
+ // Another extent may call for another stock.
1305
+ if (resized)
1306
+ this.setAppearance(this.appearance);
557
1307
  if (!this.raf)
558
1308
  this.clear();
559
1309
  }
@@ -574,7 +1324,7 @@ export class PageFlipper {
574
1324
  this.redraw();
575
1325
  }
576
1326
  assign(mesh, face, src) {
577
- const u = mesh.material.uniforms;
1327
+ const u = mesh.material.userData.uniforms;
578
1328
  if (mesh.userData[face] === src)
579
1329
  return;
580
1330
  mesh.userData[face] = src;
@@ -600,8 +1350,14 @@ export class PageFlipper {
600
1350
  leaf(k) {
601
1351
  let mesh = this.leaves.get(k);
602
1352
  if (!mesh) {
603
- mesh = new Mesh(new PlaneGeometry(1, 1, NX, NY), material(true, this.sign < 0));
604
- const caster = new Mesh(mesh.geometry, this.casterMaterial);
1353
+ const geometry = new PlaneGeometry(1, 1, NX, NY);
1354
+ spreadColumns(geometry, NX);
1355
+ geometry.setAttribute("ao", new BufferAttribute(new Float32Array(geometry.attributes.position.count).fill(1), 1));
1356
+ mesh = new Mesh(geometry, pageMaterial(this.sign < 0, this.sign));
1357
+ mesh.material.userData.uniforms.uPaper.value.setRGB(...this.paper, SRGBColorSpace);
1358
+ mesh.castShadow = mesh.receiveShadow = true;
1359
+ mesh.frustumCulled = false;
1360
+ const caster = new Mesh(mesh.geometry, this.leafCaster);
605
1361
  caster.frustumCulled = false;
606
1362
  this.shadowScene.add(caster);
607
1363
  mesh.userData.caster = caster;
@@ -609,6 +1365,7 @@ export class PageFlipper {
609
1365
  this.assign(mesh, "Back", this.book[k + 1]?.[0] ?? null);
610
1366
  this.leaves.set(k, mesh);
611
1367
  }
1368
+ this.dressPage(mesh, this.specOf(k));
612
1369
  return mesh;
613
1370
  }
614
1371
  /** Turns the pages until spread `index` lies open. */
@@ -633,6 +1390,7 @@ export class PageFlipper {
633
1390
  if (this.disposed)
634
1391
  return;
635
1392
  this.layout();
1393
+ this.lastFrame = performance.now();
636
1394
  this.raf = requestAnimationFrame(this.frame);
637
1395
  });
638
1396
  }
@@ -655,14 +1413,32 @@ export class PageFlipper {
655
1413
  this.raf = 0;
656
1414
  if (this.disposed)
657
1415
  return;
1416
+ const dt = Math.min(40, Math.max(1, now - (this.lastFrame || now - 16)));
1417
+ this.lastFrame = now;
658
1418
  const n = this.turned.length;
659
1419
  const progress = (turn) => progressFrom(turn.G, turn.P);
660
1420
  // Move the hands; land the leaves that have finished (or fallen back).
661
1421
  for (const [k, turn] of this.turns) {
1422
+ const spec = this.specOf(k);
662
1423
  if (turn.held) {
663
- // The page follows the hand with a little inertia.
1424
+ // The page follows the hand with a little inertia (more for a
1425
+ // heavier leaf).
664
1426
  const { aim } = turn.held;
665
- turn.P = reach(turn.G, { u: turn.P.u + (aim.u - turn.P.u) * 0.35, v: turn.P.v + (aim.v - turn.P.v) * 0.35 }, this.H);
1427
+ const f = 1 - Math.pow(1 - spec.follow, dt / 16);
1428
+ turn.P = reach(turn.G, { u: turn.P.u + (aim.u - turn.P.u) * f, v: turn.P.v + (aim.v - turn.P.v) * f }, this.H);
1429
+ }
1430
+ else if (turn.spring) {
1431
+ const sp = turn.spring;
1432
+ const t = Math.min(1, (now - sp.start) / sp.duration);
1433
+ const h00 = 2 * t ** 3 - 3 * t ** 2 + 1;
1434
+ const h10 = t ** 3 - 2 * t ** 2 + t;
1435
+ const h01 = -2 * t ** 3 + 3 * t ** 2;
1436
+ const at = (axis) => h00 * sp.from[axis] + h10 * sp.duration * sp.vel[axis] + h01 * sp.to[axis];
1437
+ turn.P = reach(turn.G, { u: at("u"), v: at("v") }, this.H);
1438
+ if (t >= 1) {
1439
+ this.turned[k] = sp.lands ? turn.forward : !turn.forward;
1440
+ this.turns.delete(k);
1441
+ }
666
1442
  }
667
1443
  else if (turn.anim) {
668
1444
  const { start, duration, from, to, arc, lands } = turn.anim;
@@ -690,14 +1466,14 @@ export class PageFlipper {
690
1466
  continue;
691
1467
  if (k > 0 && !follows(k - 1, true))
692
1468
  break;
693
- this.turns.set(k, this.lift(true, now));
1469
+ this.turns.set(k, { ...this.lift(true, now), hinge: this.hingeOf(k, true) });
694
1470
  }
695
1471
  for (let k = n - 1; k >= this.target; k--) {
696
1472
  if (!this.turned[k] || this.turns.has(k))
697
1473
  continue;
698
1474
  if (k < n - 1 && !follows(k + 1, false))
699
1475
  break;
700
- this.turns.set(k, this.lift(false, now));
1476
+ this.turns.set(k, { ...this.lift(false, now), hinge: this.hingeOf(k, false) });
701
1477
  }
702
1478
  this.draw();
703
1479
  this.spread.classList.add("is-turning");
@@ -717,11 +1493,41 @@ export class PageFlipper {
717
1493
  }
718
1494
  }
719
1495
  // ── The reader's hand ──
720
- /** The pointer on the z = 0 plane, in the book's coordinates (x
721
- * mirrored for a right-bound book). */
1496
+ /** The pointer on the pages' plane, in the book's coordinates (x
1497
+ * mirrored for a right-bound book): a ray from the eye through the
1498
+ * pointer, met with the plane the open pages lie in on that side. */
722
1499
  toWorld(event) {
723
1500
  const rect = this.canvas.getBoundingClientRect();
724
- return { x: this.sign * (event.clientX - (rect.left + rect.width / 2)), y: -(event.clientY - (rect.top + rect.height / 2)) };
1501
+ const ndc = new Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -(((event.clientY - rect.top) / rect.height) * 2 - 1));
1502
+ this.camera.updateMatrixWorld();
1503
+ this.raycaster.setFromCamera(ndc, this.camera);
1504
+ const hit = new Vector3();
1505
+ 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");
1508
+ let p = at((zl + zr) / 2);
1509
+ if (p)
1510
+ p = at(p.x * this.sign > 0 ? zr : zl) ?? p;
1511
+ if (!p)
1512
+ return { x: 0, y: 1e9 };
1513
+ return { x: this.sign * p.x, y: p.y };
1514
+ }
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");
1521
+ }
1522
+ /** The page under the pointer: +1 the one a forward turn takes (the
1523
+ * recto), −1 the other, 0 none. */
1524
+ hit(event) {
1525
+ if (!this.surfaces)
1526
+ this.layout();
1527
+ const { x, y } = this.toWorld(event);
1528
+ if (Math.abs(y) > this.H / 2 || Math.abs(x) > this.W * 1.05)
1529
+ return 0;
1530
+ return x > 0 ? 1 : -1;
725
1531
  }
726
1532
  /** Takes hold of the page under the pointer; false when there is none
727
1533
  * to turn (or pages are already in the air). */
@@ -733,10 +1539,11 @@ export class PageFlipper {
733
1539
  const at = this.settledAt();
734
1540
  const forward = x > 0;
735
1541
  const k = forward ? at : at - 1;
736
- if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W || Math.abs(y) > this.H / 2)
1542
+ if (k < 0 || k >= this.turned.length || Math.abs(x) > this.W * 1.05 || Math.abs(y) > this.H / 2)
737
1543
  return false;
738
- const G = { u: Math.max(0.15 * this.W, Math.abs(x)), v: y };
739
- this.turns.set(k, { forward, G, P: { ...G }, held: { aim: { ...G }, samples: [] } });
1544
+ 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) });
1546
+ this.lastFrame = performance.now();
740
1547
  this.raf = requestAnimationFrame(this.frame);
741
1548
  return true;
742
1549
  }
@@ -748,80 +1555,115 @@ export class PageFlipper {
748
1555
  const { x, y } = this.toWorld(event);
749
1556
  turn.held.aim = reach(turn.G, { u: turn.forward ? x : -x, v: y }, this.H);
750
1557
  const t = performance.now();
751
- turn.held.samples.push({ t, q: progressFrom(turn.G, turn.held.aim) });
752
- while (turn.held.samples.length > 2 && t - turn.held.samples[0].t > 120)
1558
+ turn.held.samples.push({ t, p: { ...turn.held.aim } });
1559
+ while (turn.held.samples.length > 2 && t - turn.held.samples[0].t > 100)
753
1560
  turn.held.samples.shift();
754
1561
  }
755
1562
  /** Lets go. A click turns the page over from where it was taken; a drag
756
- * carries on over if the page is past halfway or thrown that way, and
757
- * falls back otherwise. */
1563
+ * carries on to the nearer side (the throw's speed counted, as if it
1564
+ * went on a quarter of a second), the paper easing there from the hand's
1565
+ * speed, never jumping. */
758
1566
  release(click = false) {
759
1567
  const entry = [...this.turns].find(([, t]) => t.held);
760
1568
  if (!entry)
761
1569
  return;
762
1570
  const [k, turn] = entry;
763
1571
  const { samples } = turn.held;
1572
+ turn.held = undefined;
1573
+ if (click) {
1574
+ // The hand lifts the point clicked and carries it over, arching
1575
+ // towards the middle of the page: a click near the head turns the
1576
+ // leaf from its top corner, near the foot from its bottom one.
1577
+ turn.anim = { start: performance.now(), duration: DURATION, from: { ...turn.P }, to: { u: -turn.G.u, v: turn.G.v }, arc: (turn.G.v > 0 ? -1 : 1) * 0.2 * this.H, lands: true };
1578
+ this.target = turn.forward ? k + 1 : k;
1579
+ this.onTarget(this.target);
1580
+ return;
1581
+ }
764
1582
  const first = samples[0];
765
1583
  const last = samples[samples.length - 1];
766
- const speed = first && last && last.t > first.t ? (last.q - first.q) / (last.t - first.t) : 0; // per ms
767
- const q = progressFrom(turn.G, turn.P);
768
- const over = click || q + speed * 250 > this.turnAt;
1584
+ const span = first && last ? last.t - first.t : 0;
1585
+ // The hand's speed when it let go (px per ms), held to a sane range;
1586
+ // a hand that stood still before letting go throws nothing.
1587
+ const vel = span > 8 ? { u: (last.p.u - first.p.u) / span, v: (last.p.v - first.p.v) / span } : { u: 0, v: 0 };
1588
+ const speed = Math.hypot(vel.u, vel.v);
1589
+ if (speed > 4) {
1590
+ vel.u *= 4 / speed;
1591
+ vel.v *= 4 / speed;
1592
+ }
1593
+ if (last && performance.now() - last.t > 80)
1594
+ vel.u = vel.v = 0;
1595
+ const ahead = progressFrom(turn.G, { u: turn.P.u + vel.u * 250, v: turn.P.v });
1596
+ const over = ahead > this.turnAt;
769
1597
  const to = over ? { u: -turn.G.u, v: turn.G.v } : { ...turn.G };
770
- turn.held = undefined;
771
- turn.anim = {
772
- start: performance.now(),
773
- duration: click ? DURATION : Math.max(260, DURATION * 0.8 * (over ? 1 - q : q)),
774
- from: { ...turn.P },
775
- to,
776
- arc: click ? 0.2 * this.H : 0,
777
- lands: over,
778
- };
1598
+ // Long enough to read as paper settling, scaled by the distance left
1599
+ // and by the paper (a stiff leaf springs back sooner).
1600
+ const distance = Math.hypot(to.u - turn.P.u, to.v - turn.P.v);
1601
+ const duration = Math.min(700, Math.max(280, (180 + 0.9 * distance) * (0.0085 / this.specOf(k).spring)));
1602
+ // The hand's speed carried in, but never so much that the curve
1603
+ // overshoots the rest position.
1604
+ const along = (vel.u * (to.u - turn.P.u) + vel.v * (to.v - turn.P.v)) / (distance || 1);
1605
+ const cap = (1.5 * distance) / duration;
1606
+ const scale = along > cap ? cap / along : 1;
1607
+ turn.spring = { from: { ...turn.P }, to, vel: { u: vel.u * scale, v: vel.v * scale }, start: performance.now(), duration, lands: over };
779
1608
  if (over) {
780
1609
  this.target = turn.forward ? k + 1 : k;
781
1610
  this.onTarget(this.target);
782
1611
  }
783
1612
  }
784
1613
  draw() {
1614
+ this.buildBook();
1615
+ const book = this.surfaces;
785
1616
  const { W, H } = this;
786
1617
  const n = this.turned.length;
787
1618
  // The leaves in the air, in book order.
788
1619
  const air = [...this.turns]
789
1620
  .sort(([a], [b]) => a - b)
790
- .map(([k, turn]) => ({
791
- k,
792
- q: progressFrom(turn.G, turn.P),
793
- forward: turn.forward,
794
- fold: foldOf(turn.G, turn.P, W, H),
795
- mesh: this.leaf(k),
796
- }));
1621
+ .map(([k, turn]) => {
1622
+ const spec = this.specOf(k);
1623
+ return { k, q: progressFrom(turn.G, turn.P), forward: turn.forward, fold: foldOf(turn.G, turn.P, W, H, spec.roll), mesh: this.leaf(k), spec };
1624
+ });
797
1625
  for (const leaf of this.leaves.values())
798
1626
  leaf.visible = leaf.userData.caster.visible = false;
799
- air.forEach(({ q, forward, fold, mesh }, i) => {
1627
+ const k = this.pxPerMm();
1628
+ air.forEach(({ k: leafK, q, forward, fold, mesh, spec }, i) => {
800
1629
  // Leaf k lies over leaf k + 1 on the right and under it on the left.
801
1630
  const side = forward ? q : 1 - q;
802
- layLeaf(mesh.geometry, fold, forward, W, H, 0.6 + 0.8 * ((1 - side) * (air.length - 1 - i) + side * i));
1631
+ const lift = (0.5 + 0.6 * ((1 - side) * (air.length - 1 - i) + side * i)) * Math.max(1, spec.caliperMm * k);
1632
+ // A floppy leaf flutters in flight (none at rest, none for card).
1633
+ 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);
803
1635
  mesh.visible = mesh.userData.caster.visible = true;
804
1636
  if (!mesh.parent)
805
1637
  this.stage.add(mesh);
806
1638
  });
807
- // The pages lying flat: the top leaf of each stack.
1639
+ // The pages lying open: the top leaf of each stack.
808
1640
  let l = -1;
809
- for (let k = 0; k < n; k++)
810
- if (this.turned[k] && !this.turns.has(k))
811
- l = k;
1641
+ for (let j = 0; j < n; j++)
1642
+ if (this.turned[j] && !this.turns.has(j))
1643
+ l = j;
812
1644
  let r = n;
813
- for (let k = n - 1; k >= 0; k--)
814
- if (!this.turned[k] && !this.turns.has(k))
815
- r = k;
1645
+ for (let j = n - 1; j >= 0; j--)
1646
+ if (!this.turned[j] && !this.turns.has(j))
1647
+ r = j;
816
1648
  const leftSrc = this.book[l + 1]?.[0] ?? null;
817
1649
  const rightSrc = this.book[r]?.[1] ?? null;
818
1650
  this.assign(this.left, "Back", leftSrc);
819
1651
  this.assign(this.right, "Front", rightSrc);
820
- this.desk.material.uniforms.uPages.value.set(Number(leftSrc !== null), Number(rightSrc !== null));
821
- // The height map the shadows are read from.
822
- shadowUniforms.uLd.value = this.light;
823
- shadowUniforms.uShadowOn.value = this.shadowTarget && air.length ? 1 : 0;
824
- if (this.shadowTarget && air.length) {
1652
+ // What shows through an open page: the other side of its leaf.
1653
+ this.assign(this.left, "Front", l >= 0 ? (this.book[l]?.[1] ?? null) : null);
1654
+ this.assign(this.right, "Back", this.book[r + 1]?.[0] ?? null);
1655
+ const extra = this.appearance.extraLeaves;
1656
+ this.left.visible = leftSrc !== null || (extra?.before ?? 0) > 0;
1657
+ this.right.visible = rightSrc !== null || (extra?.after ?? 0) > 0;
1658
+ this.dressPage(this.left, l >= 0 ? this.specOf(l) : this.bookSpec);
1659
+ this.dressPage(this.right, r < n ? this.specOf(r) : this.bookSpec);
1660
+ // The height map lifted leaves occlude the sky from.
1661
+ for (const c of this.staticCasters)
1662
+ c.visible = true;
1663
+ this.staticCasters[0].visible = this.left.visible;
1664
+ this.staticCasters[1].visible = this.right.visible;
1665
+ occlusionUniforms.uShadowOn.value = this.shadowTarget ? 1 : 0;
1666
+ if (this.shadowTarget) {
825
1667
  this.renderer.setRenderTarget(this.shadowTarget);
826
1668
  this.renderer.render(this.shadowScene, this.shadowCamera);
827
1669
  this.renderer.setRenderTarget(null);
@@ -856,14 +1698,25 @@ export class PageFlipper {
856
1698
  this.disposed = true;
857
1699
  cancelAnimationFrame(this.raf);
858
1700
  cancelAnimationFrame(this.still);
1701
+ cancelAnimationFrame(this.orbitFrame);
859
1702
  for (const tex of this.ready.values())
860
1703
  tex.dispose();
861
1704
  this.shadowTarget?.dispose();
862
- this.casterMaterial.dispose();
863
- for (const mesh of [this.left, this.right, this.desk, ...this.leaves.values()]) {
1705
+ this.leafCaster.dispose();
1706
+ this.staticCaster.dispose();
1707
+ this.scene.environment?.dispose();
1708
+ this.pmrem?.dispose();
1709
+ for (const mesh of [this.left, this.right, this.desk, ...this.stacks, ...this.leaves.values()]) {
864
1710
  mesh.geometry.dispose();
865
1711
  mesh.material.dispose();
866
1712
  }
1713
+ for (const c of this.covers.children)
1714
+ c.geometry.dispose();
1715
+ this.clearSpine();
1716
+ this.spineTex?.dispose();
1717
+ this.coverMaterial.normalMap?.dispose();
1718
+ this.coverMaterial.dispose();
1719
+ this.edges.dispose();
867
1720
  this.renderer.dispose();
868
1721
  }
869
1722
  }