leafthrough 0.1.4 → 0.1.6

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 CHANGED
@@ -6,7 +6,8 @@ Vue, Svelte, and a Web Component for everything else (Angular, Solid, Lit, plain
6
6
  can also be [one object in your own three.js scene](#in-your-own-threejs-scene).
7
7
 
8
8
  **[Live demo](https://dmtrbrl.github.io/leafthrough/)**, with one page per binding and a dev panel
9
- for every option, method and event.
9
+ for every option, method and event, and
10
+ [a three.js scene](https://dmtrbrl.github.io/leafthrough/three.html) with the magazine in it.
10
11
 
11
12
  - **Two layouts.** `spread` is an open magazine with sheets turning over the spine.
12
13
  `single` shows one page at a time, and read pages roll up behind the magazine. `auto`
@@ -139,19 +140,21 @@ error trackers listen. To register the element under another tag, call
139
140
 
140
141
  ## In your own three.js scene
141
142
 
142
- `LeafThroughObject` is the magazine as a `THREE.Group`, for a scene you already have: a
143
- booklet in a CD case, a magazine on a desk. It draws with your renderer and takes pointer
144
- input through your camera, so it turns wherever it lies, however it's moved, turned or
145
- scaled. Every option, method and event of `LeafThrough` works the same, apart from those
146
- about its own canvas and DOM (`bleed`, `maxPixelRatio`, `plain`, `label`,
147
- `formatPageLabel`).
143
+ `LeafThroughObject` is the magazine as a `THREE.Group`, for a scene you already have. It
144
+ draws with your renderer and takes pointer input through your camera, so it turns wherever
145
+ it lies, however it's moved, turned or scaled. Every option, method, property and event of
146
+ `LeafThrough` works the same, apart from those about its own canvas and DOM: the options
147
+ `bleed`, `maxPixelRatio`, `plain`, `label` and `formatPageLabel`, and the properties
148
+ `plain` and `element`. It has one option of its own, `deform`
149
+ ([Reshaping the paper](#reshaping-the-paper)), and its sheets stand apart by default
150
+ ([Depth](#depth)).
148
151
 
149
152
  ```ts
150
153
  import { LeafThroughObject } from 'leafthrough'
151
154
 
152
- const magazine = new LeafThroughObject({ renderer, camera }, { pages, layout: 'spread', pageAspect: 1 })
155
+ const magazine = new LeafThroughObject({ renderer, camera }, { pages, layout: 'spread' })
153
156
  magazine.rotation.x = -Math.PI / 2 // lay it on the table
154
- magazine.scale.setScalar(0.12) // a page 12 cm wide, in a scene measured in metres
157
+ magazine.scale.setScalar(0.21) // a page 21 cm wide, in a scene measured in metres
155
158
  scene.add(magazine)
156
159
 
157
160
  renderer.setAnimationLoop((now) => {
@@ -161,38 +164,116 @@ renderer.setAnimationLoop((now) => {
161
164
  ```
162
165
 
163
166
  - **Units.** A page is 1 wide and `pageAspect` tall. The spine runs up the y axis at
164
- x = 0 and the pages lie on the z = 0 plane, facing +z. A closed spread shows its cover
165
- at x 0 to 1. `magazine.bounds.pages` is the pages at rest, the rectangle to frame with
166
- your camera; `magazine.bounds.reach` is everything a turning page sweeps through.
167
+ x = 0 and the pages lie on the z = 0 plane, facing +z. A spread closed on its front
168
+ cover shows it at x 0 to 1, and closed on its back cover at x -1 to 0.
169
+ `magazine.bounds.pages` is the pages at rest, the rectangle to frame with your camera;
170
+ `magazine.bounds.reach` is everything a turning page sweeps through.
167
171
  - **Input.** The magazine listens for pointer events on `renderer.domElement` (or the
168
- `domElement` you pass). A press it takes is one on its pages, which you can test with
169
- `magazine.covers(clientX, clientY)`; check that in your own handlers to leave such
170
- presses alone. Hide the magazine (`visible = false`, on it or a parent) and it takes
171
- none. With `interaction.keyboard: true` the keys work while that element has focus; a
172
- canvas usually doesn't take focus, so use `'global'`.
172
+ `domElement` you pass, which must show exactly the camera's view). It takes presses in
173
+ its hit area: the pages in `spread` (only the cover's side while closed), and in
174
+ `single` a box round the page with a strip left of the spine for turning back. It
175
+ doesn't look at your scene, so objects in front of the magazine don't block it.
176
+ `magazine.covers(clientX, clientY)` tells you whether it takes a press there; check that
177
+ in your own handlers to leave such presses alone. Hide the magazine (`visible = false`,
178
+ on it or a parent) and it takes none. With `interaction.keyboard: true` the keys work
179
+ while that element has focus; a canvas only takes focus with a `tabIndex`, so give it
180
+ one or use `'global'`. Drags scale with the magazine's size on screen, so they feel the
181
+ same near or far. For touch drags, give the element `touch-action: pan-y pinch-zoom`
182
+ (or `none`), so the browser doesn't take them for scrolling.
173
183
  - **Light.** The paper is a `MeshStandardMaterial`, lit by your scene's lights,
174
- environment, fog and tone mapping. Of the `lighting` options, `sun.position` (in the
175
- magazine's own units) steers the paper's shading and `shadows` makes the sheets cast
176
- and receive shadows when your renderer draws them.
184
+ environment, fog and tone mapping. Of the `lighting` options, `shadows` makes the
185
+ sheets cast and receive shadows when your renderer draws them, and `sun.position`
186
+ should point towards your shadow-casting directional light, in the magazine's own
187
+ frame (only the direction counts). It places the shadow a turning sheet casts right by
188
+ the spine, where the shadow map can't. `update({ lighting: { shadows } })` switches the
189
+ sheets' shadows without a rebuild. Use it rather than setting `castShadow` or
190
+ `receiveShadow` on the sheets, which the magazine sets itself.
191
+ - **Depth.** See [Depth](#depth): the sheets stand a little apart, which needs enough depth
192
+ precision from your camera.
177
193
  - **Rendering on demand.** If you only draw when something changes, listen for `change`:
178
194
  it fires when input, loading or a method call needs a frame. Then keep drawing while
179
195
  `tick()` returns true.
196
+ - **Accessibility is yours.** With no DOM of its own, it has no live region, no page
197
+ descriptions and no plain view: pages' `alt` and `text` aren't read. Describe the pages
198
+ in your own page, and announce `pagechange` if readers should hear it.
180
199
 
181
200
  ```ts
182
201
  magazine.tick(now) // advance; true while the pages still move
183
- magazine.covers(e.clientX, e.clientY) // whether a press there lands on the pages
202
+ magazine.covers(e.clientX, e.clientY) // whether the magazine takes a press there
184
203
  magazine.bounds // { pages, reach }, in the magazine's own units
185
204
  magazine.camera = otherCamera // cast input from another camera
186
205
  magazine.enabled = false // cancel active gestures and stop taking pointer/key input
206
+ magazine.update({ lighting: { shadows: false } }) // sheets stop casting and receiving, no rebuild
187
207
  magazine.addEventListener('change', () => requestRender())
188
208
  magazine.addEventListener('pagechange', (e) => e.detail) // also ready, pageload, error
189
209
  magazine.dispose() // free everything and leave the scene
190
210
  ```
191
211
 
212
+ ### Depth
213
+
214
+ In a `LeafThroughObject` the sheets stand apart, `paper.sheetGap` `0.0005` page widths
215
+ (0.1 mm paper on a 210 mm page), and sort by their real depth, so they stay in order from the front, from
216
+ behind and at any angle. Your camera has to resolve that gap. A 24-bit depth buffer tells
217
+ apart points roughly `distance² / (near × 16,777,216)` apart, in your scene's units, and that
218
+ must be well under the gap in the same units. Take a booklet 120 mm wide in a scene in
219
+ metres, seen from 4.4 m: its gap is 0.06 mm. With the camera's `near` at 0.1 the buffer
220
+ resolves about 0.012 mm, which is enough; at 0.01 it's about 0.12 mm, and hidden pages
221
+ flicker through. So keep `near` as large as your scene allows; `far` matters much less.
222
+ The magazine never changes your camera. With a coarse depth buffer you can widen the gap.
223
+
224
+ The standalone magazine instead draws sheets that touch at the spine and lets
225
+ `paper.depthBias` sort them: a polygon offset brings each sheet nearer the camera than
226
+ those under it. You can turn that on here too (`paper: { sheetGap: 0, depthBias: true }`),
227
+ but it has two limits in a scene of your own. It assumes the magazine is seen from the
228
+ front, so from behind it brings the wrong sheets forward. And it moves sheets by steps of
229
+ the depth buffer, so how far depends on `near`, `far` and distance: with a small `near`
230
+ it can pull pages through whatever lies just in front of the magazine.
231
+
232
+ ### Reshaping the paper
233
+
234
+ Experimental: this may change in a patch release. `deform` reshapes the paper with your
235
+ own GLSL, after the magazine has shaped it: bend a booklet coming out of its case, squeeze
236
+ it, roll it. It applies wherever the paper is drawn, in colour and in the shadow passes,
237
+ and lighting follows, because normals come from the deformed shape.
238
+
239
+ ```ts
240
+ const bend = { value: 0 }
241
+ const magazine = new LeafThroughObject({ renderer, camera }, {
242
+ pages,
243
+ deform: {
244
+ uniforms: { uBend: bend },
245
+ glsl: /* glsl */ `
246
+ uniform float uBend;
247
+ vec3 deformSheet(vec3 p, vec2 uv) {
248
+ // curl the pages up along x, around a cylinder of radius 1 / uBend
249
+ if (uBend < 1e-4) return p;
250
+ float a = p.x * uBend;
251
+ float r = 1.0 / uBend - p.z;
252
+ return vec3(r * sin(a), p.y, 1.0 / uBend - r * cos(a));
253
+ }`,
254
+ },
255
+ })
256
+
257
+ bend.value = 1.2 // read on the next frame: no rebuild
258
+ ```
259
+
260
+ - `deformSheet` gets `p`, a point of a sheet in the magazine's own units (see Units
261
+ above), and `uv`, where it is on that sheet: x from the spine (0) to the outer edge (1),
262
+ y from bottom to top. It returns where to draw that point, in the same units.
263
+ - three.js's common GLSL (`PI`, `saturate` and so on) is available. Name your uniforms
264
+ with a prefix of your own, as the magazine's start with `u`.
265
+ - Uniforms are read every frame: set `value` on your own objects, or pass new objects
266
+ under the same names through `update()`. Changing the GLSL, or which uniforms there are,
267
+ rebuilds the pages.
268
+ - It changes only what's drawn. Pointer input, `covers()`, `bounds` and the shadow a
269
+ turning sheet casts by the spine still work from the undeformed pages, so turn input off
270
+ (`enabled = false`) while the paper is far from its own shape.
271
+
192
272
  The [three.js demo](https://dmtrbrl.github.io/leafthrough/three.html) takes a magazine
193
273
  out of a portrait paperboard mailer on a table; its source is
194
- [`demo/main-three.ts`](demo/main-three.ts), with the native three.js mailer geometry in
195
- [`demo/envelope.ts`](demo/envelope.ts).
274
+ [`demo/main-three.ts`](https://github.com/dmtrbrl/leafthrough/blob/main/demo/main-three.ts),
275
+ with the native three.js mailer geometry in
276
+ [`demo/envelope.ts`](https://github.com/dmtrbrl/leafthrough/blob/main/demo/envelope.ts).
196
277
 
197
278
  ## Sizing
198
279
 
@@ -247,6 +328,8 @@ The magazine fills the element you give it and keeps its proportions, centred.
247
328
  | `wrinkles` | `1` | Waviness of resting sheets; `0` lays them flat. |
248
329
  | `bulge` | `1` | How much pages rise off the spine. |
249
330
  | `cutout` | `false` | Cuts each sheet to the shape of its front page: transparent areas are no paper at all, shadow included. |
331
+ | `sheetGap` | `0` (`0.0005` in a scene) | Space between stacked sheets, in page widths. 0.1 mm paper on a 120 mm page is `0.1 / 120`. |
332
+ | `depthBias` | `true` (`false` in a scene) | Draws each sheet nearer the camera than those under it, so sheets touching at the spine don't flicker. See [Depth](#depth). |
250
333
 
251
334
  **`lighting`**: `ambient: { sky, ground, intensity }`, `sun: { color, intensity, position }`,
252
335
  `shadows` (default `true`), `shadowMapSize` (default `2048`), and `floorShadow`. That last
@@ -297,7 +380,8 @@ magazine.addEventListener('error', (e) => e.detail) // { error, page }
297
380
 
298
381
  Both compare options by value. Changing callbacks or the label is free. Changing `pages`,
299
382
  `layout`, paper, lighting, motion or interaction rebuilds the scene (a few milliseconds)
300
- and keeps the reader on the same page. `startPage` only applies at creation; use `goTo()`
383
+ and keeps the reader on the same page. In a `LeafThroughObject`, `lighting.shadows` and
384
+ new values for `deform`'s uniforms apply without a rebuild. `startPage` only applies at creation; use `goTo()`
301
385
  after that. The React, Vue and Svelte bindings and the element use `setOptions()`, so
302
386
  removing a prop or attribute restores its default. Options are copied on every call, so an
303
387
  object or array changed in place and passed again (as Vue's reactive props are) counts as
@@ -352,7 +436,8 @@ off screen or the tab is hidden.
352
436
  The 3D canvas is hidden from screen readers, so give pages an `alt` and their `text` (see
353
437
  [Pages](#pages)). The magazine keeps the visible pages' descriptions and text in the page,
354
438
  visually hidden, where screen readers can read them; each page is a `figure` named by
355
- `formatPageLabel`.
439
+ `formatPageLabel`. `LeafThroughObject` has no DOM, so none of this comes with it (see
440
+ [In your own three.js scene](#in-your-own-threejs-scene)).
356
441
 
357
442
  **Plain view.** `plain: true` shows the pages as flat images, with the same layouts,
358
443
  navigation, keyboard and events, and no 3D. Clicking the right half turns forward, the left
@@ -387,7 +472,10 @@ How it works:
387
472
  - `content.ts` keeps the visible pages' content as DOM: hidden for screen readers in 3D,
388
473
  the pages themselves in the plain view.
389
474
  - `textures.ts` loads, prioritises and unloads page textures.
390
- - `src/paper` holds the shared paper material and the procedural grain.
475
+ - `src/paper` holds the shared paper material and the procedural grain. Sheets keep their
476
+ order by real depth, each lifted by `paper.sheetGap` for the sheets under it, or by a
477
+ polygon offset (`layerSheet`) where they touch. `deform` wraps the layout's sheet shape
478
+ in every pass.
391
479
  - `LeafThrough.ts` handles the DOM, sizing, loading, layout switching, keyboard and
392
480
  events. `LeafThroughObject.ts` does the same inside someone else's scene, without the DOM.
393
481
  - Engines take input through `EngineInput` (`engine.ts`): an element to listen on, a ray
@@ -395,6 +483,8 @@ How it works:
395
483
  their own floor plane, so the same code serves the magazine's own camera and any scene's.
396
484
  - `navigation.ts` maps pages to engine positions, riffles an animated `goTo()` and binds
397
485
  the keyboard, for both.
486
+ - `test/depth.test.ts` prints the pages that should be hidden pure red and checks that
487
+ none shows, across layouts, views, paper shapes, turns and camera depth ranges.
398
488
 
399
489
  ## Credits
400
490
 
@@ -403,4 +493,4 @@ The page deformation and interaction model are reverse-engineered from the magaz
403
493
 
404
494
  ## License
405
495
 
406
- [MIT](LICENSE)
496
+ [MIT](https://github.com/dmtrbrl/leafthrough/blob/main/LICENSE)
@@ -1,13 +1,21 @@
1
1
  import * as THREE from 'three';
2
2
  import { type Rect } from './framing.js';
3
3
  import type { GoToOptions } from './LeafThrough.js';
4
- import { type LeafThroughOptions, type Layout, type PageChangeDetail } from './options.js';
4
+ import { type LeafThroughOptions, type Layout, type PageChangeDetail, type PaperDeform } from './options.js';
5
5
  /**
6
6
  * The magazine's options, less those about its own canvas and DOM. Of `lighting`, only
7
- * `sun.position` (which way the paper is shaded) and `shadows` (whether sheets cast and
8
- * receive them) apply: the lights are your scene's.
7
+ * `shadows` (whether sheets cast and receive them) and `sun.position` apply: the lights are
8
+ * your scene's. Point `sun.position` towards your shadow-casting directional light, in the
9
+ * magazine's own frame; it places the shadow a turning sheet casts right by the spine.
10
+ * `lighting.shadows` changes without a rebuild.
11
+ *
12
+ * Its sheets stand apart by default (`paper.sheetGap` `0.0005`, about 0.1 mm paper on a
13
+ * 210 mm page) and `paper.depthBias` is off, so they sort by real depth from any side.
9
14
  */
10
- export type LeafThroughObjectOptions = Omit<LeafThroughOptions, 'bleed' | 'maxPixelRatio' | 'plain' | 'label' | 'formatPageLabel'>;
15
+ export type LeafThroughObjectOptions = Omit<LeafThroughOptions, 'bleed' | 'maxPixelRatio' | 'plain' | 'label' | 'formatPageLabel'> & {
16
+ /** Reshapes the paper in your own GLSL. Experimental. */
17
+ deform?: PaperDeform;
18
+ };
11
19
  /** What the magazine draws with, from your scene. */
12
20
  export interface LeafThroughObjectContext {
13
21
  /** The renderer drawing your scene. Pages load into it. */
@@ -137,6 +145,7 @@ export declare class LeafThroughObject extends THREE.Group<LeafThroughObjectEven
137
145
  private pickLayout;
138
146
  private watchSpreadQuery;
139
147
  private remount;
148
+ private resolve;
140
149
  private readonly axis;
141
150
  private pageAxis;
142
151
  private dragWidth;
@@ -33,8 +33,11 @@ function resolveSettings(options, layout, prefersReducedMotion) {
33
33
  backColor: paper.backColor ?? "#fcfcf9",
34
34
  wrinkles: paper.wrinkles ?? 1,
35
35
  bulge: paper.bulge ?? 1,
36
- cutout: paper.cutout ?? false
36
+ cutout: paper.cutout ?? false,
37
+ sheetGap: Math.max(0, paper.sheetGap ?? 0),
38
+ depthBias: paper.depthBias ?? true
37
39
  },
40
+ deform: null,
38
41
  lighting: {
39
42
  ambient: {
40
43
  sky: lighting.ambient?.sky ?? "#ffffff",
@@ -632,27 +635,37 @@ function createPaperMaterials(uniforms, shader, settings) {
632
635
  `
633
636
  );
634
637
  const noiseAt = (component) => shader.noise ? `aNoise.${component}` : "0.0";
638
+ const deform = settings.deform;
639
+ const deformChunk = deform ? `#include <common>
640
+ uniform vec3 uSheetOrigin;
641
+ ${deform.glsl}
642
+ vec3 sheetDeformed(vec3 p, vec2 uv) {
643
+ return deformSheet(p + uSheetOrigin, uv) - uSheetOrigin;
644
+ }` : "#include <common>";
645
+ const deformed = (p, uv) => deform ? `sheetDeformed(${p}, ${uv})` : p;
646
+ const deformUniforms = deform ? { ...deform.uniforms, uSheetOrigin: uniforms.uSheetOrigin } : {};
635
647
  const material = new THREE5.MeshStandardMaterial({
636
648
  side: THREE5.DoubleSide,
637
649
  metalness: paper.metalness,
638
650
  roughness: paper.roughness
639
651
  });
640
652
  material.onBeforeCompile = (program) => {
641
- Object.assign(program.uniforms, uniforms);
653
+ Object.assign(program.uniforms, uniforms, deformUniforms);
642
654
  program.vertexShader = constants + shader.vertexChunk + "varying vec2 vSheetUv;\nvarying vec3 vSheetPosition;\n" + program.vertexShader;
643
- program.vertexShader = program.vertexShader.replace(
655
+ program.vertexShader = program.vertexShader.replace("#include <common>", deformChunk).replace(
644
656
  "#include <beginnormal_vertex>",
645
657
  `#include <beginnormal_vertex>
646
658
  vec2 sheetStep = vec2(NORMAL_EPSILON, NORMAL_EPSILON / SHEET_ASPECT);
647
- vec3 sheetP = sheetPosition(uv, ${noiseAt("x")});
648
- vec3 sheetPx = sheetPosition(uv + vec2(sheetStep.x, 0.0), ${noiseAt("y")});
649
- vec3 sheetPy = sheetPosition(uv + vec2(0.0, sheetStep.y), ${noiseAt("z")});
659
+ vec3 sheetShaped = sheetPosition(uv, ${noiseAt("x")});
660
+ vec3 sheetP = ${deformed("sheetShaped", "uv")};
661
+ vec3 sheetPx = ${deformed(`sheetPosition(uv + vec2(sheetStep.x, 0.0), ${noiseAt("y")})`, "uv + vec2(sheetStep.x, 0.0)")};
662
+ vec3 sheetPy = ${deformed(`sheetPosition(uv + vec2(0.0, sheetStep.y), ${noiseAt("z")})`, "uv + vec2(0.0, sheetStep.y)")};
650
663
  objectNormal = normalize(cross(sheetPx - sheetP, sheetPy - sheetP));`
651
664
  ).replace(
652
665
  "#include <begin_vertex>",
653
666
  `vec3 transformed = sheetP;
654
667
  vSheetUv = uv;
655
- vSheetPosition = sheetP;`
668
+ vSheetPosition = sheetShaped;`
656
669
  );
657
670
  program.fragmentShader = constants + `uniform sampler2D uFront;
658
671
  uniform sampler2D uBack;
@@ -732,13 +745,13 @@ function createPaperMaterials(uniforms, shader, settings) {
732
745
  });
733
746
  const distanceMaterial = new THREE5.MeshDistanceMaterial({ side: THREE5.DoubleSide });
734
747
  depthMaterial.onBeforeCompile = distanceMaterial.onBeforeCompile = (program) => {
735
- Object.assign(program.uniforms, uniforms);
748
+ Object.assign(program.uniforms, uniforms, deformUniforms);
736
749
  program.vertexShader = constants + shader.vertexChunk + "varying vec2 vSheetUv;\nvarying vec3 vSheetPosition;\n" + program.vertexShader;
737
- program.vertexShader = program.vertexShader.replace(
750
+ program.vertexShader = program.vertexShader.replace("#include <common>", deformChunk).replace(
738
751
  "#include <begin_vertex>",
739
752
  `vSheetUv = uv;
740
753
  vSheetPosition = sheetPosition(uv, ${noiseAt("x")});
741
- vec3 transformed = vSheetPosition - vec3(SHADOW_OFFSET_X, 0.0, 0.0);`
754
+ vec3 transformed = ${deformed("vSheetPosition - vec3(SHADOW_OFFSET_X, 0.0, 0.0)", "uv")};`
742
755
  );
743
756
  program.fragmentShader = constants + `
744
757
  uniform sampler2D uFront;
@@ -753,7 +766,7 @@ function createPaperMaterials(uniforms, shader, settings) {
753
766
  ${shader.fragmentChunk ? "if (sheetHidden(vSheetPosition)) discard;" : ""}`
754
767
  );
755
768
  };
756
- const cacheKey = constants + shader.vertexChunk + (shader.fragmentChunk ?? "") + (paper.cutout ? "cutout" : "");
769
+ const cacheKey = constants + shader.vertexChunk + (shader.fragmentChunk ?? "") + (paper.cutout ? "cutout" : "") + (deform?.glsl ?? "");
757
770
  material.customProgramCacheKey = () => cacheKey;
758
771
  depthMaterial.customProgramCacheKey = () => cacheKey;
759
772
  distanceMaterial.customProgramCacheKey = () => cacheKey;
@@ -777,11 +790,11 @@ function createSheetMesh(geometry, uniforms, shader, settings) {
777
790
  mesh.frustumCulled = false;
778
791
  return mesh;
779
792
  }
780
- function layerSheet(mesh, depth, count, moving) {
793
+ function layerSheet(mesh, depth, count, moving, bias) {
781
794
  const material = mesh.material;
782
- material.polygonOffset = true;
795
+ material.polygonOffset = bias;
783
796
  material.polygonOffsetFactor = 0;
784
- material.polygonOffsetUnits = moving ? -(count + 4) : -(count - Math.min(depth, count));
797
+ material.polygonOffsetUnits = bias ? moving ? -(count + 4) : -(count - Math.min(depth, count)) : 0;
785
798
  mesh.renderOrder = depth;
786
799
  }
787
800
  function disposeSheetMesh(mesh) {
@@ -1085,6 +1098,7 @@ var vertexChunk = (
1085
1098
  uniform float uTailBend; // how far the free end bows back
1086
1099
  uniform vec3 uCone; // radius change per unit of y, radius at the top, how far to wrap
1087
1100
  uniform vec2 uFlipRotation;
1101
+ uniform float uStackGap; // how far the sheet stands off the spine, along its own normal
1088
1102
 
1089
1103
  float sheetPileLift(float x) {
1090
1104
  float t = 1.0 - min(x / uPileLift.x, 1.0);
@@ -1170,7 +1184,7 @@ var vertexChunk = (
1170
1184
  }
1171
1185
 
1172
1186
  // spin about the spine
1173
- float z = shape.z + pile.z;
1187
+ float z = shape.z + pile.z + uStackGap;
1174
1188
  return vec3(
1175
1189
  shape.x * uFlipRotation.x - z * uFlipRotation.y,
1176
1190
  shape.y,
@@ -1206,11 +1220,13 @@ function createSingleSheet(geometry, front, back, pattern, settings, shading) {
1206
1220
  uCone: { value: new THREE9.Vector3(0, 1, 0) },
1207
1221
  uFlipRotation: { value: new THREE9.Vector2(1, 0) },
1208
1222
  uTrim: { value: new THREE9.Vector3(0, 1, 1) },
1223
+ uStackGap: { value: 0 },
1209
1224
  uFront: { value: front },
1210
1225
  uBack: { value: back },
1211
1226
  uPattern: { value: pattern },
1212
1227
  uSpineContact: shading.spineContact,
1213
- uLightDirection: shading.lightDirection
1228
+ uLightDirection: shading.lightDirection,
1229
+ uSheetOrigin: shading.origin
1214
1230
  };
1215
1231
  const mesh = createSheetMesh(
1216
1232
  geometry,
@@ -1301,6 +1317,7 @@ var SingleEngine = class {
1301
1317
  fallDelay;
1302
1318
  fallDuration;
1303
1319
  abort = new AbortController();
1320
+ shading;
1304
1321
  current;
1305
1322
  turned = 0;
1306
1323
  // sum of every sheet's progress, as of the last update
@@ -1321,6 +1338,7 @@ var SingleEngine = class {
1321
1338
  this.input = ctx.input;
1322
1339
  this.metrics = ctx.metrics;
1323
1340
  this.requestFrame = ctx.requestFrame;
1341
+ this.shading = ctx.shading;
1324
1342
  this.count = ctx.textures.length;
1325
1343
  this.maxPosition = this.count;
1326
1344
  this.current = clamp(Math.round(ctx.position), 0, Math.max(this.count - 1, 0));
@@ -1399,6 +1417,11 @@ var SingleEngine = class {
1399
1417
  if (this.press) this.release(new PointerEvent("pointercancel", { pointerId: this.press.id }));
1400
1418
  this.wake();
1401
1419
  }
1420
+ setShadows(on) {
1421
+ this.settings.lighting.shadows = on;
1422
+ for (const sheet of this.sheets) sheet.mesh.castShadow = sheet.mesh.receiveShadow = on;
1423
+ this.wake();
1424
+ }
1402
1425
  dispose() {
1403
1426
  this.cancelHold();
1404
1427
  this.abort.abort();
@@ -1759,11 +1782,13 @@ var SingleEngine = class {
1759
1782
  for (const sheet of this.sheets) {
1760
1783
  const i = sheet.index;
1761
1784
  sheet.mesh.visible = closing || i <= pileFrom + PILE.renderDepth || this.busy(sheet);
1762
- layerSheet(sheet.mesh, i < this.current ? this.current - 1 - i : i - this.current, this.count, this.busy(sheet));
1785
+ const depth = i < this.current ? this.current - 1 - i : i - this.current;
1786
+ layerSheet(sheet.mesh, depth, this.count, this.busy(sheet), this.settings.paper.depthBias);
1763
1787
  }
1764
1788
  const read = smooth(this.count > 1 ? this.paced(turned) / (this.count - 1) : 0) * (1 - this.shutting(turned));
1765
1789
  this.group.position.x = BOOK.pushRight * read;
1766
1790
  this.group.position.z = -BOOK.pushBack * read;
1791
+ this.shading.origin.value.copy(this.group.position);
1767
1792
  this.dirty = false;
1768
1793
  return true;
1769
1794
  }
@@ -1833,6 +1858,11 @@ var SingleEngine = class {
1833
1858
  topReach = Math.min(topReach, edge.top);
1834
1859
  }
1835
1860
  u.uPileReach.value.set(bottomReach, topReach);
1861
+ const under = P - 1 - sheet.index;
1862
+ const before = Math.max(0, turned - 1 - sheet.index);
1863
+ const around = smooth(T);
1864
+ const offset = fallen ? before * Math.cos(spineOpen) : under + (before - under) * around;
1865
+ u.uStackGap.value = this.settings.paper.sheetGap * (offset + (under - offset) * shut);
1836
1866
  }
1837
1867
  // a turning page: swing round the spine up to the open angle, then roll round the cone
1838
1868
  shapeTurn(sheet, T, spineOpen, radii, tilt) {
@@ -1891,6 +1921,7 @@ var vertexChunk2 = (settings) => (
1891
1921
 
1892
1922
  const float BULGE = ${settings.paper.bulge.toFixed(4)};
1893
1923
  const float WRINKLE = ${(0.13 * settings.paper.wrinkles).toFixed(4)};
1924
+ const bool NESTED = ${settings.paper.depthBias ? "false" : "true"};
1894
1925
 
1895
1926
  uniform float uFlipProgress;
1896
1927
  uniform float uWrinkleSide;
@@ -1900,6 +1931,7 @@ var vertexChunk2 = (settings) => (
1900
1931
  uniform vec2 uFlipRotation;
1901
1932
  uniform float uDirection;
1902
1933
  uniform float uStackLift;
1934
+ uniform float uStackGap;
1903
1935
 
1904
1936
  attribute vec3 aNoise;
1905
1937
 
@@ -1916,8 +1948,11 @@ var vertexChunk2 = (settings) => (
1916
1948
  float y = (uv.y - 0.5) * SHEET_ASPECT;
1917
1949
 
1918
1950
  // Every sheet shares the same wrinkles and bulge, scaled by its place in the stack,
1919
- // so stacked sheets nest inside each other instead of crossing.
1920
- float wrinkle = (mix(noise, 1.0 - noise, uFlipProgress) - 0.5) * smoothstep(0.0, 0.35, x) * WRINKLE * uStackLift;
1951
+ // so stacked sheets nest inside each other instead of crossing. Where a wrinkle dips, a
1952
+ // bigger one dips below the sheets under it, which only the depth bias hides; without it
1953
+ // the dips are the same on every sheet.
1954
+ float wrinkle = uWrinkleSide * (mix(noise, 1.0 - noise, uFlipProgress) - 0.5) * smoothstep(0.0, 0.35, x) * WRINKLE;
1955
+ wrinkle *= NESTED && wrinkle < 0.0 ? 1.0 : uStackLift;
1921
1956
  float lift = sheetRestingLift(x) * BULGE * uStackLift;
1922
1957
 
1923
1958
  // bend with constant curvature past the fold line, trailing against the turn
@@ -1935,7 +1970,7 @@ var vertexChunk2 = (settings) => (
1935
1970
  // turn about the spine
1936
1971
  p = vec3(p.x * uFlipRotation.x - p.z * uFlipRotation.y, p.y, p.x * uFlipRotation.y + p.z * uFlipRotation.x);
1937
1972
 
1938
- p.z += lift + uWrinkleSide * wrinkle;
1973
+ p.z += lift + wrinkle + uStackGap;
1939
1974
  return p;
1940
1975
  }
1941
1976
  `
@@ -1992,11 +2027,13 @@ function createSpreadSheet(geometry, front, back, pattern, settings, shading) {
1992
2027
  uFlipRotation: { value: new THREE11.Vector2(1, 0) },
1993
2028
  uDirection: { value: 1 },
1994
2029
  uStackLift: { value: 1 },
2030
+ uStackGap: { value: 0 },
1995
2031
  uFront: { value: front },
1996
2032
  uBack: { value: back },
1997
2033
  uPattern: { value: pattern },
1998
2034
  uSpineContact: shading.spineContact,
1999
- uLightDirection: shading.lightDirection
2035
+ uLightDirection: shading.lightDirection,
2036
+ uSheetOrigin: shading.origin
2000
2037
  };
2001
2038
  const mesh = createSheetMesh(
2002
2039
  geometry,
@@ -2037,6 +2074,10 @@ function stackLift(index, count, progress) {
2037
2074
  if (count <= 1) return 1;
2038
2075
  return 0.31 + 0.69 * (index + (count - 1 - 2 * index) * (1 - progress)) / (count - 1);
2039
2076
  }
2077
+ function sheetsUnder(index, count, progress) {
2078
+ const t = clamp2((progress - 0.25) / 0.5, 0, 1);
2079
+ return count - 1 - index + (2 * index - count + 1) * t * t * (3 - 2 * t);
2080
+ }
2040
2081
  var SpreadEngine = class {
2041
2082
  group = new THREE12.Group();
2042
2083
  maxPosition;
@@ -2133,6 +2174,11 @@ var SpreadEngine = class {
2133
2174
  if (this.press) this.release(new PointerEvent("pointercancel", { pointerId: this.press.id }));
2134
2175
  this.wake();
2135
2176
  }
2177
+ setShadows(on) {
2178
+ this.settings.lighting.shadows = on;
2179
+ for (const sheet of this.sheets) sheet.mesh.receiveShadow = on;
2180
+ this.wake();
2181
+ }
2136
2182
  dispose() {
2137
2183
  this.cancelHold();
2138
2184
  this.abort.abort();
@@ -2495,7 +2541,7 @@ var SpreadEngine = class {
2495
2541
  const depth = i >= this.current ? i - this.current : this.current - 1 - i;
2496
2542
  const turning = this.inFlight(i) || this.drag?.index === i;
2497
2543
  sheet.mesh.castShadow = this.settings.lighting.shadows && (depth < 3 || turning);
2498
- layerSheet(sheet.mesh, depth, this.sheets.length, turning);
2544
+ layerSheet(sheet.mesh, depth, this.sheets.length, turning, this.settings.paper.depthBias);
2499
2545
  const lift = Math.abs(sheet.uniforms.uFlipRotation.value.y);
2500
2546
  if (turning && lift > upright) {
2501
2547
  upright = lift;
@@ -2526,6 +2572,7 @@ var SpreadEngine = class {
2526
2572
  u.uWrinkleSide.value = -Math.cos(turn);
2527
2573
  u.uDirection.value = sheet.directionSmooth;
2528
2574
  u.uStackLift.value = sheet.liftBase + sheet.liftSpan * sheet.progress;
2575
+ u.uStackGap.value = this.settings.paper.sheetGap * sheetsUnder(sheet.index, this.sheets.length, sheet.progress);
2529
2576
  u.uBendAngle.value = bend;
2530
2577
  u.uFold.value.set(cos, sin);
2531
2578
  u.uCurl.value.set(curlStart, curlLength);
@@ -2806,7 +2853,8 @@ var LeafThrough = class extends EventTarget {
2806
2853
  scene.add(sun, sun.target);
2807
2854
  const shading = {
2808
2855
  spineContact: { value: new THREE13.Vector3(1, 0, 0) },
2809
- lightDirection: { value: sun.position.clone().sub(sun.target.position).normalize() }
2856
+ lightDirection: { value: sun.position.clone().sub(sun.target.position).normalize() },
2857
+ origin: { value: new THREE13.Vector3() }
2810
2858
  };
2811
2859
  let floor2 = null;
2812
2860
  if (layout === "spread" && shadows && settings.lighting.floorShadow > 0) {
@@ -3097,6 +3145,7 @@ export {
3097
3145
  LIVE_OPTIONS,
3098
3146
  preloadRadius,
3099
3147
  snapshot,
3148
+ deepEqual,
3100
3149
  changedOptions,
3101
3150
  sourceKey,
3102
3151
  floorPoint,
@@ -3114,4 +3163,4 @@ export {
3114
3163
  SpreadEngine,
3115
3164
  LeafThrough
3116
3165
  };
3117
- //# sourceMappingURL=chunk-GYSQO7QZ.js.map
3166
+ //# sourceMappingURL=chunk-QFOGCGJJ.js.map