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