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.
- 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 +97 -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 +1272 -340
- 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,392 @@
|
|
|
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, 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
|
-
/**
|
|
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
|
-
|
|
37
|
+
vec4 t = texture2D(uShadowMap, uv);
|
|
38
|
+
return (channel > 0.5 ? t.g : t.r) - 10.0;
|
|
43
39
|
}
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
|
49
|
-
float a = float(
|
|
50
|
-
|
|
51
|
-
float
|
|
52
|
-
|
|
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 /
|
|
59
|
+
return occ / 8.0;
|
|
55
60
|
}
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
81
|
+
uniform float uSign;
|
|
182
82
|
varying float vZ;
|
|
183
83
|
void main() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
gl_Position = projectionMatrix * viewMatrix * vec4(
|
|
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,
|
|
93
|
+
gl_FragColor = vec4(vZ + 10.0, uLeaf * (vZ + 10.0), 0.0, 1.0);
|
|
193
94
|
}
|
|
194
95
|
`;
|
|
195
|
-
/** The
|
|
196
|
-
const
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
207
|
-
fragmentShader:
|
|
166
|
+
vertexShader: CASTER_VERTEX,
|
|
167
|
+
fragmentShader: CASTER_FRAGMENT,
|
|
208
168
|
side: DoubleSide,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
/**
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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
|
|
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,
|
|
585
|
+
shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 4000);
|
|
346
586
|
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
|
-
});
|
|
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)
|
|
387
|
-
|
|
388
|
-
|
|
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.
|
|
402
|
-
this.
|
|
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.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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:
|
|
426
|
-
magFilter:
|
|
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
|
-
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
this.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
474
|
-
const hh =
|
|
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,
|
|
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
|
-
|
|
480
|
-
|
|
1265
|
+
occlusionUniforms.uShadowBox.value.set(-hw, -hh, 2 * hw, 2 * hh);
|
|
1266
|
+
occlusionUniforms.uShadowMap.value = this.shadowTarget.texture;
|
|
481
1267
|
}
|
|
482
1268
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
752
|
-
while (turn.held.samples.length > 2 && t - turn.held.samples[0].t >
|
|
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
|
|
757
|
-
*
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1718
|
+
// The pages lying open: the top leaf of each stack.
|
|
808
1719
|
let l = -1;
|
|
809
|
-
for (let
|
|
810
|
-
if (this.turned[
|
|
811
|
-
l =
|
|
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
|
|
814
|
-
if (!this.turned[
|
|
815
|
-
r =
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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.
|
|
863
|
-
|
|
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
|
}
|