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.
- package/README.md +59 -4
- package/dist/bookGeometry.d.ts +77 -0
- package/dist/bookGeometry.d.ts.map +1 -0
- package/dist/bookGeometry.js +204 -0
- package/dist/bookGeometry.js.map +1 -0
- package/dist/deskTextures.d.ts +24 -0
- package/dist/deskTextures.d.ts.map +1 -0
- package/dist/deskTextures.js +88 -0
- package/dist/deskTextures.js.map +1 -0
- package/dist/environments.d.ts +27 -0
- package/dist/environments.d.ts.map +1 -0
- package/dist/environments.js +90 -0
- package/dist/environments.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pageFlip.d.ts +160 -26
- package/dist/pageFlip.d.ts.map +1 -1
- package/dist/pageFlip.js +1195 -342
- package/dist/pageFlip.js.map +1 -1
- package/dist/pageFlipFlat.d.ts +131 -0
- package/dist/pageFlipFlat.d.ts.map +1 -0
- package/dist/pageFlipFlat.js +870 -0
- package/dist/pageFlipFlat.js.map +1 -0
- package/dist/paper.d.ts +40 -0
- package/dist/paper.d.ts.map +1 -0
- package/dist/paper.js +65 -0
- package/dist/paper.js.map +1 -0
- package/dist/postext.d.ts +12 -2
- package/dist/postext.d.ts.map +1 -1
- package/dist/postext.js +167 -6
- package/dist/postext.js.map +1 -1
- package/dist/procedural.d.ts +35 -0
- package/dist/procedural.d.ts.map +1 -0
- package/dist/procedural.js +390 -0
- package/dist/procedural.js.map +1 -0
- package/dist/viewer.d.ts +41 -1
- package/dist/viewer.d.ts.map +1 -1
- package/dist/viewer.js +133 -16
- package/dist/viewer.js.map +1 -1
- package/package.json +3 -3
package/dist/pageFlip.js
CHANGED
|
@@ -1,239 +1,313 @@
|
|
|
1
|
-
import { CustomBlending, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter,
|
|
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
|
-
/**
|
|
6
|
-
const
|
|
7
|
-
/**
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
float
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
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
|
|
67
|
+
uniform float uSign;
|
|
182
68
|
varying float vZ;
|
|
183
69
|
void main() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
gl_Position = projectionMatrix * viewMatrix * vec4(
|
|
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,
|
|
79
|
+
gl_FragColor = vec4(vZ + 10.0, uLeaf * (vZ + 10.0), 0.0, 1.0);
|
|
193
80
|
}
|
|
194
81
|
`;
|
|
195
|
-
/** The
|
|
196
|
-
const
|
|
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
|
-
|
|
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:
|
|
207
|
-
fragmentShader:
|
|
92
|
+
vertexShader: CASTER_VERTEX,
|
|
93
|
+
fragmentShader: CASTER_FRAGMENT,
|
|
208
94
|
side: DoubleSide,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
/**
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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
|
|
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,
|
|
506
|
+
shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 4000);
|
|
346
507
|
shadowTarget = null;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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)
|
|
387
|
-
|
|
388
|
-
|
|
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.
|
|
402
|
-
this.
|
|
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.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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:
|
|
426
|
-
magFilter:
|
|
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
|
-
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
this.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
474
|
-
const hh =
|
|
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,
|
|
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
|
-
|
|
480
|
-
|
|
1186
|
+
occlusionUniforms.uShadowBox.value.set(-hw, -hh, 2 * hw, 2 * hh);
|
|
1187
|
+
occlusionUniforms.uShadowMap.value = this.shadowTarget.texture;
|
|
481
1188
|
}
|
|
482
1189
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
752
|
-
while (turn.held.samples.length > 2 && t - turn.held.samples[0].t >
|
|
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
|
|
757
|
-
*
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1639
|
+
// The pages lying open: the top leaf of each stack.
|
|
808
1640
|
let l = -1;
|
|
809
|
-
for (let
|
|
810
|
-
if (this.turned[
|
|
811
|
-
l =
|
|
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
|
|
814
|
-
if (!this.turned[
|
|
815
|
-
r =
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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.
|
|
863
|
-
|
|
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
|
}
|