paperlab 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,7 +68,7 @@ function segmentsForSine(span, amplitude, wavelength, tol = SAG_TOL) {
68
68
  }
69
69
 
70
70
  // src/config/schema.ts
71
- import { z as z14 } from "zod";
71
+ import { z as z15 } from "zod";
72
72
 
73
73
  // src/behaviors/peel.ts
74
74
  import { z as z2 } from "zod";
@@ -231,15 +231,169 @@ var peel = {
231
231
  };
232
232
 
233
233
  // src/behaviors/unroll.ts
234
+ import { z as z4 } from "zod";
235
+
236
+ // src/deformers/roll.ts
234
237
  import { z as z3 } from "zod";
235
- var unrollOptionsSchema = z3.object({
238
+ var rollOptionsSchema = z3.object({
239
+ /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
240
+ angle: z3.number().min(-360).max(360).default(90),
241
+ /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
242
+ boundary: z3.number().min(-20).max(20).default(0),
243
+ /** Radius of the OUTERMOST wrap — the one the flat sheet leaves the roll on. */
244
+ radius: z3.number().min(0.01).max(2).default(0.12),
245
+ /** Gap between consecutive wraps, in world units. 0 = a bare cylinder. */
246
+ thickness: z3.number().min(0).max(0.2).default(0.015)
247
+ });
248
+ var DEG2 = Math.PI / 180;
249
+ var TAU = Math.PI * 2;
250
+ var MIN_RADIUS_FRACTION = 0.08;
251
+ function windAngle(s, r0, k) {
252
+ if (k <= 0) return s / r0;
253
+ const rMin = r0 * MIN_RADIUS_FRACTION;
254
+ const thetaFloor = (r0 - rMin) / k;
255
+ const sFloor = r0 * thetaFloor - k * thetaFloor * thetaFloor / 2;
256
+ if (s >= sFloor) return thetaFloor + (s - sFloor) / rMin;
257
+ return 2 * s / (r0 + Math.sqrt(r0 * r0 - 2 * k * s));
258
+ }
259
+ function windRadius(theta, r0, k) {
260
+ return Math.max(r0 - k * theta, r0 * MIN_RADIUS_FRACTION);
261
+ }
262
+ function rollRadius(length, core, thickness) {
263
+ return Math.sqrt(core * core + Math.max(0, length) * thickness / Math.PI);
264
+ }
265
+ var roll = {
266
+ id: "roll",
267
+ label: "Roll",
268
+ defaults: rollOptionsSchema.parse({}),
269
+ optionsSchema: rollOptionsSchema,
270
+ geometry: {
271
+ minSegments: 48,
272
+ // The INNERMOST wrap is the tightest curvature on the sheet, so it sets
273
+ // the density — the outer one used to, back when the spiral grew outward.
274
+ // Floored at a fraction of the outer radius because segment count scales
275
+ // as 1/√r: a roll wound to a hair's breadth would otherwise ask for a
276
+ // grid nobody can afford, to resolve a few square millimetres at the core.
277
+ autoSegments: (o, sheet2) => {
278
+ const span = spanAlong(sheet2, o.angle);
279
+ const wound = Math.max(0, span / 2 - o.boundary);
280
+ const k = o.thickness / TAU;
281
+ return segmentsForArc(span, windRadius(windAngle(wound, o.radius, k), o.radius, k));
282
+ },
283
+ axis: (o) => o.angle
284
+ },
285
+ displace(out, _uv, o) {
286
+ const dirX = Math.cos(o.angle * DEG2);
287
+ const dirY = Math.sin(o.angle * DEG2);
288
+ const d = out.x * dirX + out.y * dirY;
289
+ const s = d - o.boundary;
290
+ if (s <= 0) return;
291
+ const k = o.thickness / TAU;
292
+ const theta = windAngle(s, o.radius, k);
293
+ const r = windRadius(theta, o.radius, k) - out.z;
294
+ const newD = o.boundary + r * Math.sin(theta);
295
+ const newZ = o.radius - r * Math.cos(theta);
296
+ out.x += dirX * (newD - d);
297
+ out.y += dirY * (newD - d);
298
+ out.z = newZ;
299
+ },
300
+ glsl: {
301
+ chunk: (
302
+ /* glsl */
303
+ `
304
+ void FN(inout vec3 p, vec2 uv, float t) {
305
+ vec2 dir = vec2(cos(U_angle), sin(U_angle));
306
+ float d = dot(p.xy, dir);
307
+ float s = d - U_boundary;
308
+ if (s <= 0.0) return;
309
+ float k = U_thickness / 6.2831853071795864;
310
+ float rMin = U_radius * 0.08;
311
+ float thetaFloor = (U_radius - rMin) / max(k, 1e-9);
312
+ float sFloor = U_radius * thetaFloor - 0.5 * k * thetaFloor * thetaFloor;
313
+ float theta = k <= 0.0
314
+ ? s / U_radius
315
+ : (s >= sFloor
316
+ ? thetaFloor + (s - sFloor) / rMin
317
+ : (2.0 * s) / (U_radius + sqrt(U_radius * U_radius - 2.0 * k * s)));
318
+ float r = max(U_radius - k * theta, rMin) - p.z;
319
+ float newD = U_boundary + r * sin(theta);
320
+ float newZ = U_radius - r * cos(theta);
321
+ p.xy += dir * (newD - d);
322
+ p.z = newZ;
323
+ }
324
+ `
325
+ ),
326
+ uniforms: (o) => ({
327
+ angle: o.angle * DEG2,
328
+ boundary: o.boundary,
329
+ radius: o.radius,
330
+ thickness: o.thickness
331
+ })
332
+ }
333
+ };
334
+
335
+ // src/behaviors/unroll.ts
336
+ var unrollOptionsSchema = z4.object({
236
337
  /** 0 = fully rolled cylinder, 1 = flat sheet. */
237
- progress: z3.number().min(0).max(1).default(0.5),
238
- /** How tightly the paper is wound. */
239
- tightness: z3.number().min(0).max(1).default(0.5),
338
+ progress: z4.number().min(0).max(1).default(0.5),
339
+ /** How tightly the paper is wound — thin layers and many turns, or few and fat. */
340
+ tightness: z4.number().min(0).max(1).default(0.5),
240
341
  /** Idle rocking of the rolled end. */
241
- sway: z3.number().min(0).max(1).default(0.25)
342
+ sway: z4.number().min(0).max(1).default(0.25),
343
+ /** Which end of the sheet holds the roll. `top` hangs the paper below it. */
344
+ from: z4.enum(["bottom", "top"]).default("bottom"),
345
+ /** Radius of the tube the paper is wound onto — the roll never shrinks past it. */
346
+ core: z4.number().min(5e-3).max(0.5).default(0.03),
347
+ /**
348
+ * Paper already hanging at `progress` 0, in world units.
349
+ *
350
+ * A roll on a holder is never a bare cylinder: there is always a leaf out,
351
+ * because that is what you take hold of. Starting from nothing showing
352
+ * reads as a fresh roll still in its wrapper.
353
+ */
354
+ tail: z4.number().min(0).max(20).default(0),
355
+ /**
356
+ * How far below the roll the paper lands, in world units. Omit and it
357
+ * hangs forever.
358
+ *
359
+ * Paper that reaches the ground does not stop and does not carry on
360
+ * through it: it creases and runs out flat. Everything past this distance
361
+ * turns a right angle and lies down.
362
+ */
363
+ floor: z4.number().min(0.1).max(50).optional(),
364
+ /**
365
+ * Hold the roll still in space and let the paper hang off it, instead of
366
+ * letting the roll ride along with the shrinking wound region.
367
+ */
368
+ fixed: z4.boolean().default(false)
242
369
  });
370
+ var LOOSE = 0.1;
371
+ var TIGHT = 0.02;
372
+ function layerThickness(tightness) {
373
+ return LOOSE - tightness * (LOOSE - TIGHT);
374
+ }
375
+ function sweep(o, sheet2) {
376
+ const maxRadius = rollRadius(sheet2.height, o.core, layerThickness(o.tightness));
377
+ const tail = Math.min(o.tail, sheet2.height);
378
+ return { start: -sheet2.height / 2 + tail, end: sheet2.height / 2 + maxRadius * 2 };
379
+ }
380
+ function rollBoundary(o, sheet2) {
381
+ const { start, end } = sweep(o, sheet2);
382
+ return start + o.progress * (end - start);
383
+ }
384
+ var LANDING_RADIUS = 0.035;
385
+ function landing(o, sheet2, boundary, rollRadiusNow) {
386
+ const radius = Math.min(0.5, Math.max(0.02, sheet2.height * LANDING_RADIUS));
387
+ const hingeDrop = radius / (Math.PI / 2);
388
+ const below = Math.max(o.floor, rollRadiusNow + radius);
389
+ const floorLine = o.from === "top" ? boundary - below : -boundary + below;
390
+ return {
391
+ type: "fold",
392
+ // Travel points from the roll toward the floor, so "past the crease"
393
+ // means "the length that has arrived", not the drop above it.
394
+ options: o.from === "top" ? { angle: -90, offset: -floorLine - hingeDrop, foldAngle: 90, radius } : { angle: 90, offset: floorLine - hingeDrop, foldAngle: 90, radius }
395
+ };
396
+ }
243
397
  var unroll = {
244
398
  id: "unroll",
245
399
  label: "Unroll",
@@ -250,46 +404,65 @@ var unroll = {
250
404
  duration: 3,
251
405
  loopMode: "yoyo",
252
406
  stack(o, sheet2) {
253
- const radius = 0.28 - o.tightness * 0.22;
254
- const start = -sheet2.height / 2;
255
- const end = sheet2.height / 2 + radius * 2;
256
- return [
407
+ const thickness = layerThickness(o.tightness);
408
+ const boundary = rollBoundary(o, sheet2);
409
+ const wound = Math.max(0, sheet2.height / 2 - boundary);
410
+ const radius = rollRadius(wound, o.core, thickness);
411
+ const stack = [
257
412
  {
258
413
  type: "roll",
259
414
  options: {
260
- angle: 270,
261
- boundary: start + o.progress * (end - start),
415
+ // Both directions sweep the same boundary; only which half of the
416
+ // sheet counts as "past" it changes. 270 winds the bottom (a
417
+ // receipt feeding downward), 90 winds the top (paper hanging below).
418
+ angle: o.from === "top" ? 90 : 270,
419
+ boundary,
262
420
  radius,
263
- spiral: 0.02
421
+ thickness
264
422
  }
265
423
  }
266
424
  ];
425
+ if (o.floor !== void 0) stack.push(landing(o, sheet2, boundary, radius));
426
+ return stack;
267
427
  },
268
428
  loop(o, t) {
269
429
  if (o.sway === 0) return {};
270
430
  const wobble = Math.sin(t * 1.5) * 0.01 * o.sway;
271
431
  return { progress: Math.min(1, Math.max(0, o.progress + wobble)) };
272
432
  },
433
+ transform(o, _t, pose, sheet2) {
434
+ if (!o.fixed) return;
435
+ const travel = sweep(o, sheet2).end - rollBoundary(o, sheet2);
436
+ pose.position[1] += o.from === "top" ? travel : -travel;
437
+ },
273
438
  handles: [
274
439
  {
275
440
  id: "roll-edge",
276
- anchor: (o) => [0.5, Math.max(0.02, Math.min(0.98, 1 - o.progress))],
277
- drag(local, _o, sheet2) {
278
- return { progress: Math.min(1, Math.max(0, 0.5 - local.y / sheet2.height)) };
441
+ // The grab point is the edge the paper leaves the roll on, so it tracks
442
+ // the boundary — which runs top-to-bottom for a roll at the bottom and
443
+ // bottom-to-top for one at the top.
444
+ anchor: (o) => {
445
+ const v = o.from === "top" ? o.progress : 1 - o.progress;
446
+ return [0.5, Math.max(0.02, Math.min(0.98, v))];
447
+ },
448
+ drag(local, o, sheet2) {
449
+ const along = local.y / sheet2.height;
450
+ const p = o.from === "top" ? 0.5 + along : 0.5 - along;
451
+ return { progress: Math.min(1, Math.max(0, p)) };
279
452
  }
280
453
  }
281
454
  ]
282
455
  };
283
456
 
284
457
  // src/behaviors/flip.ts
285
- import { z as z4 } from "zod";
286
- var flipOptionsSchema = z4.object({
458
+ import { z as z5 } from "zod";
459
+ var flipOptionsSchema = z5.object({
287
460
  /** 0 = flat, 1 = page fully turned over the spine. */
288
- progress: z4.number().min(0).max(1).default(0.3),
461
+ progress: z5.number().min(0).max(1).default(0.3),
289
462
  /** Which edge is the spine. */
290
- spine: z4.enum(["left", "right"]).default("left"),
463
+ spine: z5.enum(["left", "right"]).default("left"),
291
464
  /** Softness of the turning curl. */
292
- radius: z4.number().min(0.1).max(0.8).default(0.3)
465
+ radius: z5.number().min(0.1).max(0.8).default(0.3)
293
466
  });
294
467
  var flip = {
295
468
  id: "flip",
@@ -311,7 +484,7 @@ var flip = {
311
484
  angle,
312
485
  boundary: start + o.progress * (end - start),
313
486
  radius: o.radius,
314
- spiral: 0
487
+ thickness: 0
315
488
  }
316
489
  }
317
490
  ];
@@ -329,12 +502,12 @@ var flip = {
329
502
  };
330
503
 
331
504
  // src/behaviors/letter-fold.ts
332
- import { z as z5 } from "zod";
333
- var letterFoldOptionsSchema = z5.object({
505
+ import { z as z6 } from "zod";
506
+ var letterFoldOptionsSchema = z6.object({
334
507
  /** 0 = flat letter, 1 = fully tri-folded. */
335
- progress: z5.number().min(0).max(1).default(0.4),
508
+ progress: z6.number().min(0).max(1).default(0.4),
336
509
  /** Softness of the two creases. */
337
- crease: z5.number().min(0).max(1).default(0.3)
510
+ crease: z6.number().min(0).max(1).default(0.3)
338
511
  });
339
512
  var letterFold = {
340
513
  id: "letter-fold",
@@ -384,12 +557,12 @@ var letterFold = {
384
557
  };
385
558
 
386
559
  // src/behaviors/hang.ts
387
- import { z as z6 } from "zod";
388
- var hangOptionsSchema = z6.object({
560
+ import { z as z7 } from "zod";
561
+ var hangOptionsSchema = z7.object({
389
562
  /** Wind strength driving the ripple. */
390
- wind: z6.number().min(0).max(1).default(0.4),
563
+ wind: z7.number().min(0).max(1).default(0.4),
391
564
  /** Gravity bulge of the hanging sheet. */
392
- sag: z6.number().min(0).max(1).default(0.3)
565
+ sag: z7.number().min(0).max(1).default(0.3)
393
566
  });
394
567
  var hang = {
395
568
  id: "hang",
@@ -418,12 +591,12 @@ var hang = {
418
591
  };
419
592
 
420
593
  // src/behaviors/fly.ts
421
- import { z as z7 } from "zod";
422
- var flyOptionsSchema = z7.object({
594
+ import { z as z8 } from "zod";
595
+ var flyOptionsSchema = z8.object({
423
596
  /** Ripple energy. */
424
- flutter: z7.number().min(0).max(1).default(0.5),
597
+ flutter: z8.number().min(0).max(1).default(0.5),
425
598
  /** Aerodynamic arc of the sheet. */
426
- curve: z7.number().min(0).max(1).default(0.4)
599
+ curve: z8.number().min(0).max(1).default(0.4)
427
600
  });
428
601
  var fly = {
429
602
  id: "fly",
@@ -452,12 +625,12 @@ var fly = {
452
625
  };
453
626
 
454
627
  // src/behaviors/fall.ts
455
- import { z as z8 } from "zod";
456
- var fallOptionsSchema = z8.object({
628
+ import { z as z9 } from "zod";
629
+ var fallOptionsSchema = z9.object({
457
630
  /** Air resistance ripple while falling. */
458
- flutter: z8.number().min(0).max(1).default(0.6),
631
+ flutter: z9.number().min(0).max(1).default(0.6),
459
632
  /** A falling sheet always lifts a corner. */
460
- curl: z8.number().min(0).max(1).default(0.3)
633
+ curl: z9.number().min(0).max(1).default(0.3)
461
634
  });
462
635
  var fall = {
463
636
  id: "fall",
@@ -489,21 +662,21 @@ var fall = {
489
662
  };
490
663
 
491
664
  // src/behaviors/carry.ts
492
- import { z as z9 } from "zod";
493
- var carryOptionsSchema = z9.object({
665
+ import { z as z10 } from "zod";
666
+ var carryOptionsSchema = z10.object({
494
667
  /**
495
668
  * The grab point — where the pointer was on the paper at pick time.
496
669
  * 'auto' is resolved by the carry controller (usually the peeled corner:
497
670
  * continuity from peel → carry is the immersion moment).
498
671
  */
499
- grab: z9.enum([...cornerNames, "auto"]).default("auto"),
672
+ grab: z10.enum([...cornerNames, "auto"]).default("auto"),
500
673
  /** From stock feel: a stamp is stiff — it flutters, it doesn't flow. */
501
- stiffness: z9.number().min(0).max(1).default(0.7),
502
- flutter: z9.number().min(0).max(1).default(0.5),
674
+ stiffness: z10.number().min(0).max(1).default(0.7),
675
+ flutter: z10.number().min(0).max(1).default(0.5),
503
676
  /** How far the paper's yaw trails the drag direction (runtime transform). */
504
- lag: z9.number().min(0).max(1).default(0.35),
677
+ lag: z10.number().min(0).max(1).default(0.35),
505
678
  /** Drag-speed drive (0..1). Written live by the carry controller. */
506
- drive: z9.number().min(0).max(1).default(0.25)
679
+ drive: z10.number().min(0).max(1).default(0.25)
507
680
  });
508
681
  var concreteGrab = (g) => g === "auto" ? "top-left" : g;
509
682
  var DROOP_ANGLE = {
@@ -550,7 +723,7 @@ var carry = {
550
723
  };
551
724
 
552
725
  // src/behaviors/flight.ts
553
- import { z as z10 } from "zod";
726
+ import { z as z11 } from "zod";
554
727
 
555
728
  // src/physics/aero.ts
556
729
  function dampTo(state, target, smoothing, dt) {
@@ -592,17 +765,17 @@ function carryDrive(speed) {
592
765
  }
593
766
 
594
767
  // src/behaviors/flight.ts
595
- var flightOptionsSchema = z10.object({
768
+ var flightOptionsSchema = z11.object({
596
769
  /** Directional wind vector — paper travels ACROSS the scene, not just down. */
597
- wind: z10.tuple([z10.number().min(-2).max(2), z10.number().min(-2).max(2), z10.number().min(-2).max(2)]).default([0.6, 0.08, 0]),
598
- gustiness: z10.number().min(0).max(1).default(0.4),
599
- tumble: z10.number().min(0).max(1).default(0.6),
770
+ wind: z11.tuple([z11.number().min(-2).max(2), z11.number().min(-2).max(2), z11.number().min(-2).max(2)]).default([0.6, 0.08, 0]),
771
+ gustiness: z11.number().min(0).max(1).default(0.4),
772
+ tumble: z11.number().min(0).max(1).default(0.6),
600
773
  /** 'loop' is a seamless idle cycle; 'drift' travels along the wind. */
601
- path: z10.enum(["drift", "loop"]).default("drift"),
774
+ path: z11.enum(["drift", "loop"]).default("drift"),
602
775
  /** Drift only: exit the scene → re-enter the opposite side. */
603
- respawn: z10.boolean().default(true),
776
+ respawn: z11.boolean().default(true),
604
777
  /** Half-extent of the travel before respawn wraps it. */
605
- range: z10.number().min(0.5).max(12).default(3.5)
778
+ range: z11.number().min(0.5).max(12).default(3.5)
606
779
  });
607
780
  var flight = {
608
781
  id: "flight",
@@ -634,16 +807,16 @@ var flight = {
634
807
  };
635
808
 
636
809
  // src/behaviors/crumple.ts
637
- import { z as z11 } from "zod";
638
- var crumpleBehaviorOptionsSchema = z11.object({
810
+ import { z as z12 } from "zod";
811
+ var crumpleBehaviorOptionsSchema = z12.object({
639
812
  /** 0 = flat sheet, 1 = crushed. */
640
- progress: z11.number().min(0).max(1).default(0.55),
813
+ progress: z12.number().min(0).max(1).default(0.55),
641
814
  /** Few big facets at 0, many small ones at 1. */
642
- coarseness: z11.number().min(0).max(1).default(0.35),
815
+ coarseness: z12.number().min(0).max(1).default(0.35),
643
816
  /** How far the sheet curls in on itself as it crushes. */
644
- ball: z11.number().min(0).max(1).default(0.5),
817
+ ball: z12.number().min(0).max(1).default(0.5),
645
818
  /** A different crush of the same paper. */
646
- seed: z11.number().int().min(0).max(7).default(0)
819
+ seed: z12.number().int().min(0).max(7).default(0)
647
820
  });
648
821
  var crumpleBehavior = {
649
822
  id: "crumple",
@@ -676,8 +849,8 @@ var crumpleBehavior = {
676
849
  };
677
850
 
678
851
  // src/behaviors/settle.ts
679
- import { z as z12 } from "zod";
680
- var settleOptionsSchema = z12.object({
852
+ import { z as z13 } from "zod";
853
+ var settleOptionsSchema = z13.object({
681
854
  /**
682
855
  * How long ago it landed, 0..1.
683
856
  *
@@ -685,7 +858,7 @@ var settleOptionsSchema = z12.object({
685
858
  * a sheet that has been lying there, where its own weight has flattened
686
859
  * out everything except what its stiffness refuses to give up.
687
860
  */
688
- relax: z12.number().min(0).max(1).default(0.45),
861
+ relax: z13.number().min(0).max(1).default(0.45),
689
862
  /**
690
863
  * How hard the paper resists lying flat, 0..1.
691
864
  *
@@ -693,14 +866,14 @@ var settleOptionsSchema = z12.object({
693
866
  * never does. It is the whole reason a settled sheet reads as PAPER and
694
867
  * not as a decal — at 0 the mesh is a rectangle painted on the floor.
695
868
  */
696
- lift: z12.number().min(0).max(1).default(0.45),
869
+ lift: z13.number().min(0).max(1).default(0.45),
697
870
  /** Which corner stayed up. */
698
- corner: z12.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
871
+ corner: z13.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
699
872
  /**
700
873
  * Slack across the middle — the low, long undulation of a sheet that is
701
874
  * touching a floor in two places and bridging between them.
702
875
  */
703
- slack: z12.number().min(0).max(1).default(0.4)
876
+ slack: z13.number().min(0).max(1).default(0.4)
704
877
  });
705
878
  var settle = {
706
879
  id: "settle",
@@ -753,8 +926,8 @@ var settle = {
753
926
  };
754
927
 
755
928
  // src/behaviors/ribbon.ts
756
- import { z as z13 } from "zod";
757
- var ribbonOptionsSchema = z13.object({
929
+ import { z as z14 } from "zod";
930
+ var ribbonOptionsSchema = z14.object({
758
931
  /**
759
932
  * How much of the drop is lying on the floor, as a fraction of the height.
760
933
  *
@@ -763,11 +936,11 @@ var ribbonOptionsSchema = z13.object({
763
936
  * turns over reads as paper meeting a floor, which is the thing the
764
937
  * reference installations are actually about.
765
938
  */
766
- pool: z13.number().min(0).max(0.5).default(0.16),
939
+ pool: z14.number().min(0).max(0.5).default(0.16),
767
940
  /** How tightly it turns where it lands. Low is a soft slump, high is a curl. */
768
- curl: z13.number().min(0).max(1).default(0.45),
941
+ curl: z14.number().min(0).max(1).default(0.45),
769
942
  /** Folds running down the length. A printed strip is never a flat plane. */
770
- drape: z13.number().min(0).max(1).default(0.5)
943
+ drape: z14.number().min(0).max(1).default(0.5)
771
944
  });
772
945
  var ribbon = {
773
946
  id: "ribbon",
@@ -875,12 +1048,12 @@ var ribbon = {
875
1048
  };
876
1049
 
877
1050
  // src/config/schema.ts
878
- var sheetSchema = z14.object({
1051
+ var sheetSchema = z15.object({
879
1052
  /** World units. A letter sheet is ~1 × 1.4, a receipt ~1 × 2.6. */
880
- width: z14.number().positive().max(20).default(1),
881
- height: z14.number().positive().max(20).default(1.4),
1053
+ width: z15.number().positive().max(20).default(1),
1054
+ height: z15.number().positive().max(20).default(1.4),
882
1055
  /** Visual thickness in mm-ish units; drives edge/shadow treatment, not geometry (yet). */
883
- thickness: z14.number().min(0).max(2).default(0.2),
1056
+ thickness: z15.number().min(0).max(2).default(0.2),
884
1057
  /**
885
1058
  * `'auto'` sizes the grid from the active deformers' needs — genuinely, as
886
1059
  * of 0.3.0. It asks each one what these options require (a gentle bend and
@@ -896,8 +1069,8 @@ var sheetSchema = z14.object({
896
1069
  * still raises it, because that is a correctness floor rather than a
897
1070
  * preference.
898
1071
  */
899
- segments: z14.union([z14.literal("auto"), z14.number().int().min(2).max(256)]).default("auto"),
900
- cornerRadius: z14.number().min(0).max(0.5).default(0)
1072
+ segments: z15.union([z15.literal("auto"), z15.number().int().min(2).max(256)]).default("auto"),
1073
+ cornerRadius: z15.number().min(0).max(0.5).default(0)
901
1074
  });
902
1075
  var stockNames = [
903
1076
  "printer",
@@ -908,37 +1081,37 @@ var stockNames = [
908
1081
  "photo-gloss",
909
1082
  "sticker"
910
1083
  ];
911
- var stockSchema = z14.enum(stockNames);
912
- var washSchema = z14.object({
1084
+ var stockSchema = z15.enum(stockNames);
1085
+ var washSchema = z15.object({
913
1086
  /** The first pigment. */
914
- color: z14.string().default("#4a5b8c").describe("color"),
1087
+ color: z15.string().default("#4a5b8c").describe("color"),
915
1088
  /** The second. Blooms alternate, and overlaps multiply into a third. */
916
- secondary: z14.string().default("#b06a6a").describe("color"),
1089
+ secondary: z15.string().default("#b06a6a").describe("color"),
917
1090
  /** How many pools of colour. */
918
- blooms: z14.number().int().min(1).max(24).default(7),
1091
+ blooms: z15.number().int().min(1).max(24).default(7),
919
1092
  /** How far a pool runs before it dries — its size against the sheet. */
920
- spread: z14.number().min(0.1).max(1).default(0.7),
1093
+ spread: z15.number().min(0.1).max(1).default(0.7),
921
1094
  /** Softness of the wet edge. 0 is a hard cut, 1 is a pool still moving. */
922
- bleed: z14.number().min(0).max(1).default(0.5),
1095
+ bleed: z15.number().min(0).max(1).default(0.5),
923
1096
  /** How much pigment is in the water. */
924
- intensity: z14.number().min(0).max(1).default(0.55),
1097
+ intensity: z15.number().min(0).max(1).default(0.55),
925
1098
  /**
926
1099
  * Edge darkening — the ring of pigment left where a pool dried.
927
1100
  *
928
1101
  * The signature of the medium, and the one thing a plain gradient cannot
929
1102
  * fake. Without it a wash reads as an airbrush.
930
1103
  */
931
- edge: z14.number().min(0).max(1).default(0.6),
1104
+ edge: z15.number().min(0).max(1).default(0.6),
932
1105
  /** Pigment settling into the tooth of the paper. */
933
- granulation: z14.number().min(0).max(1).default(0.35),
1106
+ granulation: z15.number().min(0).max(1).default(0.35),
934
1107
  /** Fixed so a preset paints the same wash every time. */
935
- seed: z14.number().int().min(0).max(99).default(0)
1108
+ seed: z15.number().int().min(0).max(99).default(0)
936
1109
  });
937
- var blankContentBase = z14.object({
938
- type: z14.literal("blank")
1110
+ var blankContentBase = z15.object({
1111
+ type: z15.literal("blank")
939
1112
  });
940
- var imageContentBase = z14.object({
941
- type: z14.literal("image"),
1113
+ var imageContentBase = z15.object({
1114
+ type: z15.literal("image"),
942
1115
  /**
943
1116
  * Empty means "no picture yet", and renders as bare stock rather than as
944
1117
  * a failure. That is what lets a built-in preset be an image preset
@@ -946,30 +1119,30 @@ var imageContentBase = z14.object({
946
1119
  * `postage-stamp` are containers for the caller's own art, handed over via
947
1120
  * `<PaperField images={...} />` or `content.src`.
948
1121
  */
949
- src: z14.string().default(""),
950
- fit: z14.enum(["cover", "contain"]).default("cover"),
1122
+ src: z15.string().default(""),
1123
+ fit: z15.enum(["cover", "contain"]).default("cover"),
951
1124
  /** Read by the hidden DOM mirror and the no-WebGL fallback. */
952
- alt: z14.string().optional()
1125
+ alt: z15.string().optional()
953
1126
  });
954
- var textContentBase = z14.object({
955
- type: z14.literal("text"),
956
- text: z14.string().default("Dear reader,"),
957
- font: z14.string().default('Georgia, "Times New Roman", serif'),
1127
+ var textContentBase = z15.object({
1128
+ type: z15.literal("text"),
1129
+ text: z15.string().default("Dear reader,"),
1130
+ font: z15.string().default('Georgia, "Times New Roman", serif'),
958
1131
  /** px at texture resolution (long edge = 1024 logical px before DPR). */
959
- size: z14.number().min(8).max(256).default(44),
960
- weight: z14.number().min(100).max(900).default(400),
961
- color: z14.string().default("#2b2620").describe("color"),
962
- align: z14.enum(["left", "center", "right"]).default("left"),
1132
+ size: z15.number().min(8).max(256).default(44),
1133
+ weight: z15.number().min(100).max(900).default(400),
1134
+ color: z15.string().default("#2b2620").describe("color"),
1135
+ align: z15.enum(["left", "center", "right"]).default("left"),
963
1136
  /** Fraction of the short edge. */
964
- padding: z14.number().min(0).max(0.4).default(0.09),
965
- lineHeight: z14.number().min(0.8).max(3).default(1.45),
1137
+ padding: z15.number().min(0).max(0.4).default(0.09),
1138
+ lineHeight: z15.number().min(0.8).max(3).default(1.45),
966
1139
  /**
967
1140
  * Letter-spacing, in em. The one control display type cannot do without:
968
1141
  * a line set large enough to be read across a room needs its tracking
969
1142
  * pulled IN, and a small line of uppercase small-print needs it pushed
970
1143
  * out, and neither is achievable by changing the size.
971
1144
  */
972
- tracking: z14.number().min(-0.1).max(0.6).default(0),
1145
+ tracking: z15.number().min(-0.1).max(0.6).default(0),
973
1146
  /**
974
1147
  * Where the block sits down the sheet.
975
1148
  *
@@ -978,26 +1151,26 @@ var textContentBase = z14.object({
978
1151
  * poster wants — a block of type optically centred in the sheet rather
979
1152
  * than hung from its top edge.
980
1153
  */
981
- valign: z14.enum(["top", "center"]).default("top")
1154
+ valign: z15.enum(["top", "center"]).default("top")
982
1155
  });
983
- var cardContentBase = z14.object({
984
- type: z14.literal("card"),
1156
+ var cardContentBase = z15.object({
1157
+ type: z15.literal("card"),
985
1158
  /** Small, tracked, uppercase by convention — the label at the top. */
986
- title: z14.string().default(""),
1159
+ title: z15.string().default(""),
987
1160
  /** The card's reason for existing. */
988
- body: z14.string().default(""),
1161
+ body: z15.string().default(""),
989
1162
  /** Attribution, catalogue number, date — the line in small print at the foot. */
990
- note: z14.string().default(""),
1163
+ note: z15.string().default(""),
991
1164
  /** A hairline under the title. What separates a label from a paragraph. */
992
- rule: z14.boolean().default(true),
1165
+ rule: z15.boolean().default(true),
993
1166
  /**
994
1167
  * Ruled writing lines behind the body, as on an index card.
995
1168
  *
996
1169
  * Drawn UNDER the type and in the stock's own ink at low alpha, so they
997
1170
  * read as printed on the card rather than as underlines on the words.
998
1171
  */
999
- ruled: z14.boolean().default(false),
1000
- font: z14.string().default('Georgia, "Times New Roman", serif'),
1172
+ ruled: z15.boolean().default(false),
1173
+ font: z15.string().default('Georgia, "Times New Roman", serif'),
1001
1174
  /**
1002
1175
  * Body size, px at texture resolution. Title and note derive from it.
1003
1176
  *
@@ -1007,28 +1180,28 @@ var cardContentBase = z14.object({
1007
1180
  * third of the stock empty above and below it, which reads as a page that
1008
1181
  * was cropped rather than as a card that was set.
1009
1182
  */
1010
- size: z14.number().min(8).max(256).default(58),
1011
- color: z14.string().default("#2b2620").describe("color"),
1012
- align: z14.enum(["left", "center"]).default("left"),
1013
- padding: z14.number().min(0).max(0.4).default(0.1)
1183
+ size: z15.number().min(8).max(256).default(58),
1184
+ color: z15.string().default("#2b2620").describe("color"),
1185
+ align: z15.enum(["left", "center"]).default("left"),
1186
+ padding: z15.number().min(0).max(0.4).default(0.1)
1014
1187
  });
1015
- var receiptContentBase = z14.object({
1016
- type: z14.literal("receipt"),
1017
- store: z14.string().default("PAPERLAB"),
1018
- address: z14.string().default("124 PAPER ST"),
1019
- items: z14.array(z14.object({ name: z14.string(), price: z14.number() })).default([
1188
+ var receiptContentBase = z15.object({
1189
+ type: z15.literal("receipt"),
1190
+ store: z15.string().default("PAPERLAB"),
1191
+ address: z15.string().default("124 PAPER ST"),
1192
+ items: z15.array(z15.object({ name: z15.string(), price: z15.number() })).default([
1020
1193
  { name: "CURL, TRUE", price: 12 },
1021
1194
  { name: "ROLL, TIGHT", price: 8.5 },
1022
1195
  { name: "SHEET, ONE", price: 0.99 }
1023
1196
  ]),
1024
- taxRate: z14.number().min(0).max(1).default(0.08),
1025
- barcode: z14.boolean().default(true),
1197
+ taxRate: z15.number().min(0).max(1).default(0.08),
1198
+ barcode: z15.boolean().default(true),
1026
1199
  /** Fixed so presets render deterministically; omit for "now". */
1027
- timestamp: z14.string().optional(),
1028
- footer: z14.string().default("KEEP FOR YOUR RECORDS")
1200
+ timestamp: z15.string().optional(),
1201
+ footer: z15.string().default("KEEP FOR YOUR RECORDS")
1029
1202
  });
1030
1203
  var withWash = { wash: washSchema.optional() };
1031
- var backContentSchema = z14.discriminatedUnion("type", [
1204
+ var backContentSchema = z15.discriminatedUnion("type", [
1032
1205
  blankContentBase.extend(withWash),
1033
1206
  imageContentBase.extend(withWash),
1034
1207
  textContentBase.extend(withWash),
@@ -1041,7 +1214,7 @@ var imageContentSchema = imageContentBase.extend(withBack);
1041
1214
  var textContentSchema = textContentBase.extend(withBack);
1042
1215
  var cardContentSchema = cardContentBase.extend(withBack);
1043
1216
  var receiptContentSchema = receiptContentBase.extend(withBack);
1044
- var contentSchema = z14.discriminatedUnion("type", [
1217
+ var contentSchema = z15.discriminatedUnion("type", [
1045
1218
  blankContentSchema,
1046
1219
  imageContentSchema,
1047
1220
  textContentSchema,
@@ -1057,80 +1230,146 @@ function contentSchemaFor(type) {
1057
1230
  return option;
1058
1231
  }
1059
1232
  var paperEdges = ["top", "right", "bottom", "left"];
1060
- var surfaceSchema = z14.object({
1233
+ var surfaceSchema = z15.object({
1061
1234
  /** Paper fiber noise, 0..1. */
1062
- grain: z14.number().min(0).max(1).optional(),
1235
+ grain: z15.number().min(0).max(1).optional(),
1063
1236
  /** Light passing through the sheet from behind, 0..1. Stock defaults apply. */
1064
- translucency: z14.number().min(0).max(1).optional(),
1237
+ translucency: z15.number().min(0).max(1).optional(),
1065
1238
  /** Torn-edge alpha with a lightened fiber band. */
1066
- deckle: z14.object({
1067
- edges: z14.array(z14.enum(paperEdges)).default(["bottom"]),
1068
- roughness: z14.number().min(0).max(1).default(0.5)
1239
+ deckle: z15.object({
1240
+ edges: z15.array(z15.enum(paperEdges)).default(["bottom"]),
1241
+ roughness: z15.number().min(0).max(1).default(0.5)
1069
1242
  }).optional(),
1070
1243
  /** Visual AO/highlight companion to the fold deformer. */
1071
- creaseLines: z14.object({
1244
+ creaseLines: z15.object({
1072
1245
  /** Crease line direction, degrees (0 = horizontal lines). */
1073
- angle: z14.number().min(-360).max(360).default(0),
1246
+ angle: z15.number().min(-360).max(360).default(0),
1074
1247
  /** Positions across the sheet, 0..1 fractions. */
1075
- positions: z14.array(z14.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1076
- strength: z14.number().min(0).max(1).default(0.5)
1248
+ positions: z15.array(z15.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1249
+ strength: z15.number().min(0).max(1).default(0.5)
1077
1250
  }).optional(),
1078
1251
  /** Yellowing + foxing spots, 0..1. */
1079
- aging: z14.number().min(0).max(1).optional(),
1252
+ aging: z15.number().min(0).max(1).optional(),
1080
1253
  /** Reversed front-content ghost on the backside, 0..1. Stock defaults apply. */
1081
- showThrough: z14.number().min(0).max(1).optional(),
1254
+ showThrough: z15.number().min(0).max(1).optional(),
1082
1255
  /**
1083
1256
  * Postage-stamp perforation: alpha-punched semicircular holes along chosen
1084
1257
  * edges. `state` flips an edge to a ripped-through profile (torn) — set
1085
1258
  * automatically when a paper detaches from a `sheet` field, manual wins.
1086
1259
  */
1087
- perforation: z14.object({
1088
- edges: z14.union([z14.array(z14.enum(paperEdges)), z14.literal("all")]).default("all"),
1260
+ perforation: z15.object({
1261
+ edges: z15.union([z15.array(z15.enum(paperEdges)), z15.literal("all")]).default("all"),
1089
1262
  /** World units — default tuned to stamp scale. */
1090
- holeRadius: z14.number().min(2e-3).max(0.1).default(0.016),
1091
- spacing: z14.number().min(0.01).max(0.5).default(0.055),
1092
- state: z14.object({
1093
- top: z14.enum(["intact", "torn"]).optional(),
1094
- right: z14.enum(["intact", "torn"]).optional(),
1095
- bottom: z14.enum(["intact", "torn"]).optional(),
1096
- left: z14.enum(["intact", "torn"]).optional()
1263
+ holeRadius: z15.number().min(2e-3).max(0.1).default(0.016),
1264
+ spacing: z15.number().min(0.01).max(0.5).default(0.055),
1265
+ state: z15.object({
1266
+ top: z15.enum(["intact", "torn"]).optional(),
1267
+ right: z15.enum(["intact", "torn"]).optional(),
1268
+ bottom: z15.enum(["intact", "torn"]).optional(),
1269
+ left: z15.enum(["intact", "torn"]).optional()
1097
1270
  }).default({})
1098
1271
  }).optional()
1099
1272
  });
1100
- var behaviorConfigSchema = z14.discriminatedUnion("type", [
1101
- peelOptionsSchema.extend({ type: z14.literal("peel") }),
1102
- unrollOptionsSchema.extend({ type: z14.literal("unroll") }),
1103
- flipOptionsSchema.extend({ type: z14.literal("flip") }),
1104
- letterFoldOptionsSchema.extend({ type: z14.literal("letter-fold") }),
1105
- hangOptionsSchema.extend({ type: z14.literal("hang") }),
1106
- flyOptionsSchema.extend({ type: z14.literal("fly") }),
1107
- fallOptionsSchema.extend({ type: z14.literal("fall") }),
1108
- carryOptionsSchema.extend({ type: z14.literal("carry") }),
1109
- flightOptionsSchema.extend({ type: z14.literal("flight") }),
1110
- crumpleBehaviorOptionsSchema.extend({ type: z14.literal("crumple") }),
1111
- settleOptionsSchema.extend({ type: z14.literal("settle") }),
1112
- ribbonOptionsSchema.extend({ type: z14.literal("ribbon") })
1273
+ var creaseSchema = z15.object({
1274
+ angle: z15.number().min(-360).max(360).default(90),
1275
+ offset: z15.number().min(-20).max(20).default(0),
1276
+ /**
1277
+ * The residual fold angle in degrees, signed — how far open the crease
1278
+ * still sits once nothing is holding the paper. This is the whole of what
1279
+ * a crease IS: geometry and shading both read it, and it is what makes the
1280
+ * field authorable by hand (a dog-ear is a crease with a big `depth` near
1281
+ * a corner) rather than only recordable.
1282
+ */
1283
+ depth: z15.number().min(-180).max(180).default(12)
1284
+ });
1285
+ var memorySchema = z15.object({
1286
+ /**
1287
+ * How much of a fold this paper keeps, 0..1, over the stock's own
1288
+ * {@link Stock.takesSet}. Kraft holds a crease hard; vellum springs most
1289
+ * of the way back.
1290
+ */
1291
+ set: z15.number().min(0).max(1).optional(),
1292
+ /**
1293
+ * The creases themselves. Recorded by folding the paper (see
1294
+ * `onCrease`), or written by hand — a preset can ship already creased.
1295
+ *
1296
+ * Capped at four because that is what the crease shader carries, and a
1297
+ * cap the schema states is better than one the renderer applies silently.
1298
+ */
1299
+ creases: z15.array(creaseSchema).max(4).default([])
1300
+ });
1301
+ var behaviorConfigSchema = z15.discriminatedUnion("type", [
1302
+ peelOptionsSchema.extend({ type: z15.literal("peel") }),
1303
+ unrollOptionsSchema.extend({ type: z15.literal("unroll") }),
1304
+ flipOptionsSchema.extend({ type: z15.literal("flip") }),
1305
+ letterFoldOptionsSchema.extend({ type: z15.literal("letter-fold") }),
1306
+ hangOptionsSchema.extend({ type: z15.literal("hang") }),
1307
+ flyOptionsSchema.extend({ type: z15.literal("fly") }),
1308
+ fallOptionsSchema.extend({ type: z15.literal("fall") }),
1309
+ carryOptionsSchema.extend({ type: z15.literal("carry") }),
1310
+ flightOptionsSchema.extend({ type: z15.literal("flight") }),
1311
+ crumpleBehaviorOptionsSchema.extend({ type: z15.literal("crumple") }),
1312
+ settleOptionsSchema.extend({ type: z15.literal("settle") }),
1313
+ ribbonOptionsSchema.extend({ type: z15.literal("ribbon") })
1113
1314
  ]);
1114
- var deformerInstanceSchema = z14.object({
1115
- type: z14.string(),
1116
- options: z14.record(z14.unknown()).default({}),
1117
- enabled: z14.boolean().default(true)
1315
+ var deformerInstanceSchema = z15.object({
1316
+ type: z15.string(),
1317
+ options: z15.record(z15.string(), z15.unknown()).default({}),
1318
+ enabled: z15.boolean().default(true)
1118
1319
  });
1119
1320
  var physicsNames = ["none", "float", "tumble", "dangle", "taped", "breeze"];
1120
- var clothConfigSchema = z14.object({
1121
- type: z14.literal("cloth"),
1122
- pins: z14.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1123
- wind: z14.number().min(0).max(1).default(0.3),
1321
+ var clothConfigSchema = z15.object({
1322
+ type: z15.literal("cloth"),
1323
+ pins: z15.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1324
+ wind: z15.number().min(0).max(1).default(0.3),
1124
1325
  /** Bend stiffness: 1 = crisp paper, 0 = silk. */
1125
- stiffness: z14.number().min(0).max(1).default(0.8),
1126
- gravity: z14.number().min(0).max(2).default(1),
1326
+ stiffness: z15.number().min(0).max(1).default(0.8),
1327
+ gravity: z15.number().min(0).max(2).default(1),
1127
1328
  /** Local-space ground plane the sheet settles onto. */
1128
- floor: z14.number().min(-5).max(0).default(-1.4)
1329
+ floor: z15.number().min(-5).max(0).default(-1.4)
1330
+ });
1331
+ var stripConfigSchema = z15.object({
1332
+ type: z15.literal("strip"),
1333
+ /** How far the page has scrolled, in world units of paper asked for. */
1334
+ scroll: z15.number().min(-1e3).max(1e3).default(0),
1335
+ /** Thin layers and many turns, or few and fat. */
1336
+ tightness: z15.number().min(0).max(1).default(0.6),
1337
+ /** Radius of the cardboard tube — the roll never pays out past it. */
1338
+ core: z15.number().min(0.01).max(0.5).default(0.09),
1339
+ /** Paper already hanging before the first scroll. A roll always has a leaf out. */
1340
+ tail: z15.number().min(0).max(20).default(1.1),
1341
+ /** Spacing of the perforations: one sheet's worth of strip, in world units. */
1342
+ perforation: z15.number().min(0.05).max(5).default(1),
1343
+ /**
1344
+ * How much a perforation remembers being folded. 0 = a fresh roll, 1 = one
1345
+ * that has been used.
1346
+ *
1347
+ * The default is high on purpose: below about 0.6 the landed paper flops
1348
+ * over in flat panels and spreads across the floor, and at 0.7 it holds its
1349
+ * folds and stacks into an accordion. The pile is the point.
1350
+ */
1351
+ crease: z15.number().min(0).max(1).default(0.7),
1352
+ /** Bend stiffness between perforations. 1 = card, 0 = cloth. */
1353
+ stiffness: z15.number().min(0).max(1).default(0.55),
1354
+ /** Broadside air drag — what makes paper float down rather than drop. */
1355
+ drag: z15.number().min(0).max(1).default(0.55),
1356
+ gravity: z15.number().min(0).max(2).default(1),
1357
+ /**
1358
+ * How far below the roll the paper lands. A DISTANCE below the roll's axis,
1359
+ * matching `unroll.floor`, not a signed y like `cloth.floor` — the roll
1360
+ * family measures drops, and the composition is centred on the origin so
1361
+ * an absolute y would not survive the offset anyway.
1362
+ */
1363
+ floor: z15.number().min(0.1).max(30).default(1.2),
1364
+ /** How long the roll coasts after the scroll stops. */
1365
+ inertia: z15.number().min(0).max(1).default(0.45)
1129
1366
  });
1130
- var physicsSchema = z14.union([
1131
- z14.enum(physicsNames),
1132
- z14.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1133
- clothConfigSchema
1367
+ var physicsSchema = z15.union([
1368
+ z15.enum(physicsNames),
1369
+ z15.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1370
+ z15.literal("strip").transform(() => stripConfigSchema.parse({ type: "strip" })),
1371
+ clothConfigSchema,
1372
+ stripConfigSchema
1134
1373
  ]);
1135
1374
  var lightingNames = [
1136
1375
  "studio",
@@ -1143,9 +1382,9 @@ var lightingNames = [
1143
1382
  "lightbox"
1144
1383
  ];
1145
1384
  var filmNames = ["agx", "neutral", "filmic"];
1146
- var lightSchema = z14.object({
1385
+ var lightSchema = z15.object({
1147
1386
  /** Tone-mapping exposure — the stop the whole picture is printed at. */
1148
- exposure: z14.number().min(0.1).max(4).optional(),
1387
+ exposure: z15.number().min(0.1).max(4).optional(),
1149
1388
  /**
1150
1389
  * The tone curve — the film, where `exposure` is the stop.
1151
1390
  *
@@ -1154,33 +1393,33 @@ var lightSchema = z14.object({
1154
1393
  * the wrong film: it desaturates and drags bright neutrals toward
1155
1394
  * yellow-green, which is the sepia cast a lit sheet used to pick up.
1156
1395
  */
1157
- film: z14.enum(filmNames).optional(),
1396
+ film: z15.enum(filmNames).optional(),
1158
1397
  /** Key light strength. */
1159
- key: z14.number().min(0).max(12).optional(),
1398
+ key: z15.number().min(0).max(12).optional(),
1160
1399
  /** Key light colour. */
1161
- color: z14.string().optional().describe("color"),
1400
+ color: z15.string().optional().describe("color"),
1162
1401
  /**
1163
1402
  * Where the key stands, degrees around the vertical. 0° is straight in
1164
1403
  * front of the paper (+Z, beside the camera), 90° is off to the right,
1165
1404
  * and ±180° is directly behind it — which is where `nave` puts it, and
1166
1405
  * why that preset is carried by light coming THROUGH the paper.
1167
1406
  */
1168
- direction: z14.number().min(-180).max(180).optional(),
1407
+ direction: z15.number().min(-180).max(180).optional(),
1169
1408
  /** How high the key stands, degrees above the horizon. */
1170
- height: z14.number().min(-30).max(89).optional(),
1409
+ height: z15.number().min(-30).max(89).optional(),
1171
1410
  /** Flat fill from every direction at once. Cheap, and it kills form — reach for `studio` first. */
1172
- ambient: z14.number().min(0).max(2).optional(),
1411
+ ambient: z15.number().min(0).max(2).optional(),
1173
1412
  /** The room's own light: an environment map built from `sky`. Directional fill, and the only thing paper's sheen has to reflect. */
1174
- studio: z14.number().min(0).max(3).optional(),
1413
+ studio: z15.number().min(0).max(3).optional(),
1175
1414
  /** Distance haze, as a multiple of the preset's. 0 clears the air entirely; 2 halves the distance you can see. */
1176
- haze: z14.number().min(0).max(3).optional()
1415
+ haze: z15.number().min(0).max(3).optional()
1177
1416
  });
1178
- var backdropSchema = z14.object({
1417
+ var backdropSchema = z15.object({
1179
1418
  /** Behind everything, and behind the picture where it does not reach. */
1180
- color: z14.string().default("#171717").describe("color"),
1419
+ color: z15.string().default("#171717").describe("color"),
1181
1420
  /** A URL, or an uploaded picture. Empty is the colour on its own. */
1182
- image: z14.string().default(""),
1183
- fit: z14.enum(["cover", "contain"]).default("cover"),
1421
+ image: z15.string().default(""),
1422
+ fit: z15.enum(["cover", "contain"]).default("cover"),
1184
1423
  /**
1185
1424
  * Toward the colour, so the paper stays the subject.
1186
1425
  *
@@ -1189,14 +1428,35 @@ var backdropSchema = z14.object({
1189
1428
  * of the light, and what this solves by mixing it back toward the ground
1190
1429
  * it sits on.
1191
1430
  */
1192
- fade: z14.number().min(0).max(1).default(0.25),
1431
+ fade: z15.number().min(0).max(1).default(0.25),
1193
1432
  /** Out of focus, for the same reason. */
1194
- blur: z14.number().min(0).max(1).default(0.2)
1433
+ blur: z15.number().min(0).max(1).default(0.2)
1195
1434
  });
1196
- var sceneSchema = z14.object({
1197
- lighting: z14.enum(lightingNames).default("studio"),
1435
+ var sceneSchema = z15.object({
1436
+ lighting: z15.enum(lightingNames).default("studio"),
1198
1437
  /** What is behind the sheet. Unset leaves the canvas alone. */
1199
1438
  backdrop: backdropSchema.optional(),
1439
+ /**
1440
+ * Degrees the whole composition is turned about its vertical axis, so a
1441
+ * preset can choose the angle it is READ from.
1442
+ *
1443
+ * Every camera in the library is fixed and head-on — `<Paper>` sits at
1444
+ * `(0, 0.35, 2.4)` looking down -Z, and neither it nor the editor fits a
1445
+ * camera to its content. That is the right default for a sheet, which is
1446
+ * flat and faces you. It is the wrong one for anything whose shape lives
1447
+ * in DEPTH: the `strip` sim folds in z by construction, so head-on its
1448
+ * roll and the whole accordion of its pile are edge-on and the preset
1449
+ * renders as a blank white column.
1450
+ *
1451
+ * A camera field would have been the other way to fix it, and is worse: it
1452
+ * is meaningless inside `<PaperField>` and `<PaperMesh>`, where the caller
1453
+ * owns the camera and there may be a dozen papers sharing it. Turning the
1454
+ * paper works everywhere, because it is a property of the paper.
1455
+ *
1456
+ * Additive with the `rotation` prop rather than overriding it — the prop
1457
+ * is the caller's, and a preset does not get to overrule it.
1458
+ */
1459
+ turn: z15.number().min(-180).max(180).default(0),
1200
1460
  /**
1201
1461
  * Overrides on the named preset — the same authorable half stage mode has
1202
1462
  * always had, and which a lone sheet had no way to reach.
@@ -1210,56 +1470,65 @@ var sceneSchema = z14.object({
1210
1470
  });
1211
1471
  var coreStateNames = ["rest", "hover", "pressed", "picked", "placed"];
1212
1472
  var isStateName = (s) => coreStateNames.includes(s) || s.startsWith("custom:");
1213
- var stateNameSchema = z14.string().refine(isStateName, {
1473
+ var stateNameSchema = z15.string().refine(isStateName, {
1214
1474
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1215
1475
  });
1216
- var stateTransitionSchema = z14.object({
1217
- duration: z14.number().min(0).max(5).default(0.35),
1476
+ var stateTransitionSchema = z15.object({
1477
+ duration: z15.number().min(0).max(5).default(0.35),
1218
1478
  /** GSAP ease name. */
1219
- ease: z14.string().default("power2.out")
1479
+ ease: z15.string().default("power2.out")
1220
1480
  });
1221
- var stateDefSchema = z14.object({
1481
+ var stateDefSchema = z15.object({
1222
1482
  /** Deep-partial override of the paper schema (behavior params, surface, …). */
1223
- overrides: z14.record(z14.unknown()).default({}),
1483
+ overrides: z15.record(z15.string(), z15.unknown()).default({}),
1224
1484
  /** Transition INTO this state. */
1225
- transition: stateTransitionSchema.default({}),
1485
+ transition: stateTransitionSchema.prefault({}),
1226
1486
  /** Chained actions after arriving. v1: 'emit:<event>' only. */
1227
- onEnter: z14.array(z14.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1487
+ onEnter: z15.array(z15.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1228
1488
  });
1229
- var paperStatesSchema = z14.object({
1489
+ var paperStatesSchema = z15.object({
1230
1490
  initial: stateNameSchema.default("rest"),
1231
- states: z14.record(z14.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1491
+ states: z15.record(z15.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1232
1492
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1233
1493
  }),
1234
1494
  /** World-units drag distance that flips pressed → picked (pick-enabled behaviors only). */
1235
- pickThreshold: z14.number().min(5e-3).max(1).default(0.1)
1495
+ pickThreshold: z15.number().min(5e-3).max(1).default(0.1)
1236
1496
  });
1237
- var metaSchema = z14.object({
1238
- name: z14.string().default("untitled"),
1239
- author: z14.string().optional(),
1240
- version: z14.string().default("0"),
1241
- tags: z14.array(z14.string()).default([])
1497
+ var metaSchema = z15.object({
1498
+ name: z15.string().default("untitled"),
1499
+ author: z15.string().optional(),
1500
+ version: z15.string().default("0"),
1501
+ tags: z15.array(z15.string()).default([])
1242
1502
  });
1243
- var paperConfigSchema = z14.object({
1244
- meta: metaSchema.default({}),
1245
- sheet: sheetSchema.default({}),
1503
+ var paperConfigSchema = z15.object({
1504
+ meta: metaSchema.prefault({}),
1505
+ sheet: sheetSchema.prefault({}),
1246
1506
  stock: stockSchema.default("printer"),
1247
1507
  content: contentSchema.default({ type: "blank" }),
1248
1508
  /** A behavior OR a raw deformer stack — if both are present, `deformers` wins (it's the fork). */
1249
1509
  behavior: behaviorConfigSchema.optional(),
1250
- deformers: z14.array(deformerInstanceSchema).optional(),
1510
+ deformers: z15.array(deformerInstanceSchema).optional(),
1251
1511
  surface: surfaceSchema.default({}),
1512
+ /**
1513
+ * What the sheet remembers being folded — creases outlive the fold.
1514
+ *
1515
+ * Present by default, and on: paper that forgets is the bug this exists
1516
+ * to fix, so remembering is not something a preset should have to ask
1517
+ * for. `memory: { set: 0 }` is the opt-out, and it is what every sheet in
1518
+ * the library did before this shipped.
1519
+ */
1520
+ memory: memorySchema.prefault({}),
1252
1521
  physics: physicsSchema.default("none"),
1253
- scene: sceneSchema.default({}),
1254
- onTwos: z14.boolean().default(false),
1522
+ scene: sceneSchema.prefault({}),
1523
+ onTwos: z15.boolean().default(false),
1255
1524
  /** Interaction state machine — overrides-on-base diffs. */
1256
1525
  states: paperStatesSchema.optional()
1257
1526
  }).superRefine((config, ctx) => {
1258
- if (typeof config.physics === "object" && (config.behavior || config.deformers)) {
1527
+ if (typeof config.physics === "object" && config.physics.type === "strip" && (config.behavior || config.deformers)) {
1259
1528
  ctx.addIssue({
1260
- code: z14.ZodIssueCode.custom,
1529
+ code: z15.ZodIssueCode.custom,
1261
1530
  path: ["physics"],
1262
- message: "cloth physics and behavior/deformers are exclusive \u2014 cloth owns the vertices (pick Shape OR Simulation)"
1531
+ message: "the strip simulation and behavior/deformers are exclusive \u2014 the roll owns the vertices, and its rows are chain nodes rather than the sheet's own grid (pick Shape OR Strip)"
1263
1532
  });
1264
1533
  }
1265
1534
  if (config.states) {
@@ -1268,7 +1537,7 @@ var paperConfigSchema = z14.object({
1268
1537
  if (!def) continue;
1269
1538
  if (def.overrides.states !== void 0) {
1270
1539
  ctx.addIssue({
1271
- code: z14.ZodIssueCode.custom,
1540
+ code: z15.ZodIssueCode.custom,
1272
1541
  path: ["states", "states", name, "overrides"],
1273
1542
  message: "state overrides cannot override `states` (no nested state machines)"
1274
1543
  });
@@ -1279,7 +1548,7 @@ var paperConfigSchema = z14.object({
1279
1548
  if (!result.success) {
1280
1549
  const first = result.error.issues[0];
1281
1550
  ctx.addIssue({
1282
- code: z14.ZodIssueCode.custom,
1551
+ code: z15.ZodIssueCode.custom,
1283
1552
  path: ["states", "states", name, "overrides"],
1284
1553
  message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")} \u2014 ${first.message}` : "invalid"}`
1285
1554
  });
@@ -1310,7 +1579,9 @@ var stocks = {
1310
1579
  banding: 0,
1311
1580
  defaultSurface: { grain: 0.12 },
1312
1581
  showThrough: 0,
1313
- adhesive: false
1582
+ adhesive: false,
1583
+ // Office bond creases cleanly and holds it — the reference paper.
1584
+ takesSet: 0.6
1314
1585
  },
1315
1586
  thermal: {
1316
1587
  id: "thermal",
@@ -1323,7 +1594,9 @@ var stocks = {
1323
1594
  banding: 0.35,
1324
1595
  defaultSurface: { aging: 0.1 },
1325
1596
  showThrough: 0.06,
1326
- adhesive: false
1597
+ adhesive: false,
1598
+ // Thin and already curled off a roll; a fold in it stays folded.
1599
+ takesSet: 0.65
1327
1600
  },
1328
1601
  kraft: {
1329
1602
  id: "kraft",
@@ -1336,7 +1609,9 @@ var stocks = {
1336
1609
  banding: 0,
1337
1610
  defaultSurface: { grain: 0.5 },
1338
1611
  showThrough: 0,
1339
- adhesive: false
1612
+ adhesive: false,
1613
+ // Thick and fibrous. The crease is a break, and it never comes back.
1614
+ takesSet: 0.85
1340
1615
  },
1341
1616
  newsprint: {
1342
1617
  id: "newsprint",
@@ -1349,7 +1624,9 @@ var stocks = {
1349
1624
  banding: 0,
1350
1625
  defaultSurface: { grain: 0.7, aging: 0.15 },
1351
1626
  showThrough: 0.06,
1352
- adhesive: false
1627
+ adhesive: false,
1628
+ // Soft, short-fibred, and barely sprung — it crumples rather than resists.
1629
+ takesSet: 0.8
1353
1630
  },
1354
1631
  vellum: {
1355
1632
  id: "vellum",
@@ -1362,7 +1639,9 @@ var stocks = {
1362
1639
  banding: 0,
1363
1640
  defaultSurface: {},
1364
1641
  showThrough: 0.55,
1365
- adhesive: false
1642
+ adhesive: false,
1643
+ // Translucent and plasticky: it fights the fold and mostly wins.
1644
+ takesSet: 0.25
1366
1645
  },
1367
1646
  "photo-gloss": {
1368
1647
  id: "photo-gloss",
@@ -1375,7 +1654,9 @@ var stocks = {
1375
1654
  banding: 0,
1376
1655
  defaultSurface: {},
1377
1656
  showThrough: 0,
1378
- adhesive: false
1657
+ adhesive: false,
1658
+ // The coating resists, then cracks white — little angle kept, lots of mark.
1659
+ takesSet: 0.3
1379
1660
  },
1380
1661
  // Photo-gloss-like face, glossy near-white glue underside. The default
1381
1662
  // carrier for perforated stamp sheets.
@@ -1390,7 +1671,9 @@ var stocks = {
1390
1671
  banding: 0,
1391
1672
  defaultSurface: {},
1392
1673
  showThrough: 0,
1393
- adhesive: true
1674
+ adhesive: true,
1675
+ // A face sheet on a release liner; the liner does most of the remembering.
1676
+ takesSet: 0.5
1394
1677
  }
1395
1678
  };
1396
1679
  function getStock(name) {
@@ -1417,6 +1700,183 @@ var builtins = {
1417
1700
  behavior: { type: "unroll", progress: 0.55, tightness: 0.55, sway: 0.3 },
1418
1701
  surface: { deckle: { edges: ["bottom"], roughness: 0.6 } }
1419
1702
  },
1703
+ /**
1704
+ * A roll on a holder with a leaf already out, meant to be driven by scroll:
1705
+ * bind `behavior.progress` to how far down the page you are and the paper
1706
+ * pays out while the roll runs down toward its tube.
1707
+ *
1708
+ * The three parts that make it read as a real roll rather than a curled
1709
+ * sheet: `fixed` keeps the roll on its holder and moves the paper instead,
1710
+ * `tail` means there is always a leaf to take hold of, and `floor` gives
1711
+ * the drop somewhere to land — paper that reaches the ground creases and
1712
+ * lies down rather than hanging into the void forever. A `core` a third of
1713
+ * the full radius is a real cardboard tube, so the roll still looks like a
1714
+ * roll after it has been used down.
1715
+ */
1716
+ "paper-roll": {
1717
+ meta: { name: "Paper roll", tags: ["roll", "unroll", "scroll", "hero"] },
1718
+ sheet: { width: 1, height: 5 },
1719
+ stock: "newsprint",
1720
+ content: { type: "blank" },
1721
+ behavior: {
1722
+ type: "unroll",
1723
+ progress: 0.25,
1724
+ tightness: 0.8,
1725
+ sway: 0.15,
1726
+ from: "top",
1727
+ fixed: true,
1728
+ core: 0.12,
1729
+ tail: 0.5,
1730
+ floor: 2.4
1731
+ },
1732
+ surface: { deckle: { edges: ["bottom"], roughness: 0.5 } }
1733
+ },
1734
+ /**
1735
+ * The one that is a SIMULATION rather than a shape.
1736
+ *
1737
+ * `paper-roll` above draws this same object with a deformer stack, and for
1738
+ * a roll paying out against a wall that is the cheaper and better answer.
1739
+ * This preset exists for the half that geometry cannot reach: what happens
1740
+ * once the paper hits the ground. A deformer can bend a sheet along a curve
1741
+ * you have already chosen; it cannot discover that a strip under
1742
+ * compression buckles at its weakest hinge, and it cannot let one fold land
1743
+ * on the one beneath it. Both of those are what a pile IS.
1744
+ *
1745
+ * Bind `physics.scroll` to the page and the roll turns:
1746
+ *
1747
+ * ```tsx
1748
+ * const [scroll, setScroll] = useState(0)
1749
+ * useEffect(() => {
1750
+ * const onScroll = () => setScroll(window.scrollY / 120)
1751
+ * window.addEventListener('scroll', onScroll, { passive: true })
1752
+ * return () => window.removeEventListener('scroll', onScroll)
1753
+ * }, [])
1754
+ * <Paper preset="toilet-roll" physics={{ type: 'strip', scroll }} />
1755
+ * ```
1756
+ *
1757
+ * It is a MONOTONIC world-unit number, not a 0..1 progress — the sim
1758
+ * differentiates it, so scrolling back up rewinds the roll and drags the
1759
+ * pile taut before it lifts.
1760
+ *
1761
+ * The proportions are the real object's: a panel as wide as it is long, so
1762
+ * `perforation` equals the sheet width and the strip tears into squares.
1763
+ */
1764
+ "toilet-roll": {
1765
+ meta: { name: "Toilet roll", tags: ["roll", "scroll", "simulation", "hero"] },
1766
+ // The proportions are a real roll's, at the scale the library is viewed
1767
+ // at: `<Paper>` and the editor both look at the origin through about two
1768
+ // world units and neither fits a camera to its content, so the whole
1769
+ // composition — roll, drop and pile — has to live inside that. A panel is
1770
+ // as wide as it is long and the roll is about a panel across, which is
1771
+ // what a toilet roll is.
1772
+ //
1773
+ // Twenty-three panels of paper. Not a real roll's several hundred, but
1774
+ // enough that a full page of scrolling does not empty it: one scroll unit
1775
+ // is about one unit of paper on a fresh roll, so this is a couple of
1776
+ // screens' worth before the tube shows.
1777
+ sheet: { width: 0.6, height: 14 },
1778
+ stock: "printer",
1779
+ content: { type: "blank" },
1780
+ physics: {
1781
+ type: "strip",
1782
+ scroll: 0,
1783
+ // Wound tightly enough to hold this much paper at a believable size: the
1784
+ // outer radius lands at 0.61 of the panel width against a real roll's
1785
+ // 0.57, over about nine visible turns.
1786
+ //
1787
+ // `tightness` is doing double duty and the trade is worth knowing about.
1788
+ // A layer gap IS the paper's thickness, so it also sets how far apart
1789
+ // self-collision holds two folds — wind tighter for a neater roll and
1790
+ // the pile on the floor gets flatter, looser for a fatter pile and the
1791
+ // roll coarsens. This is the middle of that.
1792
+ //
1793
+ // It is ALSO the roll's rim, and that is the reason not to raise it on
1794
+ // looks alone. A layer gap is a real space between two wound turns, so
1795
+ // the roll's end face is concentric rings with nothing between them,
1796
+ // and off head-on you see between them — a fine sawtooth around the
1797
+ // rim that winding tighter genuinely reduces. It was tried. Tightening
1798
+ // to 0.78 also moves the roll's proportion to 0.553 of a panel width,
1799
+ // nearer a real roll's 0.57 than this is, so it looked like a free win
1800
+ // — and it throws the pile 1.33 units out in x against the 0.874 a
1801
+ // square parent can see, spreading 5.9 panel-widths. Framing beats the
1802
+ // rim. The sawtooth is what a roll wound from ONE zero-thickness
1803
+ // ribbon costs; it is not tuned away, and a caller who wants it gone
1804
+ // wants a thicker `stock` or fewer, fatter turns.
1805
+ tightness: 0.65,
1806
+ // A real cardboard tube, and a floor on how tight the spiral ever winds.
1807
+ // The innermost wrap is the coarsest thing in the roll — the same
1808
+ // arc-length step spans a bigger angle the smaller the radius — so the
1809
+ // core is what stops a nearly-empty roll turning back into a polygon.
1810
+ core: 0.12,
1811
+ // A panel and a half already hanging. A roll on a holder always has a
1812
+ // leaf out; starting from a bare cylinder reads as one still wrapped.
1813
+ tail: 0.9,
1814
+ perforation: 0.6,
1815
+ // The four numbers below were chosen TOGETHER, and by worst case rather
1816
+ // than by a good-looking run. A pile is chaotic: change `crease` by
1817
+ // 0.05 and one 14-second scroll can spread 2.0 panel-widths of floor or
1818
+ // 4.2, so a single trajectory is a sample and not a measurement. These
1819
+ // are scored over nine — three scroll depths crossed with three feed
1820
+ // rates — on how far the composition ever gets from the origin.
1821
+ //
1822
+ // What that fixed: the shipped set spread 2.9 panel-widths on average
1823
+ // and 4.0 at worst, and threw paper 1.60 units out in z. It ran off the
1824
+ // side of the frame and kept going. These hold 2.2 average, 2.8 worst,
1825
+ // and 1.09 out — while keeping the pile's height (about ten layers)
1826
+ // intact, which is the thing all of this exists to show.
1827
+ //
1828
+ // The most load-bearing of them. Below about 0.6 the landed paper flops
1829
+ // over in flat panels and runs away across the floor instead of folding
1830
+ // back; high is what makes the perforations hold and the pile
1831
+ // accordion. A used roll remembering its creases is the whole effect.
1832
+ crease: 0.9,
1833
+ // Low enough that a panel buckles rather than steering the pile: at 0.5
1834
+ // the sheet was stiff enough to push the folds already down along the
1835
+ // floor ahead of it, which is what "spreads across four panels" was.
1836
+ stiffness: 0.4,
1837
+ // High: paper is light and broad, and this is what separates it from a
1838
+ // rope hanging off a drum. It also damps the sideways travel that
1839
+ // carried the pile out of frame.
1840
+ drag: 0.85,
1841
+ gravity: 1,
1842
+ // The drop, measured from the roll's axis. Together with the roll's own
1843
+ // radius this IS the height of the composition, which is centred on the
1844
+ // origin — so keep their sum under about 1.7 or the roll and the pile
1845
+ // fall outside the ~1.75 units `<Paper>`'s fixed camera can see.
1846
+ //
1847
+ // Shortened from 1.2, and it is the single most effective number here:
1848
+ // a longer fall is more airtime for the strip to pick a direction and
1849
+ // glide, so it landed still travelling and slid. Worst-case spread goes
1850
+ // 4.2 panel-widths to 3.1 and the pile keeps its full depth. It buys
1851
+ // vertical room as well, which is what lets the roll sit further up.
1852
+ floor: 0.85,
1853
+ inertia: 0.5
1854
+ },
1855
+ surface: { grain: 0.25 },
1856
+ // The preset is unreadable without this, and that is not a figure of
1857
+ // speech: the strip sim folds in DEPTH, and every camera in the library
1858
+ // is fixed and head-on, so `<Paper preset="toilet-roll" />` framed the
1859
+ // roll end-on and the entire accordion edge-on and rendered a blank white
1860
+ // column. This is the three-quarter view the pile actually reads from —
1861
+ // enough to see along the folds and around the roll's rim, not so much
1862
+ // that the strip's face turns away.
1863
+ //
1864
+ // The ceiling is framing, not taste. Turning swaps the pile's DEPTH for
1865
+ // WIDTH, and depth is free — the camera has plenty — while width is not.
1866
+ // Measured over fifteen scroll trajectories the pile reaches 1.27 units
1867
+ // of depth at worst, and `halfWidth·cos θ + 1.27·sin θ` crosses the 0.874
1868
+ // half-view a square parent gets at 28°. Twenty-five leaves real margin
1869
+ // (0.809) and gives up almost nothing of the angle.
1870
+ //
1871
+ // Do not raise it without re-measuring that depth: the two numbers are
1872
+ // coupled, and the pile's depth is not stable under small changes to the
1873
+ // physics — see the note on `floor`.
1874
+ //
1875
+ // A parent narrower than square still crops the pile's far edge. That is
1876
+ // the honest limit of a fixed camera, and the caller's answer is their
1877
+ // own `rotation` prop, which this composes with rather than overrides.
1878
+ scene: { turn: 25 }
1879
+ },
1420
1880
  "letter-fold": {
1421
1881
  meta: { name: "Letter fold", tags: ["fold", "text"] },
1422
1882
  sheet: { width: 1, height: 1.4 },
@@ -1426,7 +1886,24 @@ var builtins = {
1426
1886
  text: "Dear you,\n\nSome things are worth folding carefully.\n\nYours,\nN."
1427
1887
  },
1428
1888
  behavior: { type: "letter-fold", progress: 0.4, crease: 0.3 },
1429
- surface: { creaseLines: { angle: 0, positions: [1 / 3, 2 / 3], strength: 0.5 } }
1889
+ // A letter that has been folded before. These were painted-on
1890
+ // `creaseLines` until the paper could carry real ones — two marks in the
1891
+ // shader at exactly the two places the tri-fold bends, which is a good
1892
+ // impression of a crease right up until you unfold the letter and it
1893
+ // comes back perfectly flat. As memory they bend the sheet as well as
1894
+ // marking it, and folding the letter deepens the creases it already has
1895
+ // rather than drawing a second pair on top of them.
1896
+ //
1897
+ // The lines are `letterFold`'s own: travel down and up, each a sixth of
1898
+ // the sheet from the middle. Shallower than a fold leaves (printer keeps
1899
+ // about 20°) because this letter has been folded once and put away, not
1900
+ // folded and reopened in front of you.
1901
+ memory: {
1902
+ creases: [
1903
+ { angle: 270, offset: 1.4 / 6, depth: 13 },
1904
+ { angle: 90, offset: 1.4 / 6, depth: 11 }
1905
+ ]
1906
+ }
1430
1907
  },
1431
1908
  /**
1432
1909
  * The wash, shown rather than described.
@@ -1732,77 +2209,6 @@ async function ensureFont(font, size) {
1732
2209
  // src/deformers/registry.ts
1733
2210
  import { z as z21 } from "zod";
1734
2211
 
1735
- // src/deformers/roll.ts
1736
- import { z as z15 } from "zod";
1737
- var rollOptionsSchema = z15.object({
1738
- /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
1739
- angle: z15.number().min(-360).max(360).default(90),
1740
- /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
1741
- boundary: z15.number().min(-20).max(20).default(0),
1742
- /** Cylinder radius — sharpness of the roll. */
1743
- radius: z15.number().min(0.01).max(2).default(0.12),
1744
- /** Radius growth per radian so multi-turn rolls spiral instead of z-fighting. */
1745
- spiral: z15.number().min(0).max(0.2).default(0.015)
1746
- });
1747
- var DEG2 = Math.PI / 180;
1748
- var roll = {
1749
- id: "roll",
1750
- label: "Roll",
1751
- defaults: rollOptionsSchema.parse({}),
1752
- optionsSchema: rollOptionsSchema,
1753
- geometry: {
1754
- minSegments: 48,
1755
- // The winding radius is the tightest curvature on the sheet — `spiral`
1756
- // only grows it as the roll winds outward, so the first turn is the one
1757
- // that sets the density.
1758
- autoSegments: (o, sheet2) => segmentsForArc(spanAlong(sheet2, o.angle), o.radius),
1759
- axis: (o) => o.angle
1760
- },
1761
- displace(out, _uv, o) {
1762
- const dirX = Math.cos(o.angle * DEG2);
1763
- const dirY = Math.sin(o.angle * DEG2);
1764
- const d = out.x * dirX + out.y * dirY;
1765
- const s = d - o.boundary;
1766
- if (s <= 0) return;
1767
- const theta = s / o.radius;
1768
- const r = o.radius + o.spiral * theta;
1769
- const sin = Math.sin(theta);
1770
- const cos = Math.cos(theta);
1771
- const newD = o.boundary + (r - out.z) * sin;
1772
- const newZ = r * (1 - cos) + out.z * cos;
1773
- out.x += dirX * (newD - d);
1774
- out.y += dirY * (newD - d);
1775
- out.z = newZ;
1776
- },
1777
- glsl: {
1778
- chunk: (
1779
- /* glsl */
1780
- `
1781
- void FN(inout vec3 p, vec2 uv, float t) {
1782
- vec2 dir = vec2(cos(U_angle), sin(U_angle));
1783
- float d = dot(p.xy, dir);
1784
- float s = d - U_boundary;
1785
- if (s <= 0.0) return;
1786
- float theta = s / U_radius;
1787
- float r = U_radius + U_spiral * theta;
1788
- float sn = sin(theta);
1789
- float cs = cos(theta);
1790
- float newD = U_boundary + (r - p.z) * sn;
1791
- float newZ = r * (1.0 - cs) + p.z * cs;
1792
- p.xy += dir * (newD - d);
1793
- p.z = newZ;
1794
- }
1795
- `
1796
- ),
1797
- uniforms: (o) => ({
1798
- angle: o.angle * DEG2,
1799
- boundary: o.boundary,
1800
- radius: o.radius,
1801
- spiral: o.spiral
1802
- })
1803
- }
1804
- };
1805
-
1806
2212
  // src/deformers/bend.ts
1807
2213
  import { z as z16 } from "zod";
1808
2214
  var bendOptionsSchema = z16.object({
@@ -1984,7 +2390,7 @@ var waveOptionsSchema = z18.object({
1984
2390
  pinnedEdge: z18.enum(["none", "top", "bottom", "left", "right"]).default("none")
1985
2391
  });
1986
2392
  var DEG5 = Math.PI / 180;
1987
- var TAU = Math.PI * 2;
2393
+ var TAU2 = Math.PI * 2;
1988
2394
  var wave = {
1989
2395
  id: "wave",
1990
2396
  label: "Wave",
@@ -2011,7 +2417,7 @@ var wave = {
2011
2417
  const dirX = Math.cos(o.angle * DEG5);
2012
2418
  const dirY = Math.sin(o.angle * DEG5);
2013
2419
  const d = out.x * dirX + out.y * dirY;
2014
- const phase = (d / o.wavelength - o.speed * ctx.t) * TAU;
2420
+ const phase = (d / o.wavelength - o.speed * ctx.t) * TAU2;
2015
2421
  let env = 1;
2016
2422
  if (o.pinnedEdge === "top") env = 1 - uv.y;
2017
2423
  else if (o.pinnedEdge === "bottom") env = uv.y;
@@ -2067,7 +2473,7 @@ var drapeOptionsSchema = z19.object({
2067
2473
  gather: z19.number().min(0).max(1).default(0.5),
2068
2474
  pinnedEdge: z19.enum(["top", "bottom"]).default("top")
2069
2475
  });
2070
- var TAU2 = Math.PI * 2;
2476
+ var TAU3 = Math.PI * 2;
2071
2477
  var drape = {
2072
2478
  id: "drape",
2073
2479
  label: "Drape",
@@ -2095,7 +2501,7 @@ var drape = {
2095
2501
  if (o.amplitude === 0) return;
2096
2502
  const drop = o.pinnedEdge === "top" ? 1 - uv.y : uv.y;
2097
2503
  const depth = drop ** o.falloff;
2098
- const u = uv.x * TAU2 * o.folds;
2504
+ const u = uv.x * TAU3 * o.folds;
2099
2505
  const fold2 = Math.sin(u) + o.irregular * 0.6 * Math.sin(u * 1.7 + 2.1);
2100
2506
  out.z += o.amplitude * depth * fold2;
2101
2507
  const pinch = o.gather * depth * Math.min(o.amplitude * o.folds * 0.8, 0.6);
@@ -2298,6 +2704,185 @@ function stackIsAnimated(stack) {
2298
2704
  return stack.some((i) => i.enabled !== false && registry.get(i.type)?.animated);
2299
2705
  }
2300
2706
 
2707
+ // src/deformers/memory.ts
2708
+ var MAX_SET = 0.2;
2709
+ var CREASE_RADIUS = 0.03;
2710
+ var CREASE_MIN_GROWTH = 45;
2711
+ var CREASE_DRIFT = 0.02;
2712
+ var CREASE_DRIFT_ANGLE = 2;
2713
+ var MIN_DEPTH = 1;
2714
+ var MAX_CREASES = 4;
2715
+ function canonicalLine(angle, offset) {
2716
+ let a = (angle % 360 + 360) % 360;
2717
+ let o = offset;
2718
+ if (a >= 180) {
2719
+ a -= 180;
2720
+ o = -o;
2721
+ }
2722
+ return [a, o];
2723
+ }
2724
+ function sameLine(a, b) {
2725
+ const [aa, ao] = canonicalLine(a.angle, a.offset);
2726
+ const [ba, bo] = canonicalLine(b.angle, b.offset);
2727
+ const d = Math.abs(aa - ba);
2728
+ const angleClose = Math.min(d, 180 - d) <= CREASE_DRIFT_ANGLE;
2729
+ return angleClose && Math.abs(ao - bo) <= CREASE_DRIFT;
2730
+ }
2731
+ function applyMemory(stack, creases) {
2732
+ if (creases.length === 0) return stack;
2733
+ let out = stack;
2734
+ const loose = [];
2735
+ for (const crease of creases) {
2736
+ if (Math.abs(crease.depth) < MIN_DEPTH) continue;
2737
+ const index = out.findIndex(
2738
+ (i) => i.type === "fold" && i.enabled !== false && sameLine(i.options, crease)
2739
+ );
2740
+ if (index === -1) {
2741
+ loose.push(crease);
2742
+ continue;
2743
+ }
2744
+ const live = out[index];
2745
+ const options = live.options;
2746
+ if (Math.abs(options.foldAngle) >= Math.abs(crease.depth)) continue;
2747
+ if (out === stack) out = [...stack];
2748
+ out[index] = { ...live, options: { ...options, foldAngle: crease.depth } };
2749
+ }
2750
+ if (loose.length === 0) return out;
2751
+ return [...loose.map(toFold), ...out];
2752
+ }
2753
+ function toFold(crease) {
2754
+ return {
2755
+ type: "fold",
2756
+ options: {
2757
+ angle: crease.angle,
2758
+ offset: crease.offset,
2759
+ foldAngle: crease.depth,
2760
+ radius: CREASE_RADIUS
2761
+ }
2762
+ };
2763
+ }
2764
+ var CreaseTracker = class {
2765
+ slots = /* @__PURE__ */ new Map();
2766
+ recorded = [];
2767
+ authored = [];
2768
+ /** What `observe` last handed out, so an echo can be told from an edit. */
2769
+ lastReported = [];
2770
+ constructor(authored = []) {
2771
+ this.authored = authored;
2772
+ }
2773
+ /**
2774
+ * Forget how the paper got here without forgetting the creases.
2775
+ *
2776
+ * Called when the stack is replaced wholesale (a new behavior, a new
2777
+ * sheet): the slots describe folds that no longer exist, but a crease is a
2778
+ * property of the paper and survives being put down and picked up.
2779
+ */
2780
+ reset(authored = this.creases) {
2781
+ this.slots.clear();
2782
+ this.recorded = [];
2783
+ this.authored = authored;
2784
+ }
2785
+ /**
2786
+ * Take on a crease set that came from outside, and work out which kind of
2787
+ * outside it was — because the two kinds want opposite things.
2788
+ *
2789
+ * It is USUALLY this tracker's own recording coming back, a frame or two
2790
+ * after `onCrease` handed it to the host. Then the slots must survive: the
2791
+ * fold that made the crease is very likely still closing, and resetting
2792
+ * its peak on the host's echo would stall the crease halfway into the
2793
+ * fold that was making it.
2794
+ *
2795
+ * But it can also be somebody EDITING the paper — a depth dragged down in
2796
+ * a panel, a shared link opened, a state's overrides settling. Then the
2797
+ * slots are the wrong story to keep. `merge` takes the deeper of two
2798
+ * creases on a line, so a recording of 20° would quietly outvote a human
2799
+ * asking for 5° and the slider would appear not to work; and it would go
2800
+ * on outvoting it, because the fold that recorded the 20 is still sitting
2801
+ * in a slot. An edit means the paper is what it is now, and the next
2802
+ * crease has to be earned by folding it again.
2803
+ *
2804
+ * The two are told apart by what we last reported. Anything else is an
2805
+ * edit, which is the safe way round: mistaking an echo for an edit costs
2806
+ * a crease that gets re-recorded on the next fold, while mistaking an edit
2807
+ * for an echo costs a control that does not work.
2808
+ */
2809
+ adopt(creases) {
2810
+ if (!same(creases, this.lastReported)) this.slots.clear();
2811
+ this.authored = creases;
2812
+ this.recorded = [];
2813
+ }
2814
+ /** Everything the sheet currently carries, authored and recorded merged. */
2815
+ get creases() {
2816
+ return merge(this.authored, this.recorded);
2817
+ }
2818
+ /**
2819
+ * Take one frame's reading. Returns true when the crease set changed by
2820
+ * enough to be worth telling anyone about.
2821
+ */
2822
+ observe(stack, set) {
2823
+ if (set <= 0) return false;
2824
+ for (let i = 0; i < stack.length; i++) {
2825
+ const instance = stack[i];
2826
+ if (instance.type !== "fold" || instance.enabled === false) continue;
2827
+ const o = instance.options;
2828
+ const magnitude = Math.abs(o.foldAngle);
2829
+ const slot = this.slots.get(i);
2830
+ if (!slot || Math.abs(slot.angle - o.angle) > CREASE_DRIFT_ANGLE || Math.abs(slot.offset - o.offset) > CREASE_DRIFT) {
2831
+ this.slots.set(i, {
2832
+ angle: o.angle,
2833
+ offset: o.offset,
2834
+ trough: magnitude,
2835
+ bestGrowth: 0,
2836
+ bestPeak: magnitude,
2837
+ sign: o.foldAngle < 0 ? -1 : 1
2838
+ });
2839
+ continue;
2840
+ }
2841
+ if (magnitude < slot.trough) slot.trough = magnitude;
2842
+ const growth = magnitude - slot.trough;
2843
+ if (growth > slot.bestGrowth) {
2844
+ slot.bestGrowth = growth;
2845
+ slot.bestPeak = magnitude;
2846
+ slot.sign = o.foldAngle < 0 ? -1 : 1;
2847
+ }
2848
+ }
2849
+ const next = [];
2850
+ for (const slot of this.slots.values()) {
2851
+ if (slot.bestGrowth < CREASE_MIN_GROWTH) continue;
2852
+ const depth = slot.sign * slot.bestPeak * set * MAX_SET;
2853
+ if (Math.abs(depth) < MIN_DEPTH) continue;
2854
+ next.push({ angle: slot.angle, offset: slot.offset, depth });
2855
+ }
2856
+ if (same(next, this.recorded)) return false;
2857
+ this.recorded = next;
2858
+ this.lastReported = this.creases;
2859
+ return true;
2860
+ }
2861
+ };
2862
+ function merge(authored, recorded) {
2863
+ const out = [];
2864
+ for (const crease of [...recorded, ...authored]) {
2865
+ const existing = out.find((c) => sameLine(c, crease));
2866
+ if (!existing) {
2867
+ out.push({ ...crease });
2868
+ continue;
2869
+ }
2870
+ if (Math.abs(crease.depth) > Math.abs(existing.depth)) existing.depth = crease.depth;
2871
+ }
2872
+ if (out.length > MAX_CREASES) {
2873
+ out.sort((a, b) => Math.abs(b.depth) - Math.abs(a.depth));
2874
+ out.length = MAX_CREASES;
2875
+ }
2876
+ return out;
2877
+ }
2878
+ function same(a, b) {
2879
+ if (a.length !== b.length) return false;
2880
+ return a.every((crease, i) => {
2881
+ const other = b[i];
2882
+ return sameLine(crease, other) && Math.abs(crease.depth - other.depth) < 0.5;
2883
+ });
2884
+ }
2885
+
2301
2886
  // src/behaviors/registry.ts
2302
2887
  var registry2 = /* @__PURE__ */ new Map();
2303
2888
  function registerBehavior(behavior) {
@@ -2337,56 +2922,774 @@ var idlePresets = {
2337
2922
  pose.rotation[1] = Math.sin(t * 0.31) * 0.16;
2338
2923
  pose.rotation[2] = Math.sin(t * 0.23 + 1.2) * 0.05;
2339
2924
  }
2340
- },
2341
- tumble: {
2342
- id: "tumble",
2343
- label: "Tumble",
2344
- transform(t, pose) {
2345
- pose.rotation[0] = Math.sin(t * 0.5 + 1) * 0.6;
2346
- pose.rotation[2] = Math.sin(t * 0.7) * 0.5;
2347
- pose.position[1] = Math.sin(t * 1.4) * 0.07;
2348
- pose.position[0] = Math.sin(t * 0.35) * 0.14;
2349
- },
2350
- stack: () => [
2351
- {
2352
- type: "wave",
2353
- options: { amplitude: 0.02, wavelength: 0.9, speed: 0.9, angle: 25, pinnedEdge: "none" }
2925
+ },
2926
+ tumble: {
2927
+ id: "tumble",
2928
+ label: "Tumble",
2929
+ transform(t, pose) {
2930
+ pose.rotation[0] = Math.sin(t * 0.5 + 1) * 0.6;
2931
+ pose.rotation[2] = Math.sin(t * 0.7) * 0.5;
2932
+ pose.position[1] = Math.sin(t * 1.4) * 0.07;
2933
+ pose.position[0] = Math.sin(t * 0.35) * 0.14;
2934
+ },
2935
+ stack: () => [
2936
+ {
2937
+ type: "wave",
2938
+ options: { amplitude: 0.02, wavelength: 0.9, speed: 0.9, angle: 25, pinnedEdge: "none" }
2939
+ }
2940
+ ]
2941
+ },
2942
+ dangle: {
2943
+ id: "dangle",
2944
+ label: "Dangle",
2945
+ transform(t, pose) {
2946
+ pose.rotation[2] = Math.sin(t * 1.5) * 0.06;
2947
+ pose.rotation[0] = Math.sin(t * 1.1 + 0.7) * 0.03;
2948
+ }
2949
+ },
2950
+ taped: {
2951
+ id: "taped",
2952
+ label: "Taped",
2953
+ // Taped at the top of a wall: the free bottom edge breathes.
2954
+ stack: () => [
2955
+ {
2956
+ type: "wave",
2957
+ options: { amplitude: 0.025, wavelength: 1.2, speed: 0.45, angle: 90, pinnedEdge: "top" }
2958
+ }
2959
+ ]
2960
+ },
2961
+ breeze: {
2962
+ id: "breeze",
2963
+ label: "Breeze",
2964
+ stack: () => [
2965
+ {
2966
+ type: "wave",
2967
+ options: { amplitude: 0.035, wavelength: 0.5, speed: 1, angle: 20, pinnedEdge: "none" }
2968
+ }
2969
+ ]
2970
+ }
2971
+ };
2972
+ function getIdlePreset(name) {
2973
+ return idlePresets[name];
2974
+ }
2975
+
2976
+ // src/physics/strip.ts
2977
+ var FIXED_DT = 1 / 120;
2978
+ var SOLVER_ITERATIONS = 8;
2979
+ var SLEEP_EPSILON = 1e-7;
2980
+ var SLEEP_FRAMES = 45;
2981
+ var LOOSE2 = 0.055;
2982
+ var TIGHT2 = 0.012;
2983
+ var NODES_PER_PANEL = 16;
2984
+ var MAX_NODES = 440;
2985
+ var MIN_NODES = 8;
2986
+ var REFERENCE_SEGMENT = 0.0375;
2987
+ var TANGENT_SPAN = 0.09;
2988
+ var PULL_STIFFNESS = 0.35;
2989
+ var COLLIDING_ITERATIONS = 4;
2990
+ var COLLISION_RELAXATION = 0.35;
2991
+ function safeSpiralRadius(segment, thickness) {
2992
+ return thickness > 0 ? segment * segment / (4 * thickness) : 0;
2993
+ }
2994
+ function layerThickness2(tightness) {
2995
+ return LOOSE2 - Math.min(1, Math.max(0, tightness)) * (LOOSE2 - TIGHT2);
2996
+ }
2997
+ function stripNodeCount(length, perforation) {
2998
+ const seg = Math.max(perforation, 1e-3) / NODES_PER_PANEL;
2999
+ return Math.min(MAX_NODES, Math.max(MIN_NODES, Math.round(length / seg) + 1));
3000
+ }
3001
+ function maxStripLength(perforation) {
3002
+ const seg = Math.max(perforation, 1e-3) / NODES_PER_PANEL;
3003
+ return (MAX_NODES - 1 - 0.5) * seg;
3004
+ }
3005
+ var StripSim = class {
3006
+ /** Chain length. Node 0 is the deepest wrap; node `count-1` is the free tip. */
3007
+ count;
3008
+ /** Rest spacing between neighbours, in world units. */
3009
+ segment;
3010
+ /** Node centreline, y/z interleaved. x is implicit: the strip never twists. */
3011
+ pos;
3012
+ prev;
3013
+ /** Arc distance from the tip, per node. */
3014
+ arc;
3015
+ /** 1 where a perforation hinges, and the sign of the crease it remembers. */
3016
+ perforated;
3017
+ /** Bend gain correction for this chain's node spacing. See {@link REFERENCE_SEGMENT}. */
3018
+ bendScale;
3019
+ /** Half-width, in nodes, of the window the drag tangent is measured over. */
3020
+ tangentNodes;
3021
+ halfWidth;
3022
+ totalLength;
3023
+ /**
3024
+ * Paper that can never be paid out, because its inner end is glued to the
3025
+ * tube — which is true of every roll you have ever used.
3026
+ *
3027
+ * Without it the roll pays out to nothing, `firstFreeIndex` reaches 0, and
3028
+ * every node the roll was made of becomes free paper and falls: the whole
3029
+ * roll drops off its holder and lands flat on the pile. One wrap held back
3030
+ * leaves a cylinder at the core radius on the holder, which is what an
3031
+ * empty roll looks like and the closest this library can get to drawing a
3032
+ * cardboard tube out of the one sheet it has.
3033
+ */
3034
+ tubeStub;
3035
+ params;
3036
+ /** Paid-out length: how much paper is off the roll. */
3037
+ paid;
3038
+ /** Angular velocity of the roll, rad/s. The thing that coasts. */
3039
+ omega = 0;
3040
+ /**
3041
+ * Whether anything has turned the roll yet — a scroll delta or a hand.
3042
+ *
3043
+ * `tail` is the opening pose, and editing it should re-pose the roll; but
3044
+ * once the host has scrolled, `paid` is the simulation's own state and
3045
+ * re-posing would yank the paper back out of the pile.
3046
+ */
3047
+ driven = false;
3048
+ lastScroll;
3049
+ primed = false;
3050
+ /** Node held by the pointer, or -1. Kinematic while held, like a wound node. */
3051
+ grabbed = -1;
3052
+ grabY = 0;
3053
+ grabZ = 0;
3054
+ stillFrames = 0;
3055
+ accumulator = 0;
3056
+ time = 0;
3057
+ asleep = false;
3058
+ // Spatial hash for self-collision. Sized once, refilled in place — the
3059
+ // per-substep collision pass allocates nothing.
3060
+ cellOf;
3061
+ bucketStart;
3062
+ bucketItems;
3063
+ bucketFill;
3064
+ tableSize;
3065
+ constructor(length, width, params) {
3066
+ this.params = { ...params };
3067
+ this.totalLength = length;
3068
+ this.halfWidth = width / 2;
3069
+ this.count = stripNodeCount(length, params.perforation);
3070
+ this.segment = length / (this.count - 1);
3071
+ this.bendScale = Math.min(1, this.segment / REFERENCE_SEGMENT);
3072
+ this.tangentNodes = Math.max(1, Math.round(TANGENT_SPAN / this.segment));
3073
+ this.pos = new Float64Array(this.count * 2);
3074
+ this.prev = new Float64Array(this.count * 2);
3075
+ this.arc = new Float64Array(this.count);
3076
+ this.perforated = new Int8Array(this.count);
3077
+ for (let i = 0; i < this.count; i++) {
3078
+ this.arc[i] = (this.count - 1 - i) * this.segment;
3079
+ }
3080
+ const spacing = Math.max(params.perforation, this.segment * 2);
3081
+ for (let i = 0; i < this.count - 1; i++) {
3082
+ const here = Math.floor(this.arc[i] / spacing);
3083
+ const next = Math.floor(this.arc[i + 1] / spacing);
3084
+ if (here !== next) this.perforated[i] = here % 2 === 0 ? 1 : -1;
3085
+ }
3086
+ this.tableSize = 1 << Math.ceil(Math.log2(Math.max(16, this.count * 2)));
3087
+ this.cellOf = new Int32Array(this.count);
3088
+ this.bucketStart = new Int32Array(this.tableSize + 1);
3089
+ this.bucketItems = new Int32Array(this.count);
3090
+ this.bucketFill = new Int32Array(this.tableSize);
3091
+ this.tubeStub = this.stubFor(params.core);
3092
+ this.paid = Math.min(params.tail, this.usableLength);
3093
+ this.lastScroll = params.scroll;
3094
+ this.layOut();
3095
+ }
3096
+ /**
3097
+ * Local-space y the pile builds on, after centring. A host placing a
3098
+ * shadow catcher or a contact shadow needs this; it cannot derive it,
3099
+ * because the composition is offset to sit on the origin.
3100
+ */
3101
+ get floorY() {
3102
+ return -this.params.floor + this.centreOffset;
3103
+ }
3104
+ /**
3105
+ * How far the whole composition is lifted so that it straddles the origin.
3106
+ *
3107
+ * The roll's axis is the sim's origin, which would park a long drop
3108
+ * entirely below the frame — `<Paper>` and the editor both look at the
3109
+ * origin through about two world units, and neither fits a camera to the
3110
+ * content. `unroll` learned the same lesson and left a note about it: a
3111
+ * composition anchored anywhere but the origin needs a bespoke camera at
3112
+ * every call site.
3113
+ *
3114
+ * Measured against the FULL radius rather than the current one, so it is a
3115
+ * constant for a given config. Deriving it from the live radius would drift
3116
+ * the entire pile upward as the roll ran down.
3117
+ */
3118
+ get centreOffset() {
3119
+ return (this.params.floor - this.outerRadius) / 2;
3120
+ }
3121
+ /**
3122
+ * The same centring, along the axis the pile actually grows in.
3123
+ *
3124
+ * Everything here is built around the DROP LINE — the z the paper leaves
3125
+ * the roll at — and that line is not the origin and does not stay put. It
3126
+ * starts at 0 on a full roll and travels back to `core - outerRadius` as
3127
+ * the roll runs down (see {@link tangentZ}), because the spiral's centre is
3128
+ * fixed and the tangent point walks in toward it. The pile builds around
3129
+ * wherever the line has been, so a composition that ignores this is offset
3130
+ * by half that travel before a single fold has landed, and z is DEPTH —
3131
+ * the axis a fixed head-on camera has the least room in.
3132
+ *
3133
+ * A constant for a given config, for the reason {@link centreOffset} is:
3134
+ * derived from the live radius it would slide the whole composition
3135
+ * backwards as the roll emptied, which reads far worse than sitting still
3136
+ * slightly off centre. Centring the line's travel splits the difference
3137
+ * between a full roll and an empty one.
3138
+ */
3139
+ get centreShift() {
3140
+ return (this.outerRadius - this.params.core) / 2;
3141
+ }
3142
+ /** Outer radius of what is still wound. Shrinks to `core` as the roll empties. */
3143
+ get radius() {
3144
+ return rollRadius(this.totalLength - this.paid, this.params.core, layerThickness2(this.params.tightness));
3145
+ }
3146
+ /** Radius of the full roll. Fixes the spiral's centre, which must not move. */
3147
+ get outerRadius() {
3148
+ return rollRadius(this.totalLength, this.params.core, layerThickness2(this.params.tightness));
3149
+ }
3150
+ /**
3151
+ * The wrap that never leaves: one full turn around the core, but never so
3152
+ * much of a short sheet that there is nothing left to unroll.
3153
+ *
3154
+ * A method rather than a constant because `core` is a live control. It was
3155
+ * a constructor-time `readonly`, which meant dragging `core` moved the
3156
+ * radius the spiral is drawn at while `usableLength` and the end stop kept
3157
+ * answering for the old tube — the roll would run past its own floor, or
3158
+ * stop short of it, depending on which way the slider went.
3159
+ */
3160
+ stubFor(core) {
3161
+ return Math.min(Math.PI * 2 * core, this.totalLength * 0.3);
3162
+ }
3163
+ /** Paper that can actually leave the roll — everything but the glued stub. */
3164
+ get usableLength() {
3165
+ return this.totalLength - this.tubeStub;
3166
+ }
3167
+ /** How much paper is left to give. `0` is down to the tube, `1` is untouched. */
3168
+ get remaining() {
3169
+ return 1 - this.paid / this.usableLength;
3170
+ }
3171
+ /**
3172
+ * Where the paper leaves the roll: the frontmost point of the current
3173
+ * outer wrap. The spiral's CENTRE is fixed (a lesson the `roll` deformer
3174
+ * paid for — see its notes), so as the roll runs down the tangent point
3175
+ * travels back toward the holder and the strip hangs closer to it. That
3176
+ * drift is real and worth keeping.
3177
+ */
3178
+ get tangentZ() {
3179
+ return this.radius - this.outerRadius;
3180
+ }
3181
+ /** Place every node: the wound ones on the spiral, the free ones straight down. */
3182
+ layOut() {
3183
+ const firstFree = this.firstFreeIndex();
3184
+ for (let i = 0; i < this.count; i++) {
3185
+ const p = i * 2;
3186
+ if (i < firstFree) {
3187
+ this.spiralPoint(this.arc[i] - this.paid, p, this.pos);
3188
+ } else {
3189
+ this.pos[p] = -(this.paid - this.arc[i]);
3190
+ this.pos[p + 1] = this.tangentZ;
3191
+ }
3192
+ }
3193
+ this.prev.set(this.pos);
3194
+ }
3195
+ /** First node that has come off the roll. Everything below it is kinematic. */
3196
+ firstFreeIndex() {
3197
+ const free = Math.ceil(this.count - 1 - this.paid / this.segment);
3198
+ return Math.min(this.count - 1, Math.max(0, free));
3199
+ }
3200
+ /**
3201
+ * Position of a point `wound` along the spiral from the tangent point,
3202
+ * written into `out` at offset `o`.
3203
+ *
3204
+ * Angle 0 is the tangent point at the front of the roll; winding runs up
3205
+ * over the top, which is the "over" hang. The radius falls by one layer
3206
+ * per turn, so the wraps are concentric and exactly a thickness apart.
3207
+ */
3208
+ spiralPoint(wound, o, out) {
3209
+ const r0 = this.radius;
3210
+ const thickness = layerThickness2(this.params.tightness);
3211
+ const k = thickness / (Math.PI * 2);
3212
+ const phi = windAngle(Math.max(0, wound), r0, k);
3213
+ const floor = Math.max(this.params.core, safeSpiralRadius(this.segment, thickness));
3214
+ const r = Math.max(floor, r0 - k * phi);
3215
+ out[o] = r * Math.sin(phi);
3216
+ out[o + 1] = -this.outerRadius + r * Math.cos(phi);
3217
+ }
3218
+ setParams(params) {
3219
+ let changed = false;
3220
+ const tailBefore = this.params.tail;
3221
+ for (const key of [
3222
+ "scroll",
3223
+ "tightness",
3224
+ "core",
3225
+ "tail",
3226
+ "crease",
3227
+ "stiffness",
3228
+ "drag",
3229
+ "gravity",
3230
+ "floor",
3231
+ "inertia"
3232
+ ]) {
3233
+ const value = params[key];
3234
+ if (value !== void 0 && value !== this.params[key]) {
3235
+ this.params[key] = value;
3236
+ changed = true;
3237
+ }
3238
+ }
3239
+ if (params.core !== void 0) {
3240
+ this.tubeStub = this.stubFor(params.core);
3241
+ this.paid = Math.min(this.paid, this.usableLength);
3242
+ }
3243
+ if (params.tail !== void 0 && tailBefore !== this.params.tail) {
3244
+ if (!this.driven) {
3245
+ this.paid = Math.min(this.params.tail, this.usableLength);
3246
+ this.layOut();
3247
+ }
3248
+ }
3249
+ if (changed) this.wake();
3250
+ }
3251
+ wake() {
3252
+ this.asleep = false;
3253
+ this.stillFrames = 0;
3254
+ }
3255
+ /**
3256
+ * Take hold of the paper at a point, in the same y–z the vertex buffer is
3257
+ * written in — mesh-local coordinates, the lift already applied. Returns the
3258
+ * node caught, or -1 when there is no free paper to catch.
3259
+ *
3260
+ * Speaking the buffer's coordinates rather than the solver's is the whole
3261
+ * contract here: a caller has a raycast hit on the mesh and nothing else,
3262
+ * and the centring lift is an internal detail it has no way to know.
3263
+ *
3264
+ * Only paper that has left the roll can be caught — a wound node belongs to
3265
+ * the spiral, and pinning one would be pinning the roll itself.
3266
+ */
3267
+ grabNearest(y, z25) {
3268
+ const firstFree = this.firstFreeIndex();
3269
+ const localY = y - this.centreOffset;
3270
+ const localZ = z25 - this.centreShift;
3271
+ let best = -1;
3272
+ let bestDist = Infinity;
3273
+ for (let i = firstFree; i < this.count; i++) {
3274
+ const dy = this.pos[i * 2] - localY;
3275
+ const dz = this.pos[i * 2 + 1] - localZ;
3276
+ const d = dy * dy + dz * dz;
3277
+ if (d < bestDist) {
3278
+ bestDist = d;
3279
+ best = i;
3280
+ }
3281
+ }
3282
+ this.grabbed = best;
3283
+ this.grabY = localY;
3284
+ this.grabZ = localZ;
3285
+ this.wake();
3286
+ return best;
3287
+ }
3288
+ moveGrab(y, z25) {
3289
+ if (this.grabbed < 0) return;
3290
+ this.grabY = y - this.centreOffset;
3291
+ this.grabZ = z25 - this.centreShift;
3292
+ this.wake();
3293
+ }
3294
+ release() {
3295
+ this.grabbed = -1;
3296
+ }
3297
+ /** Whether a pointer currently holds the paper. */
3298
+ get held() {
3299
+ return this.grabbed >= 0;
3300
+ }
3301
+ step(delta) {
3302
+ if (this.params.scroll !== this.lastScroll) this.wake();
3303
+ if (this.asleep) return;
3304
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
3305
+ while (this.accumulator >= FIXED_DT) {
3306
+ this.substep(FIXED_DT);
3307
+ this.accumulator -= FIXED_DT;
3308
+ }
3309
+ }
3310
+ substep(dt) {
3311
+ this.time += dt;
3312
+ this.driveRoll(dt);
3313
+ this.pullFeed(dt);
3314
+ this.integrate(dt);
3315
+ const firstFree = this.firstFreeIndex();
3316
+ this.pinWound(firstFree);
3317
+ for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
3318
+ this.solveDistance(firstFree);
3319
+ this.solveBend(firstFree);
3320
+ if (iter >= SOLVER_ITERATIONS - COLLIDING_ITERATIONS) {
3321
+ this.solveSelfCollision(firstFree);
3322
+ this.solveFloor(firstFree);
2354
3323
  }
2355
- ]
2356
- },
2357
- dangle: {
2358
- id: "dangle",
2359
- label: "Dangle",
2360
- transform(t, pose) {
2361
- pose.rotation[2] = Math.sin(t * 1.5) * 0.06;
2362
- pose.rotation[0] = Math.sin(t * 1.1 + 0.7) * 0.03;
3324
+ this.pinWound(firstFree);
2363
3325
  }
2364
- },
2365
- taped: {
2366
- id: "taped",
2367
- label: "Taped",
2368
- // Taped at the top of a wall: the free bottom edge breathes.
2369
- stack: () => [
2370
- {
2371
- type: "wave",
2372
- options: { amplitude: 0.025, wavelength: 1.2, speed: 0.45, angle: 90, pinnedEdge: "top" }
3326
+ this.solveFloor(firstFree);
3327
+ }
3328
+ /**
3329
+ * Turn the roll. Scroll enters as an angle the roll OWES, which a leaky
3330
+ * integrator spends into angular velocity so a steady scroll gives a
3331
+ * steady spin and a flick spikes and coasts down.
3332
+ *
3333
+ * `ΔL = R·Δθ` with the CURRENT radius, which is why the radius has to be
3334
+ * read inside the loop: a nearly-empty roll spins fast and gives up very
3335
+ * little paper for the same scroll, and that is the whole tell that a roll
3336
+ * is running out.
3337
+ */
3338
+ driveRoll(dt) {
3339
+ const scroll = this.params.scroll;
3340
+ if (!this.primed) {
3341
+ this.lastScroll = scroll;
3342
+ this.primed = true;
3343
+ }
3344
+ const impulse = (scroll - this.lastScroll) / this.outerRadius;
3345
+ this.lastScroll = scroll;
3346
+ const tau = 0.012 + this.params.inertia * 0.55;
3347
+ this.omega = this.omega * Math.exp(-dt / tau) + impulse / tau;
3348
+ if (Math.abs(this.omega) < 1e-6) this.omega = 0;
3349
+ const next = this.paid + this.radius * this.omega * dt;
3350
+ if (next !== this.paid) this.driven = true;
3351
+ this.paid = Math.min(this.usableLength, Math.max(0, next));
3352
+ if (this.paid === 0 || this.paid === this.usableLength) this.omega = 0;
3353
+ }
3354
+ /**
3355
+ * Take hold of the paper and pull, and the roll turns — the interaction the
3356
+ * real object is famous for.
3357
+ *
3358
+ * It is driven by TENSION rather than by mapping hand travel to an angle.
3359
+ * Paper does not stretch, so if the hand is further from the roll than there
3360
+ * is paper to reach it, the only way the constraint can be satisfied is for
3361
+ * the roll to give up more. That one rule gets the whole behaviour for free:
3362
+ * a slow pull feeds smoothly, a fast yank spins the roll and it carries on
3363
+ * after release, and pushing the paper back toward the roll does nothing at
3364
+ * all — which is exactly right, because slack does not rewind a roll. Only
3365
+ * scrolling up does.
3366
+ */
3367
+ pullFeed(dt) {
3368
+ if (this.grabbed < 0 || this.grabbed < this.firstFreeIndex()) return;
3369
+ const reach = Math.hypot(this.grabY, this.grabZ - this.tangentZ);
3370
+ const available = this.paid - this.arc[this.grabbed];
3371
+ const over = reach - available;
3372
+ if (over <= 1e-6) return;
3373
+ const feed = Math.min(over * PULL_STIFFNESS, this.usableLength - this.paid);
3374
+ if (feed <= 0) return;
3375
+ this.paid += feed;
3376
+ this.omega = feed / (this.radius * dt);
3377
+ }
3378
+ /**
3379
+ * Verlet with ANISOTROPIC drag. A strip of paper barely notices the air
3380
+ * when it moves along its own length and is stopped almost dead when it
3381
+ * moves broadside, which is the difference between paper that floats down
3382
+ * and a rope that drops. Damping the velocity uniformly — the obvious
3383
+ * thing, and what cloth does — gets neither.
3384
+ */
3385
+ integrate(dt) {
3386
+ const { gravity, drag } = this.params;
3387
+ const p = this.pos;
3388
+ const dt2 = dt * dt;
3389
+ const firstFree = this.firstFreeIndex();
3390
+ const across = Math.exp(-(0.6 + drag * 11) * dt);
3391
+ const along = Math.exp(-(0.4 + drag * 0.8) * dt);
3392
+ let travel = 0;
3393
+ for (let i = firstFree; i < this.count; i++) {
3394
+ const o = i * 2;
3395
+ if (i === this.grabbed) {
3396
+ p[o] = this.grabY;
3397
+ p[o + 1] = this.grabZ;
3398
+ continue;
2373
3399
  }
2374
- ]
2375
- },
2376
- breeze: {
2377
- id: "breeze",
2378
- label: "Breeze",
2379
- stack: () => [
2380
- {
2381
- type: "wave",
2382
- options: { amplitude: 0.035, wavelength: 0.5, speed: 1, angle: 20, pinnedEdge: "none" }
3400
+ const y = p[o];
3401
+ const z25 = p[o + 1];
3402
+ let vy = y - this.prev[o];
3403
+ let vz = z25 - this.prev[o + 1];
3404
+ const a = Math.max(firstFree, i - this.tangentNodes) * 2;
3405
+ const b = Math.min(this.count - 1, i + this.tangentNodes) * 2;
3406
+ let ty = p[b] - p[a];
3407
+ let tz = p[b + 1] - p[a + 1];
3408
+ const len = Math.hypot(ty, tz);
3409
+ if (len > 1e-9) {
3410
+ ty /= len;
3411
+ tz /= len;
3412
+ const vt = vy * ty + vz * tz;
3413
+ const ny = vy - vt * ty;
3414
+ const nz = vz - vt * tz;
3415
+ vy = vt * ty * along + ny * across;
3416
+ vz = vt * tz * along + nz * across;
2383
3417
  }
2384
- ]
3418
+ this.prev[o] = y;
3419
+ this.prev[o + 1] = z25;
3420
+ const noise = Math.sin(this.time * 2.3 + i * 1.7) * 6e-5;
3421
+ p[o] = y + vy - gravity * 3.2 * dt2;
3422
+ p[o + 1] = z25 + vz + noise;
3423
+ travel = Math.max(travel, vy * vy + vz * vz);
3424
+ }
3425
+ if (this.omega === 0 && this.grabbed < 0) {
3426
+ if (travel < SLEEP_EPSILON) {
3427
+ if (++this.stillFrames > SLEEP_FRAMES) this.asleep = true;
3428
+ } else this.stillFrames = 0;
3429
+ } else this.stillFrames = 0;
3430
+ }
3431
+ /** Wound nodes are the roll's, not the solver's: rewrite them every pass. */
3432
+ pinWound(firstFree) {
3433
+ for (let i = 0; i < firstFree; i++) {
3434
+ this.spiralPoint(this.arc[i] - this.paid, i * 2, this.pos);
3435
+ }
3436
+ const tz = this.tangentZ;
3437
+ for (let i = firstFree; i < Math.min(this.count, firstFree + 2); i++) {
3438
+ if (i === this.grabbed) continue;
3439
+ const o = i * 2;
3440
+ this.pos[o + 1] = this.pos[o + 1] + (tz - this.pos[o + 1]) * 0.5;
3441
+ }
3442
+ if (this.grabbed >= firstFree) {
3443
+ this.pos[this.grabbed * 2] = this.grabY;
3444
+ this.pos[this.grabbed * 2 + 1] = this.grabZ;
3445
+ }
3446
+ }
3447
+ /** Paper does not stretch. Distance constraints run at full strength. */
3448
+ solveDistance(firstFree) {
3449
+ const p = this.pos;
3450
+ const rest = this.segment;
3451
+ for (let i = Math.max(0, firstFree - 1); i < this.count - 1; i++) {
3452
+ const a = i * 2;
3453
+ const b = (i + 1) * 2;
3454
+ const dy = p[b] - p[a];
3455
+ const dz = p[b + 1] - p[a + 1];
3456
+ const dist = Math.hypot(dy, dz);
3457
+ if (dist < 1e-9) continue;
3458
+ const diff = (dist - rest) / dist * 0.5;
3459
+ const aFixed = i < firstFree || i === this.grabbed;
3460
+ const bFixed = i + 1 === this.grabbed;
3461
+ if (aFixed && bFixed) continue;
3462
+ const aw = aFixed ? 0 : bFixed ? 2 : 1;
3463
+ const bw = bFixed ? 0 : aFixed ? 2 : 1;
3464
+ p[a] = p[a] + dy * diff * aw;
3465
+ p[a + 1] = p[a + 1] + dz * diff * aw;
3466
+ p[b] = p[b] - dy * diff * bw;
3467
+ p[b + 1] = p[b + 1] - dz * diff * bw;
3468
+ }
3469
+ }
3470
+ /**
3471
+ * The bend constraint, and the reason this is not a rope preset.
3472
+ *
3473
+ * Every joint wants to be straight. A joint AT A PERFORATION wants it far
3474
+ * less, and may want a slight fold instead — those are the hinges the pile
3475
+ * folds at, and the alternating sign is what turns a heap into an
3476
+ * accordion. Uniform stiffness gives a coil; no stiffness gives a rope.
3477
+ */
3478
+ solveBend(firstFree) {
3479
+ const p = this.pos;
3480
+ const { stiffness, crease } = this.params;
3481
+ const start = Math.max(firstFree + 1, 1);
3482
+ for (let i = start; i < this.count - 1; i++) {
3483
+ const hinge = this.perforated[i];
3484
+ const k = (hinge !== 0 ? stiffness * 0.06 : stiffness) * 0.45 * this.bendScale;
3485
+ if (k <= 0) continue;
3486
+ const a = (i - 1) * 2;
3487
+ const m = i * 2;
3488
+ const b = (i + 1) * 2;
3489
+ let ty = (p[a] + p[b]) * 0.5;
3490
+ let tz = (p[a + 1] + p[b + 1]) * 0.5;
3491
+ if (hinge !== 0 && crease > 0) {
3492
+ let cy = p[b] - p[a];
3493
+ let cz = p[b + 1] - p[a + 1];
3494
+ const len = Math.hypot(cy, cz);
3495
+ if (len > 1e-9) {
3496
+ cy /= len;
3497
+ cz /= len;
3498
+ const bow = crease * this.segment * 0.35 * hinge;
3499
+ ty += -cz * bow;
3500
+ tz += cy * bow;
3501
+ }
3502
+ }
3503
+ const dy = (ty - p[m]) * k;
3504
+ const dz = (tz - p[m + 1]) * k;
3505
+ if (i !== this.grabbed) {
3506
+ p[m] = p[m] + dy;
3507
+ p[m + 1] = p[m + 1] + dz;
3508
+ }
3509
+ if (i - 1 >= firstFree && i - 1 !== this.grabbed) {
3510
+ p[a] = p[a] - dy * 0.5;
3511
+ p[a + 1] = p[a + 1] - dz * 0.5;
3512
+ }
3513
+ if (i + 1 !== this.grabbed) {
3514
+ p[b] = p[b] - dy * 0.5;
3515
+ p[b + 1] = p[b + 1] - dz * 0.5;
3516
+ }
3517
+ }
3518
+ }
3519
+ /**
3520
+ * Folds stack on each other instead of passing through. Without this the
3521
+ * pile collapses into a single flat line on the floor and there is nothing
3522
+ * to look at, which is the entire reason the preset exists.
3523
+ *
3524
+ * **Segment against segment, not node against node.** The obvious version
3525
+ * puts a sphere on every node and pushes overlapping pairs apart, and it
3526
+ * leaks: paper is thinner than the chain is finely cut — the layer gap here
3527
+ * is 0.027 against a node spacing of 0.037 — so those spheres do not touch
3528
+ * each other, and the chain is a string of beads with gaps between them
3529
+ * rather than a continuous tube. Another fold threads straight through a
3530
+ * gap, and once it is through, the point test pushes it out the far side
3531
+ * instead of back. It shows up exactly as a sheet edge buried in a surface.
3532
+ *
3533
+ * Testing the SEGMENTS closes the gaps, because a segment is the whole
3534
+ * length of paper between two nodes and not just its ends. The cost is a
3535
+ * closest-approach solve per candidate pair instead of a subtraction, which
3536
+ * is why the hash cell is sized to `segment + d`: a pair that can possibly
3537
+ * touch has its midpoints inside that, so a 3×3 neighbourhood is a complete
3538
+ * search rather than a hopeful one.
3539
+ *
3540
+ * Spatial hash over preallocated arrays, refilled by counting sort — no
3541
+ * allocation, and it does not degrade when the whole strip lands in one
3542
+ * cell, which is exactly what a pile IS.
3543
+ */
3544
+ solveSelfCollision(firstFree) {
3545
+ this.freeFrom = firstFree;
3546
+ const p = this.pos;
3547
+ const d = Math.max(layerThickness2(this.params.tightness), 1e-4);
3548
+ const n = this.count;
3549
+ const last = n - 2;
3550
+ if (last < firstFree + 2) return;
3551
+ const cell = this.segment + d;
3552
+ const inv = 1 / cell;
3553
+ const table = this.tableSize;
3554
+ const mask = table - 1;
3555
+ const starts = this.bucketStart;
3556
+ const items = this.bucketItems;
3557
+ const placed = this.bucketFill;
3558
+ starts.fill(0);
3559
+ placed.fill(0);
3560
+ for (let i = firstFree; i <= last; i++) {
3561
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
3562
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
3563
+ const h = (Math.floor(my * inv) * 92837111 ^ Math.floor(mz * inv) * 689287499) >>> 0 & mask;
3564
+ this.cellOf[i] = h;
3565
+ starts[h + 1]++;
3566
+ }
3567
+ for (let c = 0; c < table; c++) starts[c + 1] += starts[c];
3568
+ for (let i = firstFree; i <= last; i++) {
3569
+ const h = this.cellOf[i];
3570
+ items[starts[h] + placed[h]++] = i;
3571
+ }
3572
+ for (let i = firstFree; i <= last; i++) {
3573
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
3574
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
3575
+ const cy = Math.floor(my * inv);
3576
+ const cz = Math.floor(mz * inv);
3577
+ for (let oy = -1; oy <= 1; oy++) {
3578
+ for (let oz = -1; oz <= 1; oz++) {
3579
+ const h = ((cy + oy) * 92837111 ^ (cz + oz) * 689287499) >>> 0 & mask;
3580
+ const end = starts[h + 1];
3581
+ for (let e = starts[h]; e < end; e++) {
3582
+ const j = items[e];
3583
+ if (j >= i + 2) this.separate(i, j, d);
3584
+ }
3585
+ }
3586
+ }
3587
+ }
3588
+ }
3589
+ /**
3590
+ * Push segments `(i, i+1)` and `(j, j+1)` apart to `d`, if they are closer.
3591
+ *
3592
+ * Closest approach between two 2D segments, then the correction shared over
3593
+ * the four endpoints by how near each is to the touching point — so a fold
3594
+ * caught at its middle moves bodily, and one grazed at its tip barely
3595
+ * pivots. Wound and held nodes are the roll's and the hand's, so they take
3596
+ * none of it and their partner takes the lot.
3597
+ */
3598
+ separate(i, j, d) {
3599
+ const p = this.pos;
3600
+ const a = i * 2;
3601
+ const b = (i + 1) * 2;
3602
+ const c = j * 2;
3603
+ const e = (j + 1) * 2;
3604
+ const uy = p[b] - p[a];
3605
+ const uz = p[b + 1] - p[a + 1];
3606
+ const vy = p[e] - p[c];
3607
+ const vz = p[e + 1] - p[c + 1];
3608
+ const wy = p[a] - p[c];
3609
+ const wz = p[a + 1] - p[c + 1];
3610
+ const uu = uy * uy + uz * uz;
3611
+ const vv = vy * vy + vz * vz;
3612
+ if (uu < 1e-12 || vv < 1e-12) return;
3613
+ const uv = uy * vy + uz * vz;
3614
+ const uw = uy * wy + uz * wz;
3615
+ const vw = vy * wy + vz * wz;
3616
+ const denom = uu * vv - uv * uv;
3617
+ let s = denom > 1e-12 ? (uv * vw - vv * uw) / denom : 0;
3618
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
3619
+ let t = (uv * s + vw) / vv;
3620
+ if (t < 0) {
3621
+ t = 0;
3622
+ s = -uw / uu;
3623
+ } else if (t > 1) {
3624
+ t = 1;
3625
+ s = (uv - uw) / uu;
3626
+ }
3627
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
3628
+ let ny = p[c] + vy * t - (p[a] + uy * s);
3629
+ let nz = p[c + 1] + vz * t - (p[a + 1] + uz * s);
3630
+ let dist = Math.hypot(ny, nz);
3631
+ if (dist >= d) return;
3632
+ if (dist < 1e-9) {
3633
+ ny = -uz;
3634
+ nz = uy;
3635
+ dist = Math.hypot(ny, nz);
3636
+ if (dist < 1e-12) return;
3637
+ ny = -ny;
3638
+ nz = -nz;
3639
+ }
3640
+ const push = (d - dist) / dist * COLLISION_RELAXATION;
3641
+ const gy = ny * push;
3642
+ const gz = nz * push;
3643
+ const ki = 0.5 / ((1 - s) * (1 - s) + s * s);
3644
+ const kj = 0.5 / ((1 - t) * (1 - t) + t * t);
3645
+ this.nudge(i, -gy * (1 - s) * ki, -gz * (1 - s) * ki);
3646
+ this.nudge(i + 1, -gy * s * ki, -gz * s * ki);
3647
+ this.nudge(j, gy * (1 - t) * kj, gz * (1 - t) * kj);
3648
+ this.nudge(j + 1, gy * t * kj, gz * t * kj);
3649
+ }
3650
+ /** First free node for the substep in flight — `nudge` runs in the innermost
3651
+ * collision loop and must not recompute it per call. */
3652
+ freeFrom = 0;
3653
+ /** Move a node, unless the roll or the hand owns it. */
3654
+ nudge(i, dy, dz) {
3655
+ if (i === this.grabbed || i < this.freeFrom) return;
3656
+ this.pos[i * 2] = this.pos[i * 2] + dy;
3657
+ this.pos[i * 2 + 1] = this.pos[i * 2 + 1] + dz;
3658
+ }
3659
+ /** Restitution 0, friction high. Paper lands and stays; it does not slide. */
3660
+ solveFloor(firstFree) {
3661
+ const p = this.pos;
3662
+ const floor = -this.params.floor;
3663
+ for (let i = firstFree; i < this.count; i++) {
3664
+ const o = i * 2;
3665
+ if (i === this.grabbed || p[o] >= floor) continue;
3666
+ p[o] = floor;
3667
+ this.prev[o] = floor;
3668
+ this.prev[o + 1] = this.prev[o + 1] + (p[o + 1] - this.prev[o + 1]) * 0.92;
3669
+ }
3670
+ }
3671
+ /**
3672
+ * Write the chain into a 2×N quad strip, in PlaneGeometry vertex order:
3673
+ * row-major, top row first, x left→right. Row `i` is node `i`, so the
3674
+ * content texture runs down the strip and folds with it.
3675
+ */
3676
+ writeInto(out) {
3677
+ const lift = this.centreOffset;
3678
+ const shift = this.centreShift;
3679
+ for (let i = 0; i < this.count; i++) {
3680
+ const o = i * 2;
3681
+ const y = this.pos[o] + lift;
3682
+ const z25 = this.pos[o + 1] + shift;
3683
+ const v = i * 6;
3684
+ out[v] = -this.halfWidth;
3685
+ out[v + 1] = y;
3686
+ out[v + 2] = z25;
3687
+ out[v + 3] = this.halfWidth;
3688
+ out[v + 4] = y;
3689
+ out[v + 5] = z25;
3690
+ }
2385
3691
  }
2386
3692
  };
2387
- function getIdlePreset(name) {
2388
- return idlePresets[name];
2389
- }
2390
3693
 
2391
3694
  // src/scene/lighting.ts
2392
3695
  var lightingPresets = {
@@ -2637,8 +3940,8 @@ function barcodeBars(seed) {
2637
3940
  var money = (v) => v.toFixed(2);
2638
3941
  function paintReceipt(ctx, w, h, content, stock) {
2639
3942
  const ink = stock.inkColor;
2640
- const pad = w * 0.09;
2641
- const colWidth = w - pad * 2;
3943
+ const pad2 = w * 0.09;
3944
+ const colWidth = w - pad2 * 2;
2642
3945
  const base = Math.round(w / 15);
2643
3946
  const mono = (size, weight = 400) => `${weight} ${size}px ui-monospace, Menlo, Consolas, monospace`;
2644
3947
  let y = h * 0.045;
@@ -2651,9 +3954,9 @@ function paintReceipt(ctx, w, h, content, stock) {
2651
3954
  const row = (left, right, size = base) => {
2652
3955
  ctx.font = mono(size);
2653
3956
  ctx.textAlign = "left";
2654
- ctx.fillText(left, pad, y);
3957
+ ctx.fillText(left, pad2, y);
2655
3958
  ctx.textAlign = "right";
2656
- ctx.fillText(right, w - pad, y);
3959
+ ctx.fillText(right, w - pad2, y);
2657
3960
  };
2658
3961
  const divider = () => {
2659
3962
  ctx.font = mono(base);
@@ -3212,9 +4515,9 @@ var NOTE_TRACKING = 0.06;
3212
4515
  function paintCard(ctx, w, h, content, stock, dpr) {
3213
4516
  const ink = content.color === "#2b2620" ? stock.inkColor : content.color;
3214
4517
  const size = content.size * dpr;
3215
- const pad = content.padding * Math.min(w, h);
3216
- const maxWidth = w - pad * 2;
3217
- const x = content.align === "center" ? w / 2 : pad;
4518
+ const pad2 = content.padding * Math.min(w, h);
4519
+ const maxWidth = w - pad2 * 2;
4520
+ const x = content.align === "center" ? w / 2 : pad2;
3218
4521
  ctx.textAlign = content.align === "center" ? "center" : "left";
3219
4522
  ctx.textBaseline = "alphabetic";
3220
4523
  const titleSize = size * TITLE_RATIO;
@@ -3226,7 +4529,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
3226
4529
  const noteBlock = content.note ? noteSize * 2.4 : 0;
3227
4530
  const bodyBlock = bodyLines.length * bodyStep;
3228
4531
  const total = titleBlock + ruleBlock + bodyBlock + noteBlock;
3229
- let y = Math.max(pad, (h - total) / 2) + size * 0.9;
4532
+ let y = Math.max(pad2, (h - total) / 2) + size * 0.9;
3230
4533
  if (content.title) {
3231
4534
  ctx.font = `${titleSize}px ${content.font}`;
3232
4535
  ctx.letterSpacing = `${TITLE_TRACKING}em`;
@@ -3242,8 +4545,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
3242
4545
  ctx.globalAlpha = 0.28;
3243
4546
  ctx.lineWidth = Math.max(1, dpr * 0.75);
3244
4547
  ctx.beginPath();
3245
- ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad, y - titleSize * 0.5);
3246
- ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad + maxWidth, y - titleSize * 0.5);
4548
+ ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad2, y - titleSize * 0.5);
4549
+ ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad2 + maxWidth, y - titleSize * 0.5);
3247
4550
  ctx.stroke();
3248
4551
  ctx.restore();
3249
4552
  y += ruleBlock;
@@ -3257,8 +4560,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
3257
4560
  for (let i = 0; i < bodyLines.length; i++) {
3258
4561
  const lineY = y + i * bodyStep + size * 0.28;
3259
4562
  ctx.beginPath();
3260
- ctx.moveTo(pad, lineY);
3261
- ctx.lineTo(pad + maxWidth, lineY);
4563
+ ctx.moveTo(pad2, lineY);
4564
+ ctx.lineTo(pad2 + maxWidth, lineY);
3262
4565
  ctx.stroke();
3263
4566
  }
3264
4567
  ctx.restore();
@@ -3266,7 +4569,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
3266
4569
  ctx.font = `${size}px ${content.font}`;
3267
4570
  ctx.fillStyle = ink;
3268
4571
  for (const line of bodyLines) {
3269
- if (y > h - pad) break;
4572
+ if (y > h - pad2) break;
3270
4573
  ctx.fillText(line, x, y);
3271
4574
  y += bodyStep;
3272
4575
  }
@@ -3274,7 +4577,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
3274
4577
  ctx.font = `${noteSize}px ${content.font}`;
3275
4578
  ctx.letterSpacing = `${NOTE_TRACKING}em`;
3276
4579
  ctx.globalAlpha = 0.6;
3277
- ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad));
4580
+ ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad2));
3278
4581
  ctx.globalAlpha = 1;
3279
4582
  ctx.letterSpacing = "0em";
3280
4583
  }
@@ -3404,23 +4707,23 @@ function paintImage(ctx, w, h, img, fit) {
3404
4707
  }
3405
4708
  function paintText(ctx, w, h, content, stock) {
3406
4709
  const size = content.size * DPR;
3407
- const pad = content.padding * Math.min(w, h);
4710
+ const pad2 = content.padding * Math.min(w, h);
3408
4711
  const font = `${content.weight} ${size}px ${content.font}`;
3409
4712
  ctx.font = font;
3410
4713
  ctx.fillStyle = content.color === "#2b2620" ? stock.inkColor : content.color;
3411
4714
  ctx.textBaseline = "top";
3412
4715
  ctx.textAlign = content.align;
3413
4716
  ctx.letterSpacing = `${content.tracking}em`;
3414
- const maxWidth = w - pad * 2;
3415
- const x = content.align === "left" ? pad : content.align === "right" ? w - pad : w / 2;
4717
+ const maxWidth = w - pad2 * 2;
4718
+ const x = content.align === "left" ? pad2 : content.align === "right" ? w - pad2 : w / 2;
3416
4719
  const lineStep = size * content.lineHeight;
3417
4720
  const lines = wrapLines(ctx, content.text, maxWidth, font);
3418
4721
  ctx.font = font;
3419
4722
  ctx.letterSpacing = `${content.tracking}em`;
3420
4723
  const block = lines.length * lineStep;
3421
- let y = content.valign === "center" ? Math.max(pad, (h - block) / 2) : pad;
4724
+ let y = content.valign === "center" ? Math.max(pad2, (h - block) / 2) : pad2;
3422
4725
  for (const line of lines) {
3423
- if (y > h - pad) break;
4726
+ if (y > h - pad2) break;
3424
4727
  ctx.fillText(line, x, y);
3425
4728
  y += lineStep;
3426
4729
  }
@@ -3560,15 +4863,51 @@ function take(out, deformer, options, sheet2, demand) {
3560
4863
  if (y > out[1]) out[1] = y;
3561
4864
  }
3562
4865
 
4866
+ // src/surface/creases.ts
4867
+ var MAX_SHADED = 4;
4868
+ function resolveCreases(surface, creases, sheet2) {
4869
+ const out = [];
4870
+ const lines = surface.creaseLines;
4871
+ if (lines) {
4872
+ const angle = lines.angle + 90;
4873
+ const span = spanAlong(sheet2, angle);
4874
+ for (const position of lines.positions) {
4875
+ out.push({ angle, offset: (position - 0.5) * span, strength: lines.strength });
4876
+ }
4877
+ }
4878
+ for (const crease of creases) {
4879
+ out.push(creaseShading(crease));
4880
+ }
4881
+ if (out.length > MAX_SHADED) out.length = MAX_SHADED;
4882
+ return out;
4883
+ }
4884
+ function creaseShading(crease) {
4885
+ return {
4886
+ angle: crease.angle,
4887
+ offset: crease.offset,
4888
+ strength: Math.max(-1, Math.min(1, -crease.depth / (90 * MAX_SET)))
4889
+ };
4890
+ }
4891
+
3563
4892
  // src/physics/cloth.ts
3564
- var FIXED_DT = 1 / 120;
3565
- var SOLVER_ITERATIONS = 5;
3566
- var SLEEP_EPSILON = 1e-6;
3567
- var SLEEP_FRAMES = 45;
4893
+ var FIXED_DT2 = 1 / 120;
4894
+ var SOLVER_ITERATIONS2 = 5;
4895
+ var AERO_TURBULENCE = 0.15;
4896
+ var GRAB_RADIUS = 0.05;
4897
+ var SLEEP_EPSILON2 = 1e-6;
4898
+ var SLEEP_FRAMES2 = 45;
4899
+ function grabFalloff(distance, radius) {
4900
+ const t = Math.min(1, Math.max(0, distance / radius));
4901
+ const s = 1 - t;
4902
+ return s * s * (3 - 2 * s);
4903
+ }
3568
4904
  var ClothSim = class {
3569
4905
  cols;
3570
4906
  rows;
3571
4907
  count;
4908
+ /** The sheet this grid was laid out on. Read by {@link adopt}. */
4909
+ width;
4910
+ height;
3572
4911
  positions;
3573
4912
  prev;
3574
4913
  pinned;
@@ -3579,17 +4918,47 @@ var ClothSim = class {
3579
4918
  accumulator = 0;
3580
4919
  stillFrames = 0;
3581
4920
  grabbedIndex = -1;
4921
+ /**
4922
+ * A normal per particle, refreshed once a frame.
4923
+ *
4924
+ * The sim needs these for one thing only — how much wind each part of the
4925
+ * sheet is actually catching — and it needs its OWN rather than the mesh's,
4926
+ * because the mesh's are computed after the fact by the adapter and a
4927
+ * deformer may be running over the top of them by then. Once a frame rather
4928
+ * than once a substep: paper does not turn far in eight milliseconds, and
4929
+ * this is the only part of the step that is not a constraint solve.
4930
+ */
4931
+ normals;
4932
+ /**
4933
+ * How hard each particle is held, 0..1. Zero for all but the handful under
4934
+ * a hand, so it doubles as the inverse mass the constraint solver wants:
4935
+ * a particle is as immovable as it is held.
4936
+ */
4937
+ grabWeights;
4938
+ /** Just the held ones, so nothing iterates the whole sheet to find six. */
4939
+ grabbed = [];
4940
+ /** Each held particle's offset from the one under the cursor, at grab time. */
4941
+ grabOffsets = new Float32Array(0);
4942
+ /** Where the hand was at the start of the last step, and how fast it moved. */
4943
+ grabAt = new Float32Array(3);
4944
+ grabWas = new Float32Array(3);
4945
+ grabVelocity = new Float32Array(3);
3582
4946
  /** True when the sim has settled and steps are skipped. */
3583
4947
  asleep = false;
3584
4948
  constructor(cols, rows, width, height, pins, params) {
3585
4949
  this.cols = cols;
3586
4950
  this.rows = rows;
3587
4951
  this.count = cols * rows;
4952
+ this.width = width;
4953
+ this.height = height;
3588
4954
  this.params = { ...params };
3589
4955
  this.positions = new Float32Array(this.count * 3);
3590
4956
  this.prev = new Float32Array(this.count * 3);
3591
4957
  this.pinned = new Uint8Array(this.count);
3592
4958
  this.pinTargets = new Float32Array(this.count * 3);
4959
+ this.grabWeights = new Float32Array(this.count);
4960
+ this.normals = new Float32Array(this.count * 3);
4961
+ for (let i = 0; i < this.count; i++) this.normals[i * 3 + 2] = 1;
3593
4962
  for (let r = 0; r < rows; r++) {
3594
4963
  for (let c = 0; c < cols; c++) {
3595
4964
  const i3 = (r * cols + c) * 3;
@@ -3629,6 +4998,54 @@ var ClothSim = class {
3629
4998
  }
3630
4999
  if (pins === "corner") pin(0, 0);
3631
5000
  }
5001
+ /**
5002
+ * Carry a previous sim's drape across a rebuild.
5003
+ *
5004
+ * Sheet dimensions are a GEOMETRY dependency, so changing them builds a new
5005
+ * mesh and a new sim, and a new sim starts flat — which means a draped sheet
5006
+ * snaps rigid the instant it is resized. Nothing about the physics requires
5007
+ * that; it is only that nobody had carried the state over.
5008
+ *
5009
+ * What carries is the FREE particles, scaled by how much the sheet grew:
5010
+ * the constraints' rest lengths are laid out afresh at the new size, so
5011
+ * scaling the drape by the same ratio leaves every constraint exactly as
5012
+ * violated as it was and the sim simply continues. Pinned particles keep
5013
+ * the new layout's own rest positions instead — a pin holds a CORNER, and
5014
+ * the corner is where the resized sheet says it is.
5015
+ *
5016
+ * Refused in one case only: a different grid, because there is no
5017
+ * correspondence between the two sets of particles and the nearest thing to
5018
+ * one would be a guess. Everything else carries — a resize, a change of
5019
+ * pins, a deformer appearing on top. The sheet's state belongs to the sheet,
5020
+ * and none of those is a reason to have never fallen.
5021
+ *
5022
+ * That matters most for the one that is not a resize at all: a shape
5023
+ * arriving over a simulation rebuilds the mesh (the stack has its own
5024
+ * opinion about tessellation) without touching a single thing the physics
5025
+ * knows about. Resetting there would mean the sheet snapped flat the instant
5026
+ * you tried to fold the sheet you were holding, which is the whole point of
5027
+ * being able to.
5028
+ *
5029
+ * Returns whether it took.
5030
+ */
5031
+ adopt(previous) {
5032
+ if (!previous || previous.cols !== this.cols || previous.rows !== this.rows) return false;
5033
+ const sx = previous.width > 0 ? this.width / previous.width : 1;
5034
+ const sy = previous.height > 0 ? this.height / previous.height : 1;
5035
+ const sz = (sx + sy) / 2;
5036
+ for (let i = 0; i < this.count; i++) {
5037
+ if (this.pinned[i]) continue;
5038
+ const i3 = i * 3;
5039
+ this.positions[i3] = previous.positions[i3] * sx;
5040
+ this.positions[i3 + 1] = previous.positions[i3 + 1] * sy;
5041
+ this.positions[i3 + 2] = previous.positions[i3 + 2] * sz;
5042
+ this.prev[i3] = previous.prev[i3] * sx;
5043
+ this.prev[i3 + 1] = previous.prev[i3 + 1] * sy;
5044
+ this.prev[i3 + 2] = previous.prev[i3 + 2] * sz;
5045
+ }
5046
+ this.wake();
5047
+ return true;
5048
+ }
3632
5049
  setParams(params) {
3633
5050
  let changed = false;
3634
5051
  for (const key of ["stiffness", "gravity", "wind", "floor"]) {
@@ -3644,6 +5061,69 @@ var ClothSim = class {
3644
5061
  this.asleep = false;
3645
5062
  this.stillFrames = 0;
3646
5063
  }
5064
+ /**
5065
+ * Take hold of the sheet at one particle.
5066
+ *
5067
+ * Separate from {@link grabNearest} because the particle a hand grabbed is
5068
+ * not always the particle nearest the point it touched: with a deformer
5069
+ * running over the simulation, what the pointer hit was a RENDERED vertex,
5070
+ * and the vertex it hit is the particle of the same index — the stack maps
5071
+ * a point to a point and never reorders them.
5072
+ *
5073
+ * What is taken hold of is a PATCH around that particle, not the particle
5074
+ * alone — see {@link GRAB_RADIUS}. The patch is measured across the GRID
5075
+ * rather than through space, because a hand holds a piece of the sheet and
5076
+ * keeps holding the same piece: measured through space, a fold that brought
5077
+ * a far corner near the fingers would silently add it to the grip.
5078
+ */
5079
+ grab(index) {
5080
+ this.grabbedIndex = index >= 0 && index < this.count ? index : -1;
5081
+ this.grabWeights.fill(0);
5082
+ this.grabbed = [];
5083
+ if (this.grabbedIndex < 0) {
5084
+ this.grabOffsets = new Float32Array(0);
5085
+ this.wake();
5086
+ return -1;
5087
+ }
5088
+ const cellX = this.cols > 1 ? this.width / (this.cols - 1) : this.width;
5089
+ const cellY = this.rows > 1 ? this.height / (this.rows - 1) : this.height;
5090
+ const cell = Math.max(cellX, cellY, 1e-6);
5091
+ const radius = Math.min(
5092
+ Math.max(GRAB_RADIUS, cell * 1.5),
5093
+ Math.max(cell, Math.min(this.width, this.height) * 0.2)
5094
+ );
5095
+ const centreRow = Math.floor(this.grabbedIndex / this.cols);
5096
+ const centreCol = this.grabbedIndex % this.cols;
5097
+ const spanRows = Math.ceil(radius / cellY);
5098
+ const spanCols = Math.ceil(radius / cellX);
5099
+ const offsets = [];
5100
+ const anchor = this.grabbedIndex * 3;
5101
+ for (let r = centreRow - spanRows; r <= centreRow + spanRows; r++) {
5102
+ if (r < 0 || r >= this.rows) continue;
5103
+ for (let c = centreCol - spanCols; c <= centreCol + spanCols; c++) {
5104
+ if (c < 0 || c >= this.cols) continue;
5105
+ const i = r * this.cols + c;
5106
+ if (this.pinned[i]) continue;
5107
+ const distance = Math.hypot((c - centreCol) * cellX, (r - centreRow) * cellY);
5108
+ const weight = i === this.grabbedIndex ? 1 : grabFalloff(distance, radius);
5109
+ if (weight <= 0) continue;
5110
+ this.grabWeights[i] = weight;
5111
+ this.grabbed.push(i);
5112
+ const i3 = i * 3;
5113
+ offsets.push(
5114
+ this.positions[i3] - this.positions[anchor],
5115
+ this.positions[i3 + 1] - this.positions[anchor + 1],
5116
+ this.positions[i3 + 2] - this.positions[anchor + 2]
5117
+ );
5118
+ }
5119
+ }
5120
+ this.grabOffsets = Float32Array.from(offsets);
5121
+ this.grabAt.set(this.positions.subarray(anchor, anchor + 3));
5122
+ this.grabWas.set(this.grabAt);
5123
+ this.grabVelocity.fill(0);
5124
+ this.wake();
5125
+ return this.grabbedIndex;
5126
+ }
3647
5127
  /** Nearest particle to a local-space point — the grab interface. */
3648
5128
  grabNearest(x, y, z25) {
3649
5129
  let best = -1;
@@ -3658,30 +5138,108 @@ var ClothSim = class {
3658
5138
  best = i;
3659
5139
  }
3660
5140
  }
3661
- this.grabbedIndex = best;
3662
- this.wake();
3663
- return best;
5141
+ return this.grab(best);
5142
+ }
5143
+ /** How hard one particle is being held, 0..1 — for tests and for the adapter. */
5144
+ grabWeightAt(index) {
5145
+ return this.grabWeights[index] ?? 0;
3664
5146
  }
5147
+ /** Where the fingers are now. The patch follows, each particle by its weight. */
3665
5148
  moveGrab(x, y, z25) {
3666
5149
  if (this.grabbedIndex < 0) return;
3667
- const i3 = this.grabbedIndex * 3;
3668
- this.positions[i3] = x;
3669
- this.positions[i3 + 1] = y;
3670
- this.positions[i3 + 2] = z25;
3671
- this.prev[i3] = x;
3672
- this.prev[i3 + 1] = y;
3673
- this.prev[i3 + 2] = z25;
5150
+ this.grabAt[0] = x;
5151
+ this.grabAt[1] = y;
5152
+ this.grabAt[2] = z25;
3674
5153
  this.wake();
3675
5154
  }
5155
+ /**
5156
+ * Let go — and let go at SPEED.
5157
+ *
5158
+ * This used to drop the paper dead. Every held particle had its previous
5159
+ * position overwritten with its current one on the way past, and in a verlet
5160
+ * integrator the gap between those two IS the velocity, so a sheet whipped
5161
+ * across the frame and released came to a perfect standstill and then fell
5162
+ * straight down. Whatever you did with your hand, the paper had never heard
5163
+ * of it.
5164
+ *
5165
+ * The hand's velocity is measured per second (see {@link step}) and spent
5166
+ * here, converted into the one-substep gap the integrator reads it back out
5167
+ * of. Measured per second rather than per frame because a frame is not a
5168
+ * fixed length and a substep is: throwing the same sheet at the same speed
5169
+ * must not depend on what the frame rate happened to be.
5170
+ */
3676
5171
  release() {
5172
+ for (const i of this.grabbed) {
5173
+ const i3 = i * 3;
5174
+ const held = this.grabWeights[i];
5175
+ this.prev[i3] = this.positions[i3] - this.grabVelocity[0] * FIXED_DT2 * held;
5176
+ this.prev[i3 + 1] = this.positions[i3 + 1] - this.grabVelocity[1] * FIXED_DT2 * held;
5177
+ this.prev[i3 + 2] = this.positions[i3 + 2] - this.grabVelocity[2] * FIXED_DT2 * held;
5178
+ }
3677
5179
  this.grabbedIndex = -1;
5180
+ this.grabbed = [];
5181
+ this.grabWeights.fill(0);
5182
+ this.grabVelocity.fill(0);
5183
+ this.wake();
3678
5184
  }
3679
5185
  step(delta) {
3680
5186
  if (this.asleep) return;
3681
- this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
3682
- while (this.accumulator >= FIXED_DT) {
3683
- this.substep(FIXED_DT);
3684
- this.accumulator -= FIXED_DT;
5187
+ if (this.grabbedIndex >= 0 && delta > 0) {
5188
+ for (let axis = 0; axis < 3; axis++) {
5189
+ this.grabVelocity[axis] = (this.grabAt[axis] - this.grabWas[axis]) / delta;
5190
+ }
5191
+ this.grabWas.set(this.grabAt);
5192
+ }
5193
+ this.updateNormals();
5194
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT2 * 4);
5195
+ while (this.accumulator >= FIXED_DT2) {
5196
+ this.substep(FIXED_DT2);
5197
+ this.accumulator -= FIXED_DT2;
5198
+ }
5199
+ }
5200
+ /**
5201
+ * Which way each part of the sheet is facing, by central difference across
5202
+ * the grid.
5203
+ *
5204
+ * Clamped at the edges rather than wrapped or skipped: an edge particle
5205
+ * takes the one-sided difference, which is the same normal its neighbour
5206
+ * has, and an edge with no normal is an edge the wind cannot push.
5207
+ *
5208
+ * Which SIDE the normal points at is deliberately not worried about. The
5209
+ * force below is `n (n·v)`, a quadratic form, and that is unchanged by
5210
+ * flipping n — which is exactly right for paper, a surface with no front as
5211
+ * far as the air is concerned.
5212
+ */
5213
+ updateNormals() {
5214
+ const p = this.positions;
5215
+ const n = this.normals;
5216
+ for (let r = 0; r < this.rows; r++) {
5217
+ for (let c = 0; c < this.cols; c++) {
5218
+ const left = (r * this.cols + (c > 0 ? c - 1 : c)) * 3;
5219
+ const right = (r * this.cols + (c < this.cols - 1 ? c + 1 : c)) * 3;
5220
+ const up = ((r > 0 ? r - 1 : r) * this.cols + c) * 3;
5221
+ const down = ((r < this.rows - 1 ? r + 1 : r) * this.cols + c) * 3;
5222
+ const ux = p[right] - p[left];
5223
+ const uy = p[right + 1] - p[left + 1];
5224
+ const uz = p[right + 2] - p[left + 2];
5225
+ const vx = p[down] - p[up];
5226
+ const vy = p[down + 1] - p[up + 1];
5227
+ const vz = p[down + 2] - p[up + 2];
5228
+ const nx = vy * uz - vz * uy;
5229
+ const ny = vz * ux - vx * uz;
5230
+ const nz = vx * uy - vy * ux;
5231
+ const length = Math.sqrt(nx * nx + ny * ny + nz * nz);
5232
+ const i3 = (r * this.cols + c) * 3;
5233
+ if (length > 1e-12) {
5234
+ n[i3] = nx / length;
5235
+ n[i3 + 1] = ny / length;
5236
+ n[i3 + 2] = nz / length;
5237
+ } else {
5238
+ n[i3] = 0;
5239
+ n[i3 + 1] = 0;
5240
+ n[i3 + 2] = 0;
5241
+ }
5242
+ }
3685
5243
  }
3686
5244
  }
3687
5245
  substep(dt) {
@@ -3693,12 +5251,10 @@ var ClothSim = class {
3693
5251
  let maxTravel = 0;
3694
5252
  for (let i = 0; i < this.count; i++) {
3695
5253
  const i3 = i * 3;
3696
- if (this.pinned[i] || i === this.grabbedIndex) {
3697
- if (this.pinned[i]) {
3698
- p[i3] = this.pinTargets[i3];
3699
- p[i3 + 1] = this.pinTargets[i3 + 1];
3700
- p[i3 + 2] = this.pinTargets[i3 + 2];
3701
- }
5254
+ if (this.pinned[i]) {
5255
+ p[i3] = this.pinTargets[i3];
5256
+ p[i3 + 1] = this.pinTargets[i3 + 1];
5257
+ p[i3 + 2] = this.pinTargets[i3 + 2];
3702
5258
  this.prev[i3] = p[i3];
3703
5259
  this.prev[i3 + 1] = p[i3 + 1];
3704
5260
  this.prev[i3 + 2] = p[i3 + 2];
@@ -3707,21 +5263,39 @@ var ClothSim = class {
3707
5263
  const x = p[i3];
3708
5264
  const y = p[i3 + 1];
3709
5265
  const z25 = p[i3 + 2];
3710
- const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
3711
- const ax = gust2 * 0.25;
3712
- const az = gust2;
3713
5266
  const vx = (x - this.prev[i3]) * damping;
3714
5267
  const vy = (y - this.prev[i3 + 1]) * damping;
3715
5268
  const vz = (z25 - this.prev[i3 + 2]) * damping;
5269
+ const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
5270
+ const relX = gust2 * 0.25 - vx / dt;
5271
+ const relY = -vy / dt;
5272
+ const relZ = gust2 - vz / dt;
5273
+ const nx = this.normals[i3];
5274
+ const ny = this.normals[i3 + 1];
5275
+ const nz = this.normals[i3 + 2];
5276
+ const facing = nx * relX + ny * relY + nz * relZ;
5277
+ const ax = nx * facing * (1 - AERO_TURBULENCE) + gust2 * 0.25 * AERO_TURBULENCE;
5278
+ const ay = ny * facing * (1 - AERO_TURBULENCE);
5279
+ const az = nz * facing * (1 - AERO_TURBULENCE) + gust2 * AERO_TURBULENCE;
3716
5280
  this.prev[i3] = x;
3717
5281
  this.prev[i3 + 1] = y;
3718
5282
  this.prev[i3 + 2] = z25;
3719
5283
  p[i3] = x + vx + ax * dt2;
3720
- p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
5284
+ p[i3 + 1] = y + vy + (ay - gravity * 3.2) * dt2;
3721
5285
  p[i3 + 2] = z25 + vz + az * dt2;
3722
5286
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
3723
5287
  }
3724
- for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
5288
+ for (let k = 0; k < this.grabbed.length; k++) {
5289
+ const i = this.grabbed[k];
5290
+ const i3 = i * 3;
5291
+ const k3 = k * 3;
5292
+ const held = this.grabWeights[i];
5293
+ for (let axis = 0; axis < 3; axis++) {
5294
+ const target = this.grabAt[axis] + this.grabOffsets[k3 + axis];
5295
+ p[i3 + axis] = p[i3 + axis] + (target - p[i3 + axis]) * held;
5296
+ }
5297
+ }
5298
+ for (let iter = 0; iter < SOLVER_ITERATIONS2; iter++) {
3725
5299
  for (const c of this.constraints) {
3726
5300
  const k = c.kind === 2 ? 0.25 + stiffness * 0.7 : c.kind === 1 ? 0.85 : 1;
3727
5301
  const a3 = c.a * 3;
@@ -3731,12 +5305,13 @@ var ClothSim = class {
3731
5305
  const dz = p[b3 + 2] - p[a3 + 2];
3732
5306
  const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
3733
5307
  if (dist === 0) continue;
3734
- const diff = (dist - c.rest) / dist * 0.5 * k;
3735
- const aPinned = this.pinned[c.a] || c.a === this.grabbedIndex;
3736
- const bPinned = this.pinned[c.b] || c.b === this.grabbedIndex;
3737
- if (aPinned && bPinned) continue;
3738
- const aw = aPinned ? 0 : bPinned ? 2 : 1;
3739
- const bw = bPinned ? 0 : aPinned ? 2 : 1;
5308
+ const ma = this.pinned[c.a] ? 0 : 1 - this.grabWeights[c.a];
5309
+ const mb = this.pinned[c.b] ? 0 : 1 - this.grabWeights[c.b];
5310
+ const total = ma + mb;
5311
+ if (total <= 0) continue;
5312
+ const diff = (dist - c.rest) / dist / total * k;
5313
+ const aw = ma;
5314
+ const bw = mb;
3740
5315
  p[a3] = p[a3] + dx * diff * aw;
3741
5316
  p[a3 + 1] = p[a3 + 1] + dy * diff * aw;
3742
5317
  p[a3 + 2] = p[a3 + 2] + dz * diff * aw;
@@ -3754,8 +5329,8 @@ var ClothSim = class {
3754
5329
  }
3755
5330
  }
3756
5331
  if (wind === 0 && this.grabbedIndex < 0) {
3757
- if (maxTravel < SLEEP_EPSILON) {
3758
- if (++this.stillFrames > SLEEP_FRAMES) this.asleep = true;
5332
+ if (maxTravel < SLEEP_EPSILON2) {
5333
+ if (++this.stillFrames > SLEEP_FRAMES2) this.asleep = true;
3759
5334
  } else {
3760
5335
  this.stillFrames = 0;
3761
5336
  }
@@ -3844,6 +5419,7 @@ function translucencyUniforms(translucency, lighting) {
3844
5419
  }
3845
5420
 
3846
5421
  // src/surface/compose.ts
5422
+ var SHADED_CREASE = 0.35;
3847
5423
  var VERTEX = (
3848
5424
  /* glsl */
3849
5425
  `
@@ -3860,6 +5436,89 @@ var HELPERS = (
3860
5436
  `
3861
5437
  varying vec2 vPaperUv;
3862
5438
  uniform float uBackDarken;
5439
+ uniform vec2 uSheetSize;
5440
+
5441
+ /**
5442
+ * Where this fragment is on the sheet, in the sheet's OWN local space \u2014
5443
+ * the same coordinates the deformers displace, centred on the sheet.
5444
+ *
5445
+ * Every effect below measures in these rather than in UV, and the difference
5446
+ * is not cosmetic. UV divides the sheet's aspect out, so a 1.2 x 1.5 sheet is
5447
+ * a unit square as far as the shader is concerned: fibre drawn round comes out
5448
+ * stretched, a tear bites deeper into the short edge than the long one, and a
5449
+ * crease line scored at 45 degrees renders at 51. Worse, all three change when
5450
+ * the sheet is RESIZED, which makes the paper's own material a function of how
5451
+ * big the piece is. Grain is a property of the stock and a crease is a broken
5452
+ * fibre; neither knows how large a sheet it was cut from.
5453
+ */
5454
+ vec2 plLocal() {
5455
+ return (vPaperUv - 0.5) * uSheetSize;
5456
+ }
5457
+
5458
+ /**
5459
+ * The paper's relief, in world units above the sheet the mesh describes.
5460
+ *
5461
+ * Accumulated by whichever effects have a shape as well as a colour, and
5462
+ * spent once at the end of main by {@link plPerturb}. One shared field rather
5463
+ * than a perturbation per effect, because two effects that both tilt the
5464
+ * surface tilt it TOGETHER \u2014 a crease across a grained sheet is one surface,
5465
+ * not a crease lit on top of a grain lit on top of the paper.
5466
+ */
5467
+ float plHeight;
5468
+
5469
+ /**
5470
+ * The relief, turned into the normal the lighting actually runs on.
5471
+ *
5472
+ * This is the change that makes the surface effects respond to light at all.
5473
+ * They used to be painted: a crease multiplied a grey band into the albedo
5474
+ * and added a fixed white sheen beside it, so the mark looked identical from
5475
+ * every angle and under every rig, and turning the sheet under the key light
5476
+ * did nothing to it. Real creased paper is two facets meeting at a line \u2014
5477
+ * swing it and the crease flips from a dark line to a bright one. Only a
5478
+ * normal can do that, so now the effects describe a HEIGHT and the standard
5479
+ * material lights it.
5480
+ *
5481
+ * The maths is Mikkelsen's surface-gradient bump, which is what three's own
5482
+ * perturbNormalArb implements, with one deliberate difference: three
5483
+ * normalises the screen-space position derivatives, which makes a bump map
5484
+ * look the same at any scale and is the right call for a texture. Ours is a
5485
+ * real depth in world units \u2014 a crease is as deep as it is however close you
5486
+ * stand \u2014 so the raw derivatives stay, and the ratio between them and the
5487
+ * height's is a true surface slope.
5488
+ *
5489
+ * Analytic height plus screen derivatives also anti-aliases itself for free:
5490
+ * as a crease shrinks below a pixel the derivative flattens and the mark
5491
+ * fades, rather than crawling.
5492
+ */
5493
+ vec3 plPerturb(vec3 n, float height) {
5494
+ vec2 dH = vec2(dFdx(height), dFdy(height));
5495
+ if (dH.x == 0.0 && dH.y == 0.0) return n;
5496
+ // View-space position: the varying is its negation, by three's convention.
5497
+ vec3 sigmaX = dFdx(-vViewPosition);
5498
+ vec3 sigmaY = dFdy(-vViewPosition);
5499
+ vec3 r1 = cross(sigmaY, n);
5500
+ vec3 r2 = cross(n, sigmaX);
5501
+ float det = dot(sigmaX, r1) * (gl_FrontFacing ? 1.0 : -1.0);
5502
+ if (abs(det) < 1e-12) return n;
5503
+ vec3 grad = sign(det) * (dH.x * r1 + dH.y * r2);
5504
+ return normalize(abs(det) * n - grad);
5505
+ }
5506
+
5507
+ /**
5508
+ * A gaussian bell of unit width, pre-filtered against this fragment's own
5509
+ * footprint.
5510
+ *
5511
+ * Convolving a gaussian with the pixel broadens it and flattens it by the
5512
+ * same factor, which conserves the integral: a crease seen from across the
5513
+ * room dims instead of breaking into a dotted line. s is the distance
5514
+ * across the feature in units of its own width, so a caller only ever has to
5515
+ * decide how wide the thing is.
5516
+ */
5517
+ float plBell(float s) {
5518
+ float px = fwidth(s);
5519
+ float widen = sqrt(1.0 + px * px);
5520
+ return exp(-(s * s) / (widen * widen)) / widen;
5521
+ }
3863
5522
 
3864
5523
  float plHash(vec2 p) {
3865
5524
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
@@ -3900,12 +5559,54 @@ var GRAIN_CHUNK = (
3900
5559
  uniform float uGrainAmount;
3901
5560
  uniform float uGrainBanding;
3902
5561
 
5562
+ /**
5563
+ * Fibre density, per world unit.
5564
+ *
5565
+ * Per WORLD UNIT and not per UV, which is the whole fix: the fibre in a sheet
5566
+ * is the stock's, and it does not get coarser because someone cut a bigger
5567
+ * piece or stretch oval because the piece is taller than it is wide.
5568
+ *
5569
+ * The number is carried over from when it was a UV frequency, so a sheet one
5570
+ * world unit wide is unchanged across its width. Its height is not, and that
5571
+ * is the point: a 1.4-tall sheet used to fit the same 240 cycles into a
5572
+ * longer span and its fibre ran visibly coarser the other way.
5573
+ */
5574
+ const float PL_FIBRE = 240.0;
5575
+
5576
+ /**
5577
+ * The coarser structure underneath it \u2014 paper's tooth, the part that has a
5578
+ * SHAPE and not just a colour.
5579
+ *
5580
+ * Separate from the fibre, and much lower, for a reason worth writing down:
5581
+ * the relief is differentiated in screen space, and a field at the fibre's own
5582
+ * frequency is a few pixels per cycle at any sane viewing distance, so its
5583
+ * derivative is noise and the sheet would sparkle. The tooth is safely above
5584
+ * the sampling rate, and it is the scale at which paper actually catches a
5585
+ * raking light anyway.
5586
+ */
5587
+ const float PL_TOOTH = 70.0;
5588
+
5589
+ /**
5590
+ * How far the tooth stands proud, in world units, at full grain.
5591
+ *
5592
+ * Real paper's surface relief is tens of microns. Against a sheet whose width
5593
+ * is one world unit \u2014 call it A4 \u2014 0.00035 is about 70 microns, and at the
5594
+ * tooth's wavelength that is a surface tilting by four degrees or so. Enough
5595
+ * to break a specular highlight into paper, not enough to look pebbled.
5596
+ */
5597
+ const float PL_TOOTH_RELIEF = 0.00035;
5598
+
3903
5599
  void plGrain(inout vec4 color, inout float rough) {
3904
- float fiber = plFbm(vPaperUv * 240.0);
3905
- float fleck = plNoise(vPaperUv * 900.0);
5600
+ vec2 local = plLocal();
5601
+ float fiber = plFbm(local * PL_FIBRE);
5602
+ float fleck = plNoise(local * (PL_FIBRE * 3.75));
3906
5603
  float g = mix(0.5, fiber * 0.75 + fleck * 0.25, uGrainAmount);
3907
5604
  color.rgb *= 0.92 + g * 0.16;
3908
5605
  rough = clamp(rough + (g - 0.5) * uGrainAmount * 0.35, 0.0, 1.0);
5606
+ // The tooth, handed to the lighting rather than drawn. A single octave: the
5607
+ // relief only needs the scale the eye reads as texture, and the fbm above
5608
+ // is already carrying everything finer as colour.
5609
+ plHeight += (plNoise(local * PL_TOOTH) - 0.5) * PL_TOOTH_RELIEF * uGrainAmount;
3909
5610
  // Thermal-printer banding: faint horizontal density stripes.
3910
5611
  if (uGrainBanding > 0.0) {
3911
5612
  float band = sin(vPaperUv.y * 700.0) * 0.5 + 0.5;
@@ -3920,18 +5621,33 @@ var DECKLE_CHUNK = (
3920
5621
  uniform vec4 uDeckleEdges; // top, right, bottom, left
3921
5622
  uniform float uDeckleRoughness;
3922
5623
 
5624
+ /** Gnaw frequency along a torn edge, per world unit \u2014 see {@link plLocal}. */
5625
+ const float PL_DECKLE_GNAW = 26.0;
5626
+
3923
5627
  void plDeckle(inout vec4 color) {
3924
5628
  // Distance to each selected edge, gnawed by low-frequency noise.
3925
- float depth = 0.012 + uDeckleRoughness * 0.05;
5629
+ //
5630
+ // The depth is in world units, taken against the sheet's mean dimension.
5631
+ // Against the MEAN rather than each edge's own span, which is what UV
5632
+ // amounted to: one roughness used to bite a third deeper into the short
5633
+ // edges of a 1 x 1.4 sheet than the long ones, for no reason anybody chose.
5634
+ //
5635
+ // Still proportional to the sheet rather than absolute, which is a decision
5636
+ // and not an oversight. A real deckle is a fibre length and would be the
5637
+ // same depth on any size of sheet; roughness is a 0..1 knob someone types,
5638
+ // and an absolute one would vanish on a poster and swallow a stamp.
5639
+ float depth = (0.012 + uDeckleRoughness * 0.05) * (uSheetSize.x + uSheetSize.y) * 0.5;
3926
5640
  float tear = 1.0;
3927
5641
  float fiberBand = 0.0;
3928
5642
  vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3929
5643
  vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
5644
+ vec4 distScale = vec4(uSheetSize.y, uSheetSize.x, uSheetSize.y, uSheetSize.x);
5645
+ vec4 alongScale = vec4(uSheetSize.x, uSheetSize.y, uSheetSize.x, uSheetSize.y);
3930
5646
  for (int e = 0; e < 4; e++) {
3931
5647
  if (uDeckleEdges[e] < 0.5) continue;
3932
- float n = plFbm(vec2(alongs[e] * 26.0, float(e) * 7.31)) - 0.5;
5648
+ float n = plFbm(vec2(alongs[e] * alongScale[e] * PL_DECKLE_GNAW, float(e) * 7.31)) - 0.5;
3933
5649
  float boundary = depth * (0.55 + n * 1.6);
3934
- float d = dists[e] - boundary;
5650
+ float d = dists[e] * distScale[e] - boundary;
3935
5651
  tear = min(tear, step(0.0, d));
3936
5652
  // Lightened fiber band just inside the tear.
3937
5653
  fiberBand = max(fiberBand, smoothstep(depth * 1.4, 0.0, d) * step(0.0, d));
@@ -3944,23 +5660,55 @@ void plDeckle(inout vec4 color) {
3944
5660
  var CREASE_CHUNK = (
3945
5661
  /* glsl */
3946
5662
  `
3947
- uniform float uCreaseAngle;
3948
- uniform float uCreaseStrength;
3949
- uniform float uCreasePositions[4];
5663
+ uniform float uCreaseAngles[4];
5664
+ uniform float uCreaseStrengths[4];
5665
+ uniform float uCreaseOffsets[4];
5666
+ uniform float uCreaseWidth;
3950
5667
  uniform int uCreaseCount;
3951
5668
 
5669
+ /**
5670
+ * Peak tilt of a crease's own facets, as a slope.
5671
+ *
5672
+ * A gaussian groove of amplitude A and width w reaches a maximum slope of
5673
+ * about 0.86 A/w, so an amplitude of 0.55 w peaks near 25 degrees \u2014 steep
5674
+ * enough that turning the sheet visibly flips the line from dark to bright,
5675
+ * shallow enough that it never reads as a fold in its own right. Held as a
5676
+ * SLOPE rather than a depth because that is the quantity the lighting
5677
+ * responds to, and the only one that stays honest when the width changes.
5678
+ */
5679
+ const float PL_CREASE_TILT = 0.55;
5680
+
5681
+ /** How much grime a crease traps, at full strength. */
5682
+ const float PL_CREASE_SOIL = 0.1;
5683
+
3952
5684
  void plCrease(inout vec4 color, inout float rough) {
3953
- vec2 dir = vec2(cos(uCreaseAngle), sin(uCreaseAngle));
3954
- // Coordinate across the crease lines (0..1 over the sheet).
3955
- float t = dot(vPaperUv - 0.5, vec2(-dir.y, dir.x)) + 0.5;
5685
+ vec2 p = plLocal();
3956
5686
  for (int i = 0; i < 4; i++) {
3957
5687
  if (i >= uCreaseCount) break;
3958
- float d = abs(t - uCreasePositions[i]);
3959
- float shadow = smoothstep(0.014, 0.0, d);
3960
- float sheen = smoothstep(0.02, 0.006, d) - smoothstep(0.006, 0.0, d);
3961
- color.rgb *= 1.0 - shadow * uCreaseStrength * 0.28;
3962
- color.rgb += sheen * uCreaseStrength * 0.05;
3963
- rough = clamp(rough + shadow * uCreaseStrength * 0.2, 0.0, 1.0);
5688
+ vec2 dir = vec2(cos(uCreaseAngles[i]), sin(uCreaseAngles[i]));
5689
+ // The identical measurement the fold deformer displaces by: signed
5690
+ // distance across the line, in the sheet's own space. Shading and
5691
+ // geometry cannot place a crease differently when the number they place
5692
+ // it by is the same number.
5693
+ float s = (dot(p, dir) - uCreaseOffsets[i]) / uCreaseWidth;
5694
+ float strength = uCreaseStrengths[i];
5695
+ float bell = plBell(s);
5696
+
5697
+ // The relief. This is the fine burnished line where the fibres broke, and
5698
+ // it is deliberately narrower than the hinge the fold deformer bends
5699
+ // over: the mesh carries the wide bend, the shader carries the crease
5700
+ // inside it, and the two add up instead of competing. Signed, so a
5701
+ // mountain stands proud and a valley cuts in \u2014 the same crease read from
5702
+ // the other side of the sheet is the other one.
5703
+ plHeight += strength * uCreaseWidth * PL_CREASE_TILT * bell;
5704
+
5705
+ // What is left for the albedo once the lighting is doing the work: a
5706
+ // crease collects dirt and its broken fibres scatter wider. The grey band
5707
+ // and the painted-on sheen that used to live here were standing in for a
5708
+ // normal, and there is one now.
5709
+ float mark = bell * abs(strength);
5710
+ color.rgb *= 1.0 - mark * PL_CREASE_SOIL;
5711
+ rough = clamp(rough + mark * 0.3, 0.0, 1.0);
3964
5712
  }
3965
5713
  }
3966
5714
  `
@@ -3972,7 +5720,6 @@ uniform vec4 uPerfEdges; // top, right, bottom, left enabled
3972
5720
  uniform vec4 uPerfTorn; // 1 = ripped-through profile, 0 = clean punches
3973
5721
  uniform float uPerfRadius; // world units
3974
5722
  uniform float uPerfSpacing;
3975
- uniform vec2 uSheetSize;
3976
5723
 
3977
5724
  void plPerforation(inout vec4 color) {
3978
5725
  // Per-edge distance/along coordinates, converted from UV to world units so
@@ -4023,11 +5770,10 @@ void plAging(inout vec4 color) {
4023
5770
  }
4024
5771
  `
4025
5772
  );
4026
- function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
5773
+ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio", creases = resolveCreases(surface, [], sheet2)) {
4027
5774
  const grain = surface.grain ?? stock.defaultSurface.grain;
4028
5775
  const aging = surface.aging ?? stock.defaultSurface.aging;
4029
5776
  const deckle = surface.deckle;
4030
- const creases = surface.creaseLines;
4031
5777
  const perforation = surface.perforation;
4032
5778
  const banding = stock.banding;
4033
5779
  const showThrough = stock.adhesive ? 0 : surface.showThrough ?? stock.showThrough;
@@ -4040,6 +5786,9 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
4040
5786
  value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
4041
5787
  },
4042
5788
  uStockColor: { value: new THREE5.Color(stock.color) },
5789
+ // Always present, not just when something asks for it: every effect that
5790
+ // measures anything measures in the sheet's own space now — see plLocal.
5791
+ uSheetSize: { value: new THREE5.Vector2(sheet2.width, sheet2.height) },
4043
5792
  uOpacity: { value: stock.opacity },
4044
5793
  uShowThrough: { value: showThrough },
4045
5794
  // Always compiled in: the shader early-outs at zero translucency, which
@@ -4072,21 +5821,22 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
4072
5821
  };
4073
5822
  uniforms.uPerfRadius = { value: perforation.holeRadius };
4074
5823
  uniforms.uPerfSpacing = { value: perforation.spacing };
4075
- uniforms.uSheetSize = { value: new THREE5.Vector2(sheet2.width, sheet2.height) };
4076
5824
  }
4077
- if (creases) {
5825
+ if (creases.length > 0) {
4078
5826
  chunks.push(CREASE_CHUNK);
4079
5827
  calls.push("plCrease(csm_DiffuseColor, csm_Roughness);");
4080
- uniforms.uCreaseAngle = { value: creases.angle * Math.PI / 180 };
4081
- uniforms.uCreaseStrength = { value: creases.strength };
4082
- uniforms.uCreasePositions = { value: padPositions(creases.positions) };
4083
- uniforms.uCreaseCount = { value: Math.min(creases.positions.length, 4) };
5828
+ uniforms.uCreaseAngles = { value: pad(creases.map((c) => c.angle * Math.PI / 180)) };
5829
+ uniforms.uCreaseStrengths = { value: pad(creases.map((c) => c.strength)) };
5830
+ uniforms.uCreaseOffsets = { value: pad(creases.map((c) => c.offset)) };
5831
+ uniforms.uCreaseWidth = { value: CREASE_RADIUS * SHADED_CREASE };
5832
+ uniforms.uCreaseCount = { value: Math.min(creases.length, 4) };
4084
5833
  }
4085
5834
  if (aging !== void 0) {
4086
5835
  chunks.push(AGING_CHUNK);
4087
5836
  calls.push("plAging(csm_DiffuseColor);");
4088
5837
  uniforms.uAgingAmount = { value: aging };
4089
5838
  }
5839
+ const relief = grain !== void 0 || creases.length > 0;
4090
5840
  const frontExpr = maps.hasFrontMap ? "texture2D(uFrontMap, vPaperUv).rgb" : "uStockColor";
4091
5841
  const backBaseExpr = stock.adhesive ? "vec3(0.965, 0.96, 0.945)" : maps.hasBackMap ? "texture2D(uBackMap, vec2(1.0 - vPaperUv.x, vPaperUv.y)).rgb" : "uStockColor";
4092
5842
  const fragmentShader = (
@@ -4101,6 +5851,7 @@ ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
4101
5851
  ${TRANSLUCENCY_FRAGMENT}
4102
5852
  ${chunks.join("\n")}
4103
5853
  void main() {
5854
+ plHeight = 0.0;
4104
5855
  vec3 front = ${frontExpr};
4105
5856
  if (gl_FrontFacing) {
4106
5857
  csm_DiffuseColor = vec4(front, uOpacity);
@@ -4109,6 +5860,7 @@ void main() {
4109
5860
  csm_DiffuseColor = vec4(backBase * mix(vec3(1.0), front, uShowThrough), uOpacity);
4110
5861
  }
4111
5862
  ${calls.join("\n ")}
5863
+ ${relief ? " // The relief every effect above described, spent once \u2014 see plPerturb.\n csm_FragNormal = plPerturb(csm_FragNormal, plHeight);" : ""}
4112
5864
  if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
4113
5865
  ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
4114
5866
  // What the key light pushes through the sheet, filtered by the ink on it.
@@ -4120,7 +5872,7 @@ void main() {
4120
5872
  structureKey: `${[
4121
5873
  grain !== void 0 || banding > 0 ? "g" : "",
4122
5874
  deckle ? "d" : "",
4123
- creases ? "c" : "",
5875
+ creases.length > 0 ? "c" : "",
4124
5876
  aging !== void 0 ? "a" : "",
4125
5877
  perforation ? "p" : "",
4126
5878
  stock.adhesive ? "A" : ""
@@ -4131,9 +5883,9 @@ void main() {
4131
5883
  alphaTest: deckle || perforation ? 0.5 : 0
4132
5884
  };
4133
5885
  }
4134
- function padPositions(positions) {
4135
- const out = positions.slice(0, 4);
4136
- while (out.length < 4) out.push(-1);
5886
+ function pad(values, fill = 0) {
5887
+ const out = values.slice(0, 4);
5888
+ while (out.length < 4) out.push(fill);
4137
5889
  return out;
4138
5890
  }
4139
5891
 
@@ -4146,7 +5898,8 @@ function PaperMaterial({
4146
5898
  surface,
4147
5899
  thickness,
4148
5900
  sheet: sheet2,
4149
- lighting = "studio"
5901
+ lighting = "studio",
5902
+ creases
4150
5903
  }) {
4151
5904
  const rig = useLightRig(lighting);
4152
5905
  const composed = composeSurface(
@@ -4158,7 +5911,8 @@ function PaperMaterial({
4158
5911
  hasBackMap: Boolean(backTexture)
4159
5912
  },
4160
5913
  sheet2,
4161
- rig
5914
+ rig,
5915
+ creases ?? resolveCreases(surface, [], sheet2 ?? { width: 1, height: 1.4 })
4162
5916
  );
4163
5917
  const bound = useMemo2(() => composed.uniforms, [composed.structureKey]);
4164
5918
  useEffect4(() => {
@@ -4216,6 +5970,7 @@ function configInputs(props) {
4216
5970
  props.behavior ?? null,
4217
5971
  props.deformers ?? null,
4218
5972
  props.surface ?? null,
5973
+ props.memory ?? null,
4219
5974
  props.scene ?? null,
4220
5975
  props.physics ?? null,
4221
5976
  props.onTwos ?? null
@@ -4234,6 +5989,7 @@ function resolveConfig(props) {
4234
5989
  if (props.behavior) overrides.behavior = props.behavior;
4235
5990
  if (props.deformers) overrides.deformers = props.deformers;
4236
5991
  if (props.surface) overrides.surface = { ...base.surface, ...props.surface };
5992
+ if (props.memory) overrides.memory = { ...base.memory, ...props.memory };
4237
5993
  if (props.scene) overrides.scene = { ...base.scene, ...props.scene };
4238
5994
  if (props.physics) overrides.physics = props.physics;
4239
5995
  if (props.onTwos !== void 0) overrides.onTwos = props.onTwos;
@@ -4261,7 +6017,9 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4261
6017
  machineRef.current = machine;
4262
6018
  const resolvedRef = useRef3(resolved);
4263
6019
  resolvedRef.current = resolved;
4264
- const isCloth = !reduced && typeof config.physics === "object";
6020
+ const simKind = !reduced && typeof config.physics === "object" ? config.physics.type : null;
6021
+ const isCloth = simKind === "cloth";
6022
+ const isStrip = simKind === "strip";
4265
6023
  const idle = !reduced && typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4266
6024
  const meshRef = useRef3(null);
4267
6025
  const groupRef = useRef3(null);
@@ -4273,19 +6031,33 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4273
6031
  const draggingRef = useRef3(null);
4274
6032
  const configRef = useRef3(config);
4275
6033
  configRef.current = config;
6034
+ const baseRotation = useMemo3(() => {
6035
+ const [rx, ry, rz] = props.rotation ?? [0, 0, 0];
6036
+ return [rx, ry + config.scene.turn * Math.PI / 180, rz];
6037
+ }, [props.rotation, config.scene.turn]);
4276
6038
  const controls = useThree((s) => s.controls);
4277
6039
  const camera = useThree((s) => s.camera);
4278
6040
  const behaviorKey = JSON.stringify(config.behavior ?? null);
4279
6041
  const deformersKey = JSON.stringify(config.deformers ?? null);
4280
6042
  const sheetKey = JSON.stringify(config.sheet);
4281
- const physicsKey = JSON.stringify(config.physics);
6043
+ const physicsKey = typeof config.physics === "object" ? config.physics.type : config.physics;
6044
+ const memoryKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}`).join("|");
6045
+ const creaseKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}:${c.depth}`).join("|");
6046
+ const creasesRef = useRef3(null);
6047
+ creasesRef.current ??= new CreaseTracker(config.memory.creases);
6048
+ const creases = creasesRef.current;
4282
6049
  useEffect5(() => {
4283
6050
  if (!draggingRef.current && !playingRef.current) overridesRef.current = {};
6051
+ creases.reset();
4284
6052
  dirtyRef.current = true;
4285
6053
  }, [behaviorKey, deformersKey, sheetKey, physicsKey]);
6054
+ useEffect5(() => {
6055
+ creases.adopt(configRef.current.memory.creases);
6056
+ dirtyRef.current = true;
6057
+ }, [creaseKey]);
4286
6058
  const { minSegments, autoSegments, animatedStack } = useMemo3(() => {
4287
6059
  const cfg = configRef.current;
4288
- const probe2 = buildStack(cfg, {});
6060
+ const probe2 = withMemory(buildStack(cfg, {}), cfg);
4289
6061
  if (!probe2) {
4290
6062
  return {
4291
6063
  minSegments: [2, 2],
@@ -4298,7 +6070,7 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4298
6070
  if (cfg.behavior && !cfg.deformers) {
4299
6071
  const param = getBehavior(cfg.behavior.type).progressParam;
4300
6072
  for (const p of PROGRESS_SAMPLES) {
4301
- const at = buildStack(cfg, { [param]: p });
6073
+ const at = withMemory(buildStack(cfg, { [param]: p }), cfg);
4302
6074
  if (!at) continue;
4303
6075
  const [x, y] = stackAutoSegments(at, cfg.sheet);
4304
6076
  if (x > want[0]) want[0] = x;
@@ -4311,30 +6083,65 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4311
6083
  autoSegments: want,
4312
6084
  animatedStack: animated
4313
6085
  };
4314
- }, [behaviorKey, deformersKey, physicsKey]);
6086
+ }, [behaviorKey, deformersKey, physicsKey, memoryKey]);
4315
6087
  const geometry = useMemo3(() => {
6088
+ if (isStrip) {
6089
+ const strip = configRef.current.physics;
6090
+ const nodes = stripNodeCount(config.sheet.height, strip.perforation);
6091
+ return new THREE7.PlaneGeometry(config.sheet.width, config.sheet.height, 1, nodes - 1);
6092
+ }
4316
6093
  if (!isCloth) return createSheetGeometry(config.sheet, minSegments, autoSegments);
4317
- const [sx, sy] = resolveSegments(config.sheet, 2);
6094
+ const [sx, sy] = resolveSegments(config.sheet, minSegments);
4318
6095
  const capped = Math.min(Math.max(sx, sy), CLOTH_MAX_SEGMENTS);
4319
6096
  return new THREE7.PlaneGeometry(config.sheet.width, config.sheet.height, capped, capped);
4320
- }, [sheetKey, minSegments, autoSegments, isCloth]);
6097
+ }, [
6098
+ sheetKey,
6099
+ minSegments,
6100
+ autoSegments,
6101
+ isCloth,
6102
+ isStrip,
6103
+ isStrip ? config.physics.perforation : 0
6104
+ ]);
4321
6105
  useEffect5(() => () => geometry.dispose(), [geometry]);
4322
6106
  const basePositions = useMemo3(
4323
6107
  () => Float32Array.from(geometry.attributes.position.array),
4324
6108
  [geometry]
4325
6109
  );
6110
+ const stripSim = useMemo3(() => {
6111
+ if (!isStrip) return null;
6112
+ const strip = configRef.current.physics;
6113
+ return new StripSim(config.sheet.height, config.sheet.width, {
6114
+ scroll: strip.scroll,
6115
+ tightness: strip.tightness,
6116
+ core: strip.core,
6117
+ tail: strip.tail,
6118
+ perforation: strip.perforation,
6119
+ crease: strip.crease,
6120
+ stiffness: strip.stiffness,
6121
+ drag: strip.drag,
6122
+ gravity: strip.gravity,
6123
+ floor: strip.floor,
6124
+ inertia: strip.inertia
6125
+ });
6126
+ }, [geometry, isStrip]);
6127
+ const lastSimRef = useRef3(null);
4326
6128
  const sim = useMemo3(() => {
4327
6129
  if (!isCloth) return null;
4328
6130
  const cloth = configRef.current.physics;
4329
6131
  const cols = geometry.parameters.widthSegments + 1;
4330
6132
  const rows = geometry.parameters.heightSegments + 1;
4331
- return new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
6133
+ const next = new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
4332
6134
  stiffness: cloth.stiffness,
4333
6135
  gravity: cloth.gravity,
4334
6136
  wind: cloth.wind,
4335
6137
  floor: cloth.floor
4336
6138
  });
6139
+ next.adopt(lastSimRef.current);
6140
+ return next;
4337
6141
  }, [geometry, isCloth, isCloth ? config.physics.pins : ""]);
6142
+ useEffect5(() => {
6143
+ lastSimRef.current = sim;
6144
+ }, [sim]);
4338
6145
  const stock = getStock(config.stock);
4339
6146
  const texture = useContentTexture(config.content, config.sheet, stock);
4340
6147
  const backTexture = useContentTexture(config.content.back, config.sheet, stock);
@@ -4429,6 +6236,10 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4429
6236
  placeProgrammatic: () => machineRef.current?.placeProgrammatic() ?? false,
4430
6237
  returnProgrammatic: () => machineRef.current?.returnProgrammatic() ?? false
4431
6238
  }));
6239
+ const shadedCreases = useMemo3(
6240
+ () => resolveCreases(config.surface, config.memory.creases, config.sheet),
6241
+ [config.surface, config.memory.creases, config.sheet]
6242
+ );
4432
6243
  const idlePose = useRef3({ position: [0, 0, 0], rotation: [0, 0, 0] });
4433
6244
  useFrame(({ clock }, delta) => {
4434
6245
  const cfg = liveConfig();
@@ -4441,10 +6252,10 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4441
6252
  idle?.transform?.(now, pose);
4442
6253
  if (hasBehaviorTransform) {
4443
6254
  const o = effectiveOptions(now);
4444
- if (o) behavior.transform(o, now, pose);
6255
+ if (o) behavior.transform(o, now, pose, cfg.sheet);
4445
6256
  }
4446
6257
  const base = props.position ?? [0, 0, 0];
4447
- const baseRot = props.rotation ?? [0, 0, 0];
6258
+ const baseRot = baseRotation;
4448
6259
  groupRef.current.position.set(
4449
6260
  base[0] + pose.position[0],
4450
6261
  base[1] + pose.position[1],
@@ -4456,6 +6267,56 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4456
6267
  baseRot[2] + pose.rotation[2]
4457
6268
  );
4458
6269
  }
6270
+ if (isStrip && stripSim) {
6271
+ const strip = cfg.physics;
6272
+ stripSim.setParams({
6273
+ scroll: strip.scroll,
6274
+ tightness: strip.tightness,
6275
+ core: strip.core,
6276
+ tail: strip.tail,
6277
+ crease: strip.crease,
6278
+ stiffness: strip.stiffness,
6279
+ drag: strip.drag,
6280
+ gravity: strip.gravity,
6281
+ floor: strip.floor,
6282
+ inertia: strip.inertia
6283
+ });
6284
+ stripSim.step(delta);
6285
+ if (!stripSim.asleep) {
6286
+ const position = geometry.attributes.position;
6287
+ stripSim.writeInto(position.array);
6288
+ position.needsUpdate = true;
6289
+ computeSheetNormals(geometry);
6290
+ geometry.computeBoundingSphere();
6291
+ }
6292
+ return;
6293
+ }
6294
+ const applyShape = (base, force) => {
6295
+ const animated = !reduced && animatedStack;
6296
+ const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
6297
+ const machineAnimating = Boolean(machineRef.current?.transitioning);
6298
+ if (!force && !dirtyRef.current && !hasLoop && !animated && !machineAnimating) return false;
6299
+ const raw = buildStack(cfg, overridesRef.current, behavior, now);
6300
+ const setAmount = cfg.memory.set ?? stock.takesSet;
6301
+ if (raw && creases.observe(raw, setAmount)) props.onCrease?.(creases.creases);
6302
+ const stack = withMemory(raw, cfg, creases.creases);
6303
+ if (!stack) return false;
6304
+ dirtyRef.current = false;
6305
+ const ctx = { t: now, sheet: cfg.sheet };
6306
+ applyDeformerStack(geometry, base, stack, ctx);
6307
+ if (props.interactive && behavior?.handles) {
6308
+ const o = effectiveOptions(now);
6309
+ behavior.handles.forEach((h, i) => {
6310
+ const mesh = handleRefs.current[i];
6311
+ if (!mesh || !o) return;
6312
+ const [u, v] = h.anchor(o, cfg.sheet);
6313
+ anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
6314
+ displacePoint(anchorScratch, u, v, stack, ctx);
6315
+ mesh.position.copy(anchorScratch);
6316
+ });
6317
+ }
6318
+ return true;
6319
+ };
4459
6320
  if (isCloth && sim) {
4460
6321
  const cloth = cfg.physics;
4461
6322
  sim.setParams({
@@ -4465,7 +6326,8 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4465
6326
  floor: cloth.floor
4466
6327
  });
4467
6328
  sim.step(delta);
4468
- if (!sim.asleep) {
6329
+ const moved = !sim.asleep;
6330
+ if (!applyShape(sim.positions, moved) && moved) {
4469
6331
  const position = geometry.attributes.position;
4470
6332
  position.array.set(sim.positions);
4471
6333
  position.needsUpdate = true;
@@ -4473,26 +6335,7 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4473
6335
  }
4474
6336
  return;
4475
6337
  }
4476
- const animated = !reduced && animatedStack;
4477
- const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
4478
- const machineAnimating = Boolean(machineRef.current?.transitioning);
4479
- if (!dirtyRef.current && !hasLoop && !animated && !machineAnimating) return;
4480
- const stack = buildStack(cfg, overridesRef.current, behavior, now);
4481
- if (!stack) return;
4482
- dirtyRef.current = false;
4483
- const ctx = { t: now, sheet: cfg.sheet };
4484
- applyDeformerStack(geometry, basePositions, stack, ctx);
4485
- if (props.interactive && behavior?.handles) {
4486
- const o = effectiveOptions(now);
4487
- behavior.handles.forEach((h, i) => {
4488
- const mesh = handleRefs.current[i];
4489
- if (!mesh || !o) return;
4490
- const [u, v] = h.anchor(o, cfg.sheet);
4491
- anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
4492
- displacePoint(anchorScratch, u, v, stack, ctx);
4493
- mesh.position.copy(anchorScratch);
4494
- });
4495
- }
6338
+ applyShape(basePositions, false);
4496
6339
  });
4497
6340
  const localDragPoint = (e) => {
4498
6341
  const group = groupRef.current;
@@ -4516,13 +6359,22 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4516
6359
  if (typeof p === "number") props.onProgress?.(p);
4517
6360
  };
4518
6361
  const grabAnchor = useRef3(new THREE7.Vector3());
6362
+ const grabOffset = useRef3(new THREE7.Vector3());
4519
6363
  const clothDown = (e) => {
4520
6364
  if (!isCloth || !sim || !props.interactive || !groupRef.current) return;
4521
6365
  e.stopPropagation();
4522
6366
  if (controls) controls.enabled = false;
4523
6367
  grabAnchor.current.copy(e.point);
4524
6368
  const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
4525
- sim.grabNearest(local.x, local.y, local.z);
6369
+ const drawn = geometry.attributes.position.array;
6370
+ const index = nearestVertex(drawn, sim.count, local.x, local.y, local.z);
6371
+ sim.grab(index);
6372
+ const i3 = index * 3;
6373
+ grabOffset.current.set(
6374
+ drawn[i3] - sim.positions[i3],
6375
+ drawn[i3 + 1] - sim.positions[i3 + 1],
6376
+ drawn[i3 + 2] - sim.positions[i3 + 2]
6377
+ );
4526
6378
  draggingRef.current = "cloth";
4527
6379
  e.target.setPointerCapture(e.pointerId);
4528
6380
  };
@@ -4533,7 +6385,8 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4533
6385
  const hit = e.ray.intersectPlane(dragPlane, dragPoint);
4534
6386
  if (!hit) return;
4535
6387
  groupRef.current.worldToLocal(hit);
4536
- sim.moveGrab(hit.x, hit.y, hit.z);
6388
+ const offset = grabOffset.current;
6389
+ sim.moveGrab(hit.x - offset.x, hit.y - offset.y, hit.z - offset.z);
4537
6390
  };
4538
6391
  const clothUp = (e) => {
4539
6392
  if (draggingRef.current !== "cloth" || !sim) return;
@@ -4542,8 +6395,34 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4542
6395
  sim.release();
4543
6396
  e.target.releasePointerCapture(e.pointerId);
4544
6397
  };
6398
+ const stripDown = (e) => {
6399
+ if (!isStrip || !stripSim || !props.interactive || !groupRef.current) return;
6400
+ e.stopPropagation();
6401
+ const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
6402
+ if (stripSim.grabNearest(local.y, local.z) < 0) return;
6403
+ if (controls) controls.enabled = false;
6404
+ grabAnchor.current.copy(e.point);
6405
+ draggingRef.current = "strip";
6406
+ e.target.setPointerCapture(e.pointerId);
6407
+ };
6408
+ const stripMove = (e) => {
6409
+ if (draggingRef.current !== "strip" || !stripSim || !groupRef.current) return;
6410
+ camera.getWorldDirection(planeNormal);
6411
+ dragPlane.setFromNormalAndCoplanarPoint(planeNormal, grabAnchor.current);
6412
+ const hit = e.ray.intersectPlane(dragPlane, dragPoint);
6413
+ if (!hit) return;
6414
+ groupRef.current.worldToLocal(hit);
6415
+ stripSim.moveGrab(hit.y, hit.z);
6416
+ };
6417
+ const stripUp = (e) => {
6418
+ if (draggingRef.current !== "strip" || !stripSim) return;
6419
+ draggingRef.current = null;
6420
+ if (controls) controls.enabled = true;
6421
+ stripSim.release();
6422
+ e.target.releasePointerCapture(e.pointerId);
6423
+ };
4545
6424
  const sendState = (event) => machineRef.current?.send(event);
4546
- return /* @__PURE__ */ jsxs("group", { ref: groupRef, position: props.position, rotation: props.rotation, children: [
6425
+ return /* @__PURE__ */ jsxs("group", { ref: groupRef, position: props.position, rotation: baseRotation, children: [
4547
6426
  /* @__PURE__ */ jsx4(
4548
6427
  "mesh",
4549
6428
  {
@@ -4554,13 +6433,15 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4554
6433
  frustumCulled: false,
4555
6434
  onPointerOver: statesLive ? () => sendState("enter") : void 0,
4556
6435
  onPointerOut: statesLive ? () => sendState("leave") : void 0,
4557
- onPointerDown: isCloth || statesLive ? (e) => {
6436
+ onPointerDown: isCloth || isStrip || statesLive ? (e) => {
4558
6437
  if (isCloth) clothDown(e);
6438
+ if (isStrip) stripDown(e);
4559
6439
  if (statesLive) sendState("down");
4560
6440
  } : void 0,
4561
- onPointerMove: isCloth ? clothMove : void 0,
4562
- onPointerUp: isCloth || statesLive ? (e) => {
6441
+ onPointerMove: isCloth ? clothMove : isStrip ? stripMove : void 0,
6442
+ onPointerUp: isCloth || isStrip || statesLive ? (e) => {
4563
6443
  if (isCloth) clothUp(e);
6444
+ if (isStrip) stripUp(e);
4564
6445
  if (statesLive) sendState("up");
4565
6446
  } : void 0,
4566
6447
  children: /* @__PURE__ */ jsx4(
@@ -4572,12 +6453,17 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4572
6453
  surface: config.surface,
4573
6454
  thickness: config.sheet.thickness,
4574
6455
  sheet: config.sheet,
4575
- lighting: config.scene.lighting
6456
+ lighting: config.scene.lighting,
6457
+ creases: shadedCreases
4576
6458
  }
4577
6459
  )
4578
6460
  }
4579
6461
  ),
4580
- props.interactive && !isCloth && behavior?.handles?.map((h, i) => /* @__PURE__ */ jsxs(
6462
+ props.interactive && // Any simulation, not cloth alone: a sim owns the vertices, so there
6463
+ // is no deformer stack for a handle to drive. Harmless as `!isCloth`
6464
+ // only because the schema makes a sim and a behavior exclusive — the
6465
+ // intent is what is written here.
6466
+ !simKind && behavior?.handles?.map((h, i) => /* @__PURE__ */ jsxs(
4581
6467
  "mesh",
4582
6468
  {
4583
6469
  userData: { paperlabChrome: true },
@@ -4608,8 +6494,32 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
4608
6494
  ))
4609
6495
  ] });
4610
6496
  });
6497
+ function withMemory(stack, config, creases = config.memory.creases) {
6498
+ if (isStripConfig(config.physics)) return null;
6499
+ const out = applyMemory(stack ?? [], creases);
6500
+ return out.length > 0 ? out : null;
6501
+ }
6502
+ function nearestVertex(array, count, x, y, z25) {
6503
+ let best = -1;
6504
+ let bestDist = Infinity;
6505
+ for (let i = 0; i < count; i++) {
6506
+ const i3 = i * 3;
6507
+ const dx = array[i3] - x;
6508
+ const dy = array[i3 + 1] - y;
6509
+ const dz = array[i3 + 2] - z25;
6510
+ const d = dx * dx + dy * dy + dz * dz;
6511
+ if (d < bestDist) {
6512
+ bestDist = d;
6513
+ best = i;
6514
+ }
6515
+ }
6516
+ return best;
6517
+ }
6518
+ function isStripConfig(physics) {
6519
+ return typeof physics === "object" && physics.type === "strip";
6520
+ }
4611
6521
  function buildStack(config, overrides, behavior, t = 0) {
4612
- if (typeof config.physics === "object") return null;
6522
+ if (isStripConfig(config.physics)) return null;
4613
6523
  const idle = typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4614
6524
  const idleStack = idle?.stack?.() ?? [];
4615
6525
  let shapeStack = [];
@@ -5264,7 +7174,7 @@ function getWalkPath(options) {
5264
7174
  // src/field/layouts/index.ts
5265
7175
  import { z as z24 } from "zod";
5266
7176
  var DEFAULT_SHEET = { width: 1, height: 1.4 };
5267
- var TAU3 = Math.PI * 2;
7177
+ var TAU4 = Math.PI * 2;
5268
7178
  var DEG7 = Math.PI / 180;
5269
7179
  function jitter2(seed, i) {
5270
7180
  let h = Math.imul(seed * 1e3 + i + 1 ^ 2654435769, 2654435761);
@@ -5284,7 +7194,7 @@ var ring = {
5284
7194
  defaults: ringSchema.parse({}),
5285
7195
  optionsSchema: ringSchema,
5286
7196
  pose(i, n, o, phase) {
5287
- const theta = (i / n + phase) * TAU3;
7197
+ const theta = (i / n + phase) * TAU4;
5288
7198
  return {
5289
7199
  position: [Math.sin(theta) * o.radius, 0, Math.cos(theta) * o.radius],
5290
7200
  // Face radially OUTWARD so the papers nearest the camera show their
@@ -5445,7 +7355,7 @@ var sweepSchema = z24.object({
5445
7355
  from: z24.number().min(0).max(1).default(0),
5446
7356
  to: z24.number().min(0).max(1).default(1)
5447
7357
  });
5448
- var sweep = {
7358
+ var sweep2 = {
5449
7359
  id: "sweep",
5450
7360
  label: "Sweep",
5451
7361
  defaults: sweepSchema.parse({}),
@@ -5560,7 +7470,7 @@ var rack = {
5560
7470
  };
5561
7471
  var colonnadeSchema = z24.object({
5562
7472
  /** The walk the colonnade is built along — see `stage/path`. */
5563
- path: walkPathSchema.default({}),
7473
+ path: walkPathSchema.prefault({}),
5564
7474
  /** Half-width of the clear aisle: how far each banner stands off the walk line. */
5565
7475
  aisle: z24.number().min(0.2).max(20).default(2.4),
5566
7476
  /** How much that gap opens and closes along the walk. Nothing hung by hand is a corridor. */
@@ -5654,7 +7564,7 @@ registerLayout(spread);
5654
7564
  registerLayout(pile);
5655
7565
  registerLayout(wall);
5656
7566
  registerLayout(spill);
5657
- registerLayout(sweep);
7567
+ registerLayout(sweep2);
5658
7568
  registerLayout(book);
5659
7569
  registerLayout(accordion);
5660
7570
  registerLayout(rack);
@@ -6116,7 +8026,8 @@ function FieldGroup({
6116
8026
  behaviorTransform.transform(
6117
8027
  { ...config.behavior, [behaviorTransform.progressParam]: progressRef.current },
6118
8028
  t,
6119
- pose2
8029
+ pose2,
8030
+ shared.sheet
6120
8031
  );
6121
8032
  scratchObj.position.x += pose2.position[0];
6122
8033
  scratchObj.position.y += pose2.position[1];
@@ -7021,6 +8932,8 @@ function diffConfig(config) {
7021
8932
  }
7022
8933
  if (config.deformers) out.deformers = config.deformers;
7023
8934
  if (Object.keys(config.surface).length > 0) out.surface = config.surface;
8935
+ const memory = diffAgainst(config.memory, memorySchema.parse({}));
8936
+ if (Object.keys(memory).length > 0) out.memory = memory;
7024
8937
  if (typeof config.physics === "object") {
7025
8938
  const defaults = clothConfigSchema.parse({ type: "cloth" });
7026
8939
  out.physics = { type: "cloth", ...diffAgainst(config.physics, defaults) };
@@ -7095,8 +9008,9 @@ function describeConfig(config) {
7095
9008
  if (config.content.type === "receipt") contentPhrase = `a store receipt for "${config.content.store}"`;
7096
9009
  const parts = [`${contentPhrase} on ${stock.label.toLowerCase()} paper stock (${size})`];
7097
9010
  if (typeof config.physics === "object") {
9011
+ const sim = config.physics;
7098
9012
  parts.push(
7099
- config.physics.pins === "none" ? "falling and settling as cloth" : `pinned (${config.physics.pins}) and moving like cloth in wind`
9013
+ sim.type === "strip" ? "paying off a roll as the page scrolls, folding at its perforations into a pile on the floor" : sim.pins === "none" ? "falling and settling as cloth" : `pinned (${sim.pins}) and moving like cloth in wind`
7100
9014
  );
7101
9015
  } else if (config.behavior) {
7102
9016
  const phrase = BEHAVIOR_PHRASES[config.behavior.type];
@@ -7167,9 +9081,12 @@ export {
7167
9081
  contentNames,
7168
9082
  contentSchemaFor,
7169
9083
  paperEdges,
9084
+ creaseSchema,
9085
+ memorySchema,
7170
9086
  behaviorConfigSchema,
7171
9087
  physicsNames,
7172
9088
  clothConfigSchema,
9089
+ stripConfigSchema,
7173
9090
  lightingNames,
7174
9091
  lightSchema,
7175
9092
  backdropSchema,
@@ -7193,10 +9110,15 @@ export {
7193
9110
  getDeformer,
7194
9111
  listDeformers,
7195
9112
  displacePoint,
9113
+ MAX_SET,
9114
+ MAX_CREASES,
9115
+ applyMemory,
9116
+ CreaseTracker,
7196
9117
  registerBehavior,
7197
9118
  getBehavior,
7198
9119
  listBehaviors,
7199
9120
  idleNames,
9121
+ maxStripLength,
7200
9122
  lightAngles,
7201
9123
  resolveLighting,
7202
9124
  LightRig,
@@ -7231,4 +9153,4 @@ export {
7231
9153
  describeConfig,
7232
9154
  buildAgentPayload
7233
9155
  };
7234
- //# sourceMappingURL=chunk-HRXQTJFS.js.map
9156
+ //# sourceMappingURL=chunk-E22ILEDO.js.map