postext-folio 0.1.2 → 0.2.1

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