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.
package/dist/index.cjs CHANGED
@@ -31,9 +31,12 @@ var __toCommonJS = (mod2) => __copyProps(__defProp({}, "__esModule", { value: tr
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AUTO_CEILING: () => AUTO_CEILING,
34
+ CreaseTracker: () => CreaseTracker,
34
35
  DropZone: () => DropZone,
35
36
  FLAT_SEGMENTS: () => FLAT_SEGMENTS,
36
37
  LightRig: () => LightRig,
38
+ MAX_CREASES: () => MAX_CREASES,
39
+ MAX_SET: () => MAX_SET,
37
40
  PARITY_EPSILON: () => PARITY_EPSILON,
38
41
  Paper: () => Paper,
39
42
  PaperBackdrop: () => PaperBackdrop,
@@ -42,6 +45,7 @@ __export(index_exports, {
42
45
  PaperLighting: () => PaperLighting,
43
46
  PaperMesh: () => PaperMesh,
44
47
  SAG_TOL: () => SAG_TOL,
48
+ applyMemory: () => applyMemory,
45
49
  backdropSchema: () => backdropSchema,
46
50
  behaviorConfigSchema: () => behaviorConfigSchema,
47
51
  buildAgentPayload: () => buildAgentPayload,
@@ -52,6 +56,7 @@ __export(index_exports, {
52
56
  contentNames: () => contentNames,
53
57
  contentSchemaFor: () => contentSchemaFor,
54
58
  coreStateNames: () => coreStateNames,
59
+ creaseSchema: () => creaseSchema,
55
60
  describeConfig: () => describeConfig,
56
61
  describeFieldConfig: () => describeFieldConfig,
57
62
  diffConfig: () => diffConfig,
@@ -70,6 +75,8 @@ __export(index_exports, {
70
75
  listDeformers: () => listDeformers,
71
76
  listLayouts: () => listLayouts,
72
77
  listPresets: () => listPresets,
78
+ maxStripLength: () => maxStripLength,
79
+ memorySchema: () => memorySchema,
73
80
  mergeConfig: () => mergeConfig,
74
81
  mergeWithDeletes: () => mergeWithDeletes,
75
82
  paperConfigSchema: () => paperConfigSchema,
@@ -96,6 +103,7 @@ __export(index_exports, {
96
103
  stateDefSchema: () => stateDefSchema,
97
104
  stockNames: () => stockNames,
98
105
  stocks: () => stocks,
106
+ stripConfigSchema: () => stripConfigSchema,
99
107
  supportsWebGL: () => supportsWebGL,
100
108
  uniquePresetName: () => uniquePresetName,
101
109
  unregisterPreset: () => unregisterPreset,
@@ -117,7 +125,7 @@ var import_fiber = require("@react-three/fiber");
117
125
  var import_react7 = require("react");
118
126
 
119
127
  // src/config/schema.ts
120
- var import_zod14 = require("zod");
128
+ var import_zod15 = require("zod");
121
129
 
122
130
  // src/config/merge.ts
123
131
  function mergeConfig(base, override) {
@@ -351,15 +359,169 @@ var peel = {
351
359
  };
352
360
 
353
361
  // src/behaviors/unroll.ts
362
+ var import_zod4 = require("zod");
363
+
364
+ // src/deformers/roll.ts
354
365
  var import_zod3 = require("zod");
355
- var unrollOptionsSchema = import_zod3.z.object({
366
+ var rollOptionsSchema = import_zod3.z.object({
367
+ /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
368
+ angle: import_zod3.z.number().min(-360).max(360).default(90),
369
+ /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
370
+ boundary: import_zod3.z.number().min(-20).max(20).default(0),
371
+ /** Radius of the OUTERMOST wrap — the one the flat sheet leaves the roll on. */
372
+ radius: import_zod3.z.number().min(0.01).max(2).default(0.12),
373
+ /** Gap between consecutive wraps, in world units. 0 = a bare cylinder. */
374
+ thickness: import_zod3.z.number().min(0).max(0.2).default(0.015)
375
+ });
376
+ var DEG2 = Math.PI / 180;
377
+ var TAU = Math.PI * 2;
378
+ var MIN_RADIUS_FRACTION = 0.08;
379
+ function windAngle(s, r0, k) {
380
+ if (k <= 0) return s / r0;
381
+ const rMin = r0 * MIN_RADIUS_FRACTION;
382
+ const thetaFloor = (r0 - rMin) / k;
383
+ const sFloor = r0 * thetaFloor - k * thetaFloor * thetaFloor / 2;
384
+ if (s >= sFloor) return thetaFloor + (s - sFloor) / rMin;
385
+ return 2 * s / (r0 + Math.sqrt(r0 * r0 - 2 * k * s));
386
+ }
387
+ function windRadius(theta, r0, k) {
388
+ return Math.max(r0 - k * theta, r0 * MIN_RADIUS_FRACTION);
389
+ }
390
+ function rollRadius(length, core, thickness) {
391
+ return Math.sqrt(core * core + Math.max(0, length) * thickness / Math.PI);
392
+ }
393
+ var roll = {
394
+ id: "roll",
395
+ label: "Roll",
396
+ defaults: rollOptionsSchema.parse({}),
397
+ optionsSchema: rollOptionsSchema,
398
+ geometry: {
399
+ minSegments: 48,
400
+ // The INNERMOST wrap is the tightest curvature on the sheet, so it sets
401
+ // the density — the outer one used to, back when the spiral grew outward.
402
+ // Floored at a fraction of the outer radius because segment count scales
403
+ // as 1/√r: a roll wound to a hair's breadth would otherwise ask for a
404
+ // grid nobody can afford, to resolve a few square millimetres at the core.
405
+ autoSegments: (o, sheet2) => {
406
+ const span = spanAlong(sheet2, o.angle);
407
+ const wound = Math.max(0, span / 2 - o.boundary);
408
+ const k = o.thickness / TAU;
409
+ return segmentsForArc(span, windRadius(windAngle(wound, o.radius, k), o.radius, k));
410
+ },
411
+ axis: (o) => o.angle
412
+ },
413
+ displace(out, _uv, o) {
414
+ const dirX = Math.cos(o.angle * DEG2);
415
+ const dirY = Math.sin(o.angle * DEG2);
416
+ const d = out.x * dirX + out.y * dirY;
417
+ const s = d - o.boundary;
418
+ if (s <= 0) return;
419
+ const k = o.thickness / TAU;
420
+ const theta = windAngle(s, o.radius, k);
421
+ const r = windRadius(theta, o.radius, k) - out.z;
422
+ const newD = o.boundary + r * Math.sin(theta);
423
+ const newZ = o.radius - r * Math.cos(theta);
424
+ out.x += dirX * (newD - d);
425
+ out.y += dirY * (newD - d);
426
+ out.z = newZ;
427
+ },
428
+ glsl: {
429
+ chunk: (
430
+ /* glsl */
431
+ `
432
+ void FN(inout vec3 p, vec2 uv, float t) {
433
+ vec2 dir = vec2(cos(U_angle), sin(U_angle));
434
+ float d = dot(p.xy, dir);
435
+ float s = d - U_boundary;
436
+ if (s <= 0.0) return;
437
+ float k = U_thickness / 6.2831853071795864;
438
+ float rMin = U_radius * 0.08;
439
+ float thetaFloor = (U_radius - rMin) / max(k, 1e-9);
440
+ float sFloor = U_radius * thetaFloor - 0.5 * k * thetaFloor * thetaFloor;
441
+ float theta = k <= 0.0
442
+ ? s / U_radius
443
+ : (s >= sFloor
444
+ ? thetaFloor + (s - sFloor) / rMin
445
+ : (2.0 * s) / (U_radius + sqrt(U_radius * U_radius - 2.0 * k * s)));
446
+ float r = max(U_radius - k * theta, rMin) - p.z;
447
+ float newD = U_boundary + r * sin(theta);
448
+ float newZ = U_radius - r * cos(theta);
449
+ p.xy += dir * (newD - d);
450
+ p.z = newZ;
451
+ }
452
+ `
453
+ ),
454
+ uniforms: (o) => ({
455
+ angle: o.angle * DEG2,
456
+ boundary: o.boundary,
457
+ radius: o.radius,
458
+ thickness: o.thickness
459
+ })
460
+ }
461
+ };
462
+
463
+ // src/behaviors/unroll.ts
464
+ var unrollOptionsSchema = import_zod4.z.object({
356
465
  /** 0 = fully rolled cylinder, 1 = flat sheet. */
357
- progress: import_zod3.z.number().min(0).max(1).default(0.5),
358
- /** How tightly the paper is wound. */
359
- tightness: import_zod3.z.number().min(0).max(1).default(0.5),
466
+ progress: import_zod4.z.number().min(0).max(1).default(0.5),
467
+ /** How tightly the paper is wound — thin layers and many turns, or few and fat. */
468
+ tightness: import_zod4.z.number().min(0).max(1).default(0.5),
360
469
  /** Idle rocking of the rolled end. */
361
- sway: import_zod3.z.number().min(0).max(1).default(0.25)
470
+ sway: import_zod4.z.number().min(0).max(1).default(0.25),
471
+ /** Which end of the sheet holds the roll. `top` hangs the paper below it. */
472
+ from: import_zod4.z.enum(["bottom", "top"]).default("bottom"),
473
+ /** Radius of the tube the paper is wound onto — the roll never shrinks past it. */
474
+ core: import_zod4.z.number().min(5e-3).max(0.5).default(0.03),
475
+ /**
476
+ * Paper already hanging at `progress` 0, in world units.
477
+ *
478
+ * A roll on a holder is never a bare cylinder: there is always a leaf out,
479
+ * because that is what you take hold of. Starting from nothing showing
480
+ * reads as a fresh roll still in its wrapper.
481
+ */
482
+ tail: import_zod4.z.number().min(0).max(20).default(0),
483
+ /**
484
+ * How far below the roll the paper lands, in world units. Omit and it
485
+ * hangs forever.
486
+ *
487
+ * Paper that reaches the ground does not stop and does not carry on
488
+ * through it: it creases and runs out flat. Everything past this distance
489
+ * turns a right angle and lies down.
490
+ */
491
+ floor: import_zod4.z.number().min(0.1).max(50).optional(),
492
+ /**
493
+ * Hold the roll still in space and let the paper hang off it, instead of
494
+ * letting the roll ride along with the shrinking wound region.
495
+ */
496
+ fixed: import_zod4.z.boolean().default(false)
362
497
  });
498
+ var LOOSE = 0.1;
499
+ var TIGHT = 0.02;
500
+ function layerThickness(tightness) {
501
+ return LOOSE - tightness * (LOOSE - TIGHT);
502
+ }
503
+ function sweep(o, sheet2) {
504
+ const maxRadius = rollRadius(sheet2.height, o.core, layerThickness(o.tightness));
505
+ const tail = Math.min(o.tail, sheet2.height);
506
+ return { start: -sheet2.height / 2 + tail, end: sheet2.height / 2 + maxRadius * 2 };
507
+ }
508
+ function rollBoundary(o, sheet2) {
509
+ const { start, end } = sweep(o, sheet2);
510
+ return start + o.progress * (end - start);
511
+ }
512
+ var LANDING_RADIUS = 0.035;
513
+ function landing(o, sheet2, boundary, rollRadiusNow) {
514
+ const radius = Math.min(0.5, Math.max(0.02, sheet2.height * LANDING_RADIUS));
515
+ const hingeDrop = radius / (Math.PI / 2);
516
+ const below = Math.max(o.floor, rollRadiusNow + radius);
517
+ const floorLine = o.from === "top" ? boundary - below : -boundary + below;
518
+ return {
519
+ type: "fold",
520
+ // Travel points from the roll toward the floor, so "past the crease"
521
+ // means "the length that has arrived", not the drop above it.
522
+ options: o.from === "top" ? { angle: -90, offset: -floorLine - hingeDrop, foldAngle: 90, radius } : { angle: 90, offset: floorLine - hingeDrop, foldAngle: 90, radius }
523
+ };
524
+ }
363
525
  var unroll = {
364
526
  id: "unroll",
365
527
  label: "Unroll",
@@ -370,46 +532,65 @@ var unroll = {
370
532
  duration: 3,
371
533
  loopMode: "yoyo",
372
534
  stack(o, sheet2) {
373
- const radius = 0.28 - o.tightness * 0.22;
374
- const start = -sheet2.height / 2;
375
- const end = sheet2.height / 2 + radius * 2;
376
- return [
535
+ const thickness = layerThickness(o.tightness);
536
+ const boundary = rollBoundary(o, sheet2);
537
+ const wound = Math.max(0, sheet2.height / 2 - boundary);
538
+ const radius = rollRadius(wound, o.core, thickness);
539
+ const stack = [
377
540
  {
378
541
  type: "roll",
379
542
  options: {
380
- angle: 270,
381
- boundary: start + o.progress * (end - start),
543
+ // Both directions sweep the same boundary; only which half of the
544
+ // sheet counts as "past" it changes. 270 winds the bottom (a
545
+ // receipt feeding downward), 90 winds the top (paper hanging below).
546
+ angle: o.from === "top" ? 90 : 270,
547
+ boundary,
382
548
  radius,
383
- spiral: 0.02
549
+ thickness
384
550
  }
385
551
  }
386
552
  ];
553
+ if (o.floor !== void 0) stack.push(landing(o, sheet2, boundary, radius));
554
+ return stack;
387
555
  },
388
556
  loop(o, t) {
389
557
  if (o.sway === 0) return {};
390
558
  const wobble = Math.sin(t * 1.5) * 0.01 * o.sway;
391
559
  return { progress: Math.min(1, Math.max(0, o.progress + wobble)) };
392
560
  },
561
+ transform(o, _t, pose, sheet2) {
562
+ if (!o.fixed) return;
563
+ const travel = sweep(o, sheet2).end - rollBoundary(o, sheet2);
564
+ pose.position[1] += o.from === "top" ? travel : -travel;
565
+ },
393
566
  handles: [
394
567
  {
395
568
  id: "roll-edge",
396
- anchor: (o) => [0.5, Math.max(0.02, Math.min(0.98, 1 - o.progress))],
397
- drag(local, _o, sheet2) {
398
- return { progress: Math.min(1, Math.max(0, 0.5 - local.y / sheet2.height)) };
569
+ // The grab point is the edge the paper leaves the roll on, so it tracks
570
+ // the boundary — which runs top-to-bottom for a roll at the bottom and
571
+ // bottom-to-top for one at the top.
572
+ anchor: (o) => {
573
+ const v = o.from === "top" ? o.progress : 1 - o.progress;
574
+ return [0.5, Math.max(0.02, Math.min(0.98, v))];
575
+ },
576
+ drag(local, o, sheet2) {
577
+ const along = local.y / sheet2.height;
578
+ const p = o.from === "top" ? 0.5 + along : 0.5 - along;
579
+ return { progress: Math.min(1, Math.max(0, p)) };
399
580
  }
400
581
  }
401
582
  ]
402
583
  };
403
584
 
404
585
  // src/behaviors/flip.ts
405
- var import_zod4 = require("zod");
406
- var flipOptionsSchema = import_zod4.z.object({
586
+ var import_zod5 = require("zod");
587
+ var flipOptionsSchema = import_zod5.z.object({
407
588
  /** 0 = flat, 1 = page fully turned over the spine. */
408
- progress: import_zod4.z.number().min(0).max(1).default(0.3),
589
+ progress: import_zod5.z.number().min(0).max(1).default(0.3),
409
590
  /** Which edge is the spine. */
410
- spine: import_zod4.z.enum(["left", "right"]).default("left"),
591
+ spine: import_zod5.z.enum(["left", "right"]).default("left"),
411
592
  /** Softness of the turning curl. */
412
- radius: import_zod4.z.number().min(0.1).max(0.8).default(0.3)
593
+ radius: import_zod5.z.number().min(0.1).max(0.8).default(0.3)
413
594
  });
414
595
  var flip = {
415
596
  id: "flip",
@@ -431,7 +612,7 @@ var flip = {
431
612
  angle,
432
613
  boundary: start + o.progress * (end - start),
433
614
  radius: o.radius,
434
- spiral: 0
615
+ thickness: 0
435
616
  }
436
617
  }
437
618
  ];
@@ -449,12 +630,12 @@ var flip = {
449
630
  };
450
631
 
451
632
  // src/behaviors/letter-fold.ts
452
- var import_zod5 = require("zod");
453
- var letterFoldOptionsSchema = import_zod5.z.object({
633
+ var import_zod6 = require("zod");
634
+ var letterFoldOptionsSchema = import_zod6.z.object({
454
635
  /** 0 = flat letter, 1 = fully tri-folded. */
455
- progress: import_zod5.z.number().min(0).max(1).default(0.4),
636
+ progress: import_zod6.z.number().min(0).max(1).default(0.4),
456
637
  /** Softness of the two creases. */
457
- crease: import_zod5.z.number().min(0).max(1).default(0.3)
638
+ crease: import_zod6.z.number().min(0).max(1).default(0.3)
458
639
  });
459
640
  var letterFold = {
460
641
  id: "letter-fold",
@@ -504,12 +685,12 @@ var letterFold = {
504
685
  };
505
686
 
506
687
  // src/behaviors/hang.ts
507
- var import_zod6 = require("zod");
508
- var hangOptionsSchema = import_zod6.z.object({
688
+ var import_zod7 = require("zod");
689
+ var hangOptionsSchema = import_zod7.z.object({
509
690
  /** Wind strength driving the ripple. */
510
- wind: import_zod6.z.number().min(0).max(1).default(0.4),
691
+ wind: import_zod7.z.number().min(0).max(1).default(0.4),
511
692
  /** Gravity bulge of the hanging sheet. */
512
- sag: import_zod6.z.number().min(0).max(1).default(0.3)
693
+ sag: import_zod7.z.number().min(0).max(1).default(0.3)
513
694
  });
514
695
  var hang = {
515
696
  id: "hang",
@@ -538,12 +719,12 @@ var hang = {
538
719
  };
539
720
 
540
721
  // src/behaviors/fly.ts
541
- var import_zod7 = require("zod");
542
- var flyOptionsSchema = import_zod7.z.object({
722
+ var import_zod8 = require("zod");
723
+ var flyOptionsSchema = import_zod8.z.object({
543
724
  /** Ripple energy. */
544
- flutter: import_zod7.z.number().min(0).max(1).default(0.5),
725
+ flutter: import_zod8.z.number().min(0).max(1).default(0.5),
545
726
  /** Aerodynamic arc of the sheet. */
546
- curve: import_zod7.z.number().min(0).max(1).default(0.4)
727
+ curve: import_zod8.z.number().min(0).max(1).default(0.4)
547
728
  });
548
729
  var fly = {
549
730
  id: "fly",
@@ -572,12 +753,12 @@ var fly = {
572
753
  };
573
754
 
574
755
  // src/behaviors/fall.ts
575
- var import_zod8 = require("zod");
576
- var fallOptionsSchema = import_zod8.z.object({
756
+ var import_zod9 = require("zod");
757
+ var fallOptionsSchema = import_zod9.z.object({
577
758
  /** Air resistance ripple while falling. */
578
- flutter: import_zod8.z.number().min(0).max(1).default(0.6),
759
+ flutter: import_zod9.z.number().min(0).max(1).default(0.6),
579
760
  /** A falling sheet always lifts a corner. */
580
- curl: import_zod8.z.number().min(0).max(1).default(0.3)
761
+ curl: import_zod9.z.number().min(0).max(1).default(0.3)
581
762
  });
582
763
  var fall = {
583
764
  id: "fall",
@@ -609,21 +790,21 @@ var fall = {
609
790
  };
610
791
 
611
792
  // src/behaviors/carry.ts
612
- var import_zod9 = require("zod");
613
- var carryOptionsSchema = import_zod9.z.object({
793
+ var import_zod10 = require("zod");
794
+ var carryOptionsSchema = import_zod10.z.object({
614
795
  /**
615
796
  * The grab point — where the pointer was on the paper at pick time.
616
797
  * 'auto' is resolved by the carry controller (usually the peeled corner:
617
798
  * continuity from peel → carry is the immersion moment).
618
799
  */
619
- grab: import_zod9.z.enum([...cornerNames, "auto"]).default("auto"),
800
+ grab: import_zod10.z.enum([...cornerNames, "auto"]).default("auto"),
620
801
  /** From stock feel: a stamp is stiff — it flutters, it doesn't flow. */
621
- stiffness: import_zod9.z.number().min(0).max(1).default(0.7),
622
- flutter: import_zod9.z.number().min(0).max(1).default(0.5),
802
+ stiffness: import_zod10.z.number().min(0).max(1).default(0.7),
803
+ flutter: import_zod10.z.number().min(0).max(1).default(0.5),
623
804
  /** How far the paper's yaw trails the drag direction (runtime transform). */
624
- lag: import_zod9.z.number().min(0).max(1).default(0.35),
805
+ lag: import_zod10.z.number().min(0).max(1).default(0.35),
625
806
  /** Drag-speed drive (0..1). Written live by the carry controller. */
626
- drive: import_zod9.z.number().min(0).max(1).default(0.25)
807
+ drive: import_zod10.z.number().min(0).max(1).default(0.25)
627
808
  });
628
809
  var concreteGrab = (g) => g === "auto" ? "top-left" : g;
629
810
  var DROOP_ANGLE = {
@@ -670,7 +851,7 @@ var carry = {
670
851
  };
671
852
 
672
853
  // src/behaviors/flight.ts
673
- var import_zod10 = require("zod");
854
+ var import_zod11 = require("zod");
674
855
 
675
856
  // src/physics/aero.ts
676
857
  function dampTo(state, target, smoothing, dt) {
@@ -712,17 +893,17 @@ function carryDrive(speed) {
712
893
  }
713
894
 
714
895
  // src/behaviors/flight.ts
715
- var flightOptionsSchema = import_zod10.z.object({
896
+ var flightOptionsSchema = import_zod11.z.object({
716
897
  /** Directional wind vector — paper travels ACROSS the scene, not just down. */
717
- wind: import_zod10.z.tuple([import_zod10.z.number().min(-2).max(2), import_zod10.z.number().min(-2).max(2), import_zod10.z.number().min(-2).max(2)]).default([0.6, 0.08, 0]),
718
- gustiness: import_zod10.z.number().min(0).max(1).default(0.4),
719
- tumble: import_zod10.z.number().min(0).max(1).default(0.6),
898
+ wind: import_zod11.z.tuple([import_zod11.z.number().min(-2).max(2), import_zod11.z.number().min(-2).max(2), import_zod11.z.number().min(-2).max(2)]).default([0.6, 0.08, 0]),
899
+ gustiness: import_zod11.z.number().min(0).max(1).default(0.4),
900
+ tumble: import_zod11.z.number().min(0).max(1).default(0.6),
720
901
  /** 'loop' is a seamless idle cycle; 'drift' travels along the wind. */
721
- path: import_zod10.z.enum(["drift", "loop"]).default("drift"),
902
+ path: import_zod11.z.enum(["drift", "loop"]).default("drift"),
722
903
  /** Drift only: exit the scene → re-enter the opposite side. */
723
- respawn: import_zod10.z.boolean().default(true),
904
+ respawn: import_zod11.z.boolean().default(true),
724
905
  /** Half-extent of the travel before respawn wraps it. */
725
- range: import_zod10.z.number().min(0.5).max(12).default(3.5)
906
+ range: import_zod11.z.number().min(0.5).max(12).default(3.5)
726
907
  });
727
908
  var flight = {
728
909
  id: "flight",
@@ -754,16 +935,16 @@ var flight = {
754
935
  };
755
936
 
756
937
  // src/behaviors/crumple.ts
757
- var import_zod11 = require("zod");
758
- var crumpleBehaviorOptionsSchema = import_zod11.z.object({
938
+ var import_zod12 = require("zod");
939
+ var crumpleBehaviorOptionsSchema = import_zod12.z.object({
759
940
  /** 0 = flat sheet, 1 = crushed. */
760
- progress: import_zod11.z.number().min(0).max(1).default(0.55),
941
+ progress: import_zod12.z.number().min(0).max(1).default(0.55),
761
942
  /** Few big facets at 0, many small ones at 1. */
762
- coarseness: import_zod11.z.number().min(0).max(1).default(0.35),
943
+ coarseness: import_zod12.z.number().min(0).max(1).default(0.35),
763
944
  /** How far the sheet curls in on itself as it crushes. */
764
- ball: import_zod11.z.number().min(0).max(1).default(0.5),
945
+ ball: import_zod12.z.number().min(0).max(1).default(0.5),
765
946
  /** A different crush of the same paper. */
766
- seed: import_zod11.z.number().int().min(0).max(7).default(0)
947
+ seed: import_zod12.z.number().int().min(0).max(7).default(0)
767
948
  });
768
949
  var crumpleBehavior = {
769
950
  id: "crumple",
@@ -796,8 +977,8 @@ var crumpleBehavior = {
796
977
  };
797
978
 
798
979
  // src/behaviors/settle.ts
799
- var import_zod12 = require("zod");
800
- var settleOptionsSchema = import_zod12.z.object({
980
+ var import_zod13 = require("zod");
981
+ var settleOptionsSchema = import_zod13.z.object({
801
982
  /**
802
983
  * How long ago it landed, 0..1.
803
984
  *
@@ -805,7 +986,7 @@ var settleOptionsSchema = import_zod12.z.object({
805
986
  * a sheet that has been lying there, where its own weight has flattened
806
987
  * out everything except what its stiffness refuses to give up.
807
988
  */
808
- relax: import_zod12.z.number().min(0).max(1).default(0.45),
989
+ relax: import_zod13.z.number().min(0).max(1).default(0.45),
809
990
  /**
810
991
  * How hard the paper resists lying flat, 0..1.
811
992
  *
@@ -813,14 +994,14 @@ var settleOptionsSchema = import_zod12.z.object({
813
994
  * never does. It is the whole reason a settled sheet reads as PAPER and
814
995
  * not as a decal — at 0 the mesh is a rectangle painted on the floor.
815
996
  */
816
- lift: import_zod12.z.number().min(0).max(1).default(0.45),
997
+ lift: import_zod13.z.number().min(0).max(1).default(0.45),
817
998
  /** Which corner stayed up. */
818
- corner: import_zod12.z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
999
+ corner: import_zod13.z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
819
1000
  /**
820
1001
  * Slack across the middle — the low, long undulation of a sheet that is
821
1002
  * touching a floor in two places and bridging between them.
822
1003
  */
823
- slack: import_zod12.z.number().min(0).max(1).default(0.4)
1004
+ slack: import_zod13.z.number().min(0).max(1).default(0.4)
824
1005
  });
825
1006
  var settle = {
826
1007
  id: "settle",
@@ -873,8 +1054,8 @@ var settle = {
873
1054
  };
874
1055
 
875
1056
  // src/behaviors/ribbon.ts
876
- var import_zod13 = require("zod");
877
- var ribbonOptionsSchema = import_zod13.z.object({
1057
+ var import_zod14 = require("zod");
1058
+ var ribbonOptionsSchema = import_zod14.z.object({
878
1059
  /**
879
1060
  * How much of the drop is lying on the floor, as a fraction of the height.
880
1061
  *
@@ -883,11 +1064,11 @@ var ribbonOptionsSchema = import_zod13.z.object({
883
1064
  * turns over reads as paper meeting a floor, which is the thing the
884
1065
  * reference installations are actually about.
885
1066
  */
886
- pool: import_zod13.z.number().min(0).max(0.5).default(0.16),
1067
+ pool: import_zod14.z.number().min(0).max(0.5).default(0.16),
887
1068
  /** How tightly it turns where it lands. Low is a soft slump, high is a curl. */
888
- curl: import_zod13.z.number().min(0).max(1).default(0.45),
1069
+ curl: import_zod14.z.number().min(0).max(1).default(0.45),
889
1070
  /** Folds running down the length. A printed strip is never a flat plane. */
890
- drape: import_zod13.z.number().min(0).max(1).default(0.5)
1071
+ drape: import_zod14.z.number().min(0).max(1).default(0.5)
891
1072
  });
892
1073
  var ribbon = {
893
1074
  id: "ribbon",
@@ -995,12 +1176,12 @@ var ribbon = {
995
1176
  };
996
1177
 
997
1178
  // src/config/schema.ts
998
- var sheetSchema = import_zod14.z.object({
1179
+ var sheetSchema = import_zod15.z.object({
999
1180
  /** World units. A letter sheet is ~1 × 1.4, a receipt ~1 × 2.6. */
1000
- width: import_zod14.z.number().positive().max(20).default(1),
1001
- height: import_zod14.z.number().positive().max(20).default(1.4),
1181
+ width: import_zod15.z.number().positive().max(20).default(1),
1182
+ height: import_zod15.z.number().positive().max(20).default(1.4),
1002
1183
  /** Visual thickness in mm-ish units; drives edge/shadow treatment, not geometry (yet). */
1003
- thickness: import_zod14.z.number().min(0).max(2).default(0.2),
1184
+ thickness: import_zod15.z.number().min(0).max(2).default(0.2),
1004
1185
  /**
1005
1186
  * `'auto'` sizes the grid from the active deformers' needs — genuinely, as
1006
1187
  * of 0.3.0. It asks each one what these options require (a gentle bend and
@@ -1016,8 +1197,8 @@ var sheetSchema = import_zod14.z.object({
1016
1197
  * still raises it, because that is a correctness floor rather than a
1017
1198
  * preference.
1018
1199
  */
1019
- segments: import_zod14.z.union([import_zod14.z.literal("auto"), import_zod14.z.number().int().min(2).max(256)]).default("auto"),
1020
- cornerRadius: import_zod14.z.number().min(0).max(0.5).default(0)
1200
+ segments: import_zod15.z.union([import_zod15.z.literal("auto"), import_zod15.z.number().int().min(2).max(256)]).default("auto"),
1201
+ cornerRadius: import_zod15.z.number().min(0).max(0.5).default(0)
1021
1202
  });
1022
1203
  var stockNames = [
1023
1204
  "printer",
@@ -1028,37 +1209,37 @@ var stockNames = [
1028
1209
  "photo-gloss",
1029
1210
  "sticker"
1030
1211
  ];
1031
- var stockSchema = import_zod14.z.enum(stockNames);
1032
- var washSchema = import_zod14.z.object({
1212
+ var stockSchema = import_zod15.z.enum(stockNames);
1213
+ var washSchema = import_zod15.z.object({
1033
1214
  /** The first pigment. */
1034
- color: import_zod14.z.string().default("#4a5b8c").describe("color"),
1215
+ color: import_zod15.z.string().default("#4a5b8c").describe("color"),
1035
1216
  /** The second. Blooms alternate, and overlaps multiply into a third. */
1036
- secondary: import_zod14.z.string().default("#b06a6a").describe("color"),
1217
+ secondary: import_zod15.z.string().default("#b06a6a").describe("color"),
1037
1218
  /** How many pools of colour. */
1038
- blooms: import_zod14.z.number().int().min(1).max(24).default(7),
1219
+ blooms: import_zod15.z.number().int().min(1).max(24).default(7),
1039
1220
  /** How far a pool runs before it dries — its size against the sheet. */
1040
- spread: import_zod14.z.number().min(0.1).max(1).default(0.7),
1221
+ spread: import_zod15.z.number().min(0.1).max(1).default(0.7),
1041
1222
  /** Softness of the wet edge. 0 is a hard cut, 1 is a pool still moving. */
1042
- bleed: import_zod14.z.number().min(0).max(1).default(0.5),
1223
+ bleed: import_zod15.z.number().min(0).max(1).default(0.5),
1043
1224
  /** How much pigment is in the water. */
1044
- intensity: import_zod14.z.number().min(0).max(1).default(0.55),
1225
+ intensity: import_zod15.z.number().min(0).max(1).default(0.55),
1045
1226
  /**
1046
1227
  * Edge darkening — the ring of pigment left where a pool dried.
1047
1228
  *
1048
1229
  * The signature of the medium, and the one thing a plain gradient cannot
1049
1230
  * fake. Without it a wash reads as an airbrush.
1050
1231
  */
1051
- edge: import_zod14.z.number().min(0).max(1).default(0.6),
1232
+ edge: import_zod15.z.number().min(0).max(1).default(0.6),
1052
1233
  /** Pigment settling into the tooth of the paper. */
1053
- granulation: import_zod14.z.number().min(0).max(1).default(0.35),
1234
+ granulation: import_zod15.z.number().min(0).max(1).default(0.35),
1054
1235
  /** Fixed so a preset paints the same wash every time. */
1055
- seed: import_zod14.z.number().int().min(0).max(99).default(0)
1236
+ seed: import_zod15.z.number().int().min(0).max(99).default(0)
1056
1237
  });
1057
- var blankContentBase = import_zod14.z.object({
1058
- type: import_zod14.z.literal("blank")
1238
+ var blankContentBase = import_zod15.z.object({
1239
+ type: import_zod15.z.literal("blank")
1059
1240
  });
1060
- var imageContentBase = import_zod14.z.object({
1061
- type: import_zod14.z.literal("image"),
1241
+ var imageContentBase = import_zod15.z.object({
1242
+ type: import_zod15.z.literal("image"),
1062
1243
  /**
1063
1244
  * Empty means "no picture yet", and renders as bare stock rather than as
1064
1245
  * a failure. That is what lets a built-in preset be an image preset
@@ -1066,30 +1247,30 @@ var imageContentBase = import_zod14.z.object({
1066
1247
  * `postage-stamp` are containers for the caller's own art, handed over via
1067
1248
  * `<PaperField images={...} />` or `content.src`.
1068
1249
  */
1069
- src: import_zod14.z.string().default(""),
1070
- fit: import_zod14.z.enum(["cover", "contain"]).default("cover"),
1250
+ src: import_zod15.z.string().default(""),
1251
+ fit: import_zod15.z.enum(["cover", "contain"]).default("cover"),
1071
1252
  /** Read by the hidden DOM mirror and the no-WebGL fallback. */
1072
- alt: import_zod14.z.string().optional()
1253
+ alt: import_zod15.z.string().optional()
1073
1254
  });
1074
- var textContentBase = import_zod14.z.object({
1075
- type: import_zod14.z.literal("text"),
1076
- text: import_zod14.z.string().default("Dear reader,"),
1077
- font: import_zod14.z.string().default('Georgia, "Times New Roman", serif'),
1255
+ var textContentBase = import_zod15.z.object({
1256
+ type: import_zod15.z.literal("text"),
1257
+ text: import_zod15.z.string().default("Dear reader,"),
1258
+ font: import_zod15.z.string().default('Georgia, "Times New Roman", serif'),
1078
1259
  /** px at texture resolution (long edge = 1024 logical px before DPR). */
1079
- size: import_zod14.z.number().min(8).max(256).default(44),
1080
- weight: import_zod14.z.number().min(100).max(900).default(400),
1081
- color: import_zod14.z.string().default("#2b2620").describe("color"),
1082
- align: import_zod14.z.enum(["left", "center", "right"]).default("left"),
1260
+ size: import_zod15.z.number().min(8).max(256).default(44),
1261
+ weight: import_zod15.z.number().min(100).max(900).default(400),
1262
+ color: import_zod15.z.string().default("#2b2620").describe("color"),
1263
+ align: import_zod15.z.enum(["left", "center", "right"]).default("left"),
1083
1264
  /** Fraction of the short edge. */
1084
- padding: import_zod14.z.number().min(0).max(0.4).default(0.09),
1085
- lineHeight: import_zod14.z.number().min(0.8).max(3).default(1.45),
1265
+ padding: import_zod15.z.number().min(0).max(0.4).default(0.09),
1266
+ lineHeight: import_zod15.z.number().min(0.8).max(3).default(1.45),
1086
1267
  /**
1087
1268
  * Letter-spacing, in em. The one control display type cannot do without:
1088
1269
  * a line set large enough to be read across a room needs its tracking
1089
1270
  * pulled IN, and a small line of uppercase small-print needs it pushed
1090
1271
  * out, and neither is achievable by changing the size.
1091
1272
  */
1092
- tracking: import_zod14.z.number().min(-0.1).max(0.6).default(0),
1273
+ tracking: import_zod15.z.number().min(-0.1).max(0.6).default(0),
1093
1274
  /**
1094
1275
  * Where the block sits down the sheet.
1095
1276
  *
@@ -1098,26 +1279,26 @@ var textContentBase = import_zod14.z.object({
1098
1279
  * poster wants — a block of type optically centred in the sheet rather
1099
1280
  * than hung from its top edge.
1100
1281
  */
1101
- valign: import_zod14.z.enum(["top", "center"]).default("top")
1282
+ valign: import_zod15.z.enum(["top", "center"]).default("top")
1102
1283
  });
1103
- var cardContentBase = import_zod14.z.object({
1104
- type: import_zod14.z.literal("card"),
1284
+ var cardContentBase = import_zod15.z.object({
1285
+ type: import_zod15.z.literal("card"),
1105
1286
  /** Small, tracked, uppercase by convention — the label at the top. */
1106
- title: import_zod14.z.string().default(""),
1287
+ title: import_zod15.z.string().default(""),
1107
1288
  /** The card's reason for existing. */
1108
- body: import_zod14.z.string().default(""),
1289
+ body: import_zod15.z.string().default(""),
1109
1290
  /** Attribution, catalogue number, date — the line in small print at the foot. */
1110
- note: import_zod14.z.string().default(""),
1291
+ note: import_zod15.z.string().default(""),
1111
1292
  /** A hairline under the title. What separates a label from a paragraph. */
1112
- rule: import_zod14.z.boolean().default(true),
1293
+ rule: import_zod15.z.boolean().default(true),
1113
1294
  /**
1114
1295
  * Ruled writing lines behind the body, as on an index card.
1115
1296
  *
1116
1297
  * Drawn UNDER the type and in the stock's own ink at low alpha, so they
1117
1298
  * read as printed on the card rather than as underlines on the words.
1118
1299
  */
1119
- ruled: import_zod14.z.boolean().default(false),
1120
- font: import_zod14.z.string().default('Georgia, "Times New Roman", serif'),
1300
+ ruled: import_zod15.z.boolean().default(false),
1301
+ font: import_zod15.z.string().default('Georgia, "Times New Roman", serif'),
1121
1302
  /**
1122
1303
  * Body size, px at texture resolution. Title and note derive from it.
1123
1304
  *
@@ -1127,28 +1308,28 @@ var cardContentBase = import_zod14.z.object({
1127
1308
  * third of the stock empty above and below it, which reads as a page that
1128
1309
  * was cropped rather than as a card that was set.
1129
1310
  */
1130
- size: import_zod14.z.number().min(8).max(256).default(58),
1131
- color: import_zod14.z.string().default("#2b2620").describe("color"),
1132
- align: import_zod14.z.enum(["left", "center"]).default("left"),
1133
- padding: import_zod14.z.number().min(0).max(0.4).default(0.1)
1311
+ size: import_zod15.z.number().min(8).max(256).default(58),
1312
+ color: import_zod15.z.string().default("#2b2620").describe("color"),
1313
+ align: import_zod15.z.enum(["left", "center"]).default("left"),
1314
+ padding: import_zod15.z.number().min(0).max(0.4).default(0.1)
1134
1315
  });
1135
- var receiptContentBase = import_zod14.z.object({
1136
- type: import_zod14.z.literal("receipt"),
1137
- store: import_zod14.z.string().default("PAPERLAB"),
1138
- address: import_zod14.z.string().default("124 PAPER ST"),
1139
- items: import_zod14.z.array(import_zod14.z.object({ name: import_zod14.z.string(), price: import_zod14.z.number() })).default([
1316
+ var receiptContentBase = import_zod15.z.object({
1317
+ type: import_zod15.z.literal("receipt"),
1318
+ store: import_zod15.z.string().default("PAPERLAB"),
1319
+ address: import_zod15.z.string().default("124 PAPER ST"),
1320
+ items: import_zod15.z.array(import_zod15.z.object({ name: import_zod15.z.string(), price: import_zod15.z.number() })).default([
1140
1321
  { name: "CURL, TRUE", price: 12 },
1141
1322
  { name: "ROLL, TIGHT", price: 8.5 },
1142
1323
  { name: "SHEET, ONE", price: 0.99 }
1143
1324
  ]),
1144
- taxRate: import_zod14.z.number().min(0).max(1).default(0.08),
1145
- barcode: import_zod14.z.boolean().default(true),
1325
+ taxRate: import_zod15.z.number().min(0).max(1).default(0.08),
1326
+ barcode: import_zod15.z.boolean().default(true),
1146
1327
  /** Fixed so presets render deterministically; omit for "now". */
1147
- timestamp: import_zod14.z.string().optional(),
1148
- footer: import_zod14.z.string().default("KEEP FOR YOUR RECORDS")
1328
+ timestamp: import_zod15.z.string().optional(),
1329
+ footer: import_zod15.z.string().default("KEEP FOR YOUR RECORDS")
1149
1330
  });
1150
1331
  var withWash = { wash: washSchema.optional() };
1151
- var backContentSchema = import_zod14.z.discriminatedUnion("type", [
1332
+ var backContentSchema = import_zod15.z.discriminatedUnion("type", [
1152
1333
  blankContentBase.extend(withWash),
1153
1334
  imageContentBase.extend(withWash),
1154
1335
  textContentBase.extend(withWash),
@@ -1161,7 +1342,7 @@ var imageContentSchema = imageContentBase.extend(withBack);
1161
1342
  var textContentSchema = textContentBase.extend(withBack);
1162
1343
  var cardContentSchema = cardContentBase.extend(withBack);
1163
1344
  var receiptContentSchema = receiptContentBase.extend(withBack);
1164
- var contentSchema = import_zod14.z.discriminatedUnion("type", [
1345
+ var contentSchema = import_zod15.z.discriminatedUnion("type", [
1165
1346
  blankContentSchema,
1166
1347
  imageContentSchema,
1167
1348
  textContentSchema,
@@ -1177,80 +1358,146 @@ function contentSchemaFor(type) {
1177
1358
  return option;
1178
1359
  }
1179
1360
  var paperEdges = ["top", "right", "bottom", "left"];
1180
- var surfaceSchema = import_zod14.z.object({
1361
+ var surfaceSchema = import_zod15.z.object({
1181
1362
  /** Paper fiber noise, 0..1. */
1182
- grain: import_zod14.z.number().min(0).max(1).optional(),
1363
+ grain: import_zod15.z.number().min(0).max(1).optional(),
1183
1364
  /** Light passing through the sheet from behind, 0..1. Stock defaults apply. */
1184
- translucency: import_zod14.z.number().min(0).max(1).optional(),
1365
+ translucency: import_zod15.z.number().min(0).max(1).optional(),
1185
1366
  /** Torn-edge alpha with a lightened fiber band. */
1186
- deckle: import_zod14.z.object({
1187
- edges: import_zod14.z.array(import_zod14.z.enum(paperEdges)).default(["bottom"]),
1188
- roughness: import_zod14.z.number().min(0).max(1).default(0.5)
1367
+ deckle: import_zod15.z.object({
1368
+ edges: import_zod15.z.array(import_zod15.z.enum(paperEdges)).default(["bottom"]),
1369
+ roughness: import_zod15.z.number().min(0).max(1).default(0.5)
1189
1370
  }).optional(),
1190
1371
  /** Visual AO/highlight companion to the fold deformer. */
1191
- creaseLines: import_zod14.z.object({
1372
+ creaseLines: import_zod15.z.object({
1192
1373
  /** Crease line direction, degrees (0 = horizontal lines). */
1193
- angle: import_zod14.z.number().min(-360).max(360).default(0),
1374
+ angle: import_zod15.z.number().min(-360).max(360).default(0),
1194
1375
  /** Positions across the sheet, 0..1 fractions. */
1195
- positions: import_zod14.z.array(import_zod14.z.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1196
- strength: import_zod14.z.number().min(0).max(1).default(0.5)
1376
+ positions: import_zod15.z.array(import_zod15.z.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1377
+ strength: import_zod15.z.number().min(0).max(1).default(0.5)
1197
1378
  }).optional(),
1198
1379
  /** Yellowing + foxing spots, 0..1. */
1199
- aging: import_zod14.z.number().min(0).max(1).optional(),
1380
+ aging: import_zod15.z.number().min(0).max(1).optional(),
1200
1381
  /** Reversed front-content ghost on the backside, 0..1. Stock defaults apply. */
1201
- showThrough: import_zod14.z.number().min(0).max(1).optional(),
1382
+ showThrough: import_zod15.z.number().min(0).max(1).optional(),
1202
1383
  /**
1203
1384
  * Postage-stamp perforation: alpha-punched semicircular holes along chosen
1204
1385
  * edges. `state` flips an edge to a ripped-through profile (torn) — set
1205
1386
  * automatically when a paper detaches from a `sheet` field, manual wins.
1206
1387
  */
1207
- perforation: import_zod14.z.object({
1208
- edges: import_zod14.z.union([import_zod14.z.array(import_zod14.z.enum(paperEdges)), import_zod14.z.literal("all")]).default("all"),
1388
+ perforation: import_zod15.z.object({
1389
+ edges: import_zod15.z.union([import_zod15.z.array(import_zod15.z.enum(paperEdges)), import_zod15.z.literal("all")]).default("all"),
1209
1390
  /** World units — default tuned to stamp scale. */
1210
- holeRadius: import_zod14.z.number().min(2e-3).max(0.1).default(0.016),
1211
- spacing: import_zod14.z.number().min(0.01).max(0.5).default(0.055),
1212
- state: import_zod14.z.object({
1213
- top: import_zod14.z.enum(["intact", "torn"]).optional(),
1214
- right: import_zod14.z.enum(["intact", "torn"]).optional(),
1215
- bottom: import_zod14.z.enum(["intact", "torn"]).optional(),
1216
- left: import_zod14.z.enum(["intact", "torn"]).optional()
1391
+ holeRadius: import_zod15.z.number().min(2e-3).max(0.1).default(0.016),
1392
+ spacing: import_zod15.z.number().min(0.01).max(0.5).default(0.055),
1393
+ state: import_zod15.z.object({
1394
+ top: import_zod15.z.enum(["intact", "torn"]).optional(),
1395
+ right: import_zod15.z.enum(["intact", "torn"]).optional(),
1396
+ bottom: import_zod15.z.enum(["intact", "torn"]).optional(),
1397
+ left: import_zod15.z.enum(["intact", "torn"]).optional()
1217
1398
  }).default({})
1218
1399
  }).optional()
1219
1400
  });
1220
- var behaviorConfigSchema = import_zod14.z.discriminatedUnion("type", [
1221
- peelOptionsSchema.extend({ type: import_zod14.z.literal("peel") }),
1222
- unrollOptionsSchema.extend({ type: import_zod14.z.literal("unroll") }),
1223
- flipOptionsSchema.extend({ type: import_zod14.z.literal("flip") }),
1224
- letterFoldOptionsSchema.extend({ type: import_zod14.z.literal("letter-fold") }),
1225
- hangOptionsSchema.extend({ type: import_zod14.z.literal("hang") }),
1226
- flyOptionsSchema.extend({ type: import_zod14.z.literal("fly") }),
1227
- fallOptionsSchema.extend({ type: import_zod14.z.literal("fall") }),
1228
- carryOptionsSchema.extend({ type: import_zod14.z.literal("carry") }),
1229
- flightOptionsSchema.extend({ type: import_zod14.z.literal("flight") }),
1230
- crumpleBehaviorOptionsSchema.extend({ type: import_zod14.z.literal("crumple") }),
1231
- settleOptionsSchema.extend({ type: import_zod14.z.literal("settle") }),
1232
- ribbonOptionsSchema.extend({ type: import_zod14.z.literal("ribbon") })
1401
+ var creaseSchema = import_zod15.z.object({
1402
+ angle: import_zod15.z.number().min(-360).max(360).default(90),
1403
+ offset: import_zod15.z.number().min(-20).max(20).default(0),
1404
+ /**
1405
+ * The residual fold angle in degrees, signed — how far open the crease
1406
+ * still sits once nothing is holding the paper. This is the whole of what
1407
+ * a crease IS: geometry and shading both read it, and it is what makes the
1408
+ * field authorable by hand (a dog-ear is a crease with a big `depth` near
1409
+ * a corner) rather than only recordable.
1410
+ */
1411
+ depth: import_zod15.z.number().min(-180).max(180).default(12)
1412
+ });
1413
+ var memorySchema = import_zod15.z.object({
1414
+ /**
1415
+ * How much of a fold this paper keeps, 0..1, over the stock's own
1416
+ * {@link Stock.takesSet}. Kraft holds a crease hard; vellum springs most
1417
+ * of the way back.
1418
+ */
1419
+ set: import_zod15.z.number().min(0).max(1).optional(),
1420
+ /**
1421
+ * The creases themselves. Recorded by folding the paper (see
1422
+ * `onCrease`), or written by hand — a preset can ship already creased.
1423
+ *
1424
+ * Capped at four because that is what the crease shader carries, and a
1425
+ * cap the schema states is better than one the renderer applies silently.
1426
+ */
1427
+ creases: import_zod15.z.array(creaseSchema).max(4).default([])
1428
+ });
1429
+ var behaviorConfigSchema = import_zod15.z.discriminatedUnion("type", [
1430
+ peelOptionsSchema.extend({ type: import_zod15.z.literal("peel") }),
1431
+ unrollOptionsSchema.extend({ type: import_zod15.z.literal("unroll") }),
1432
+ flipOptionsSchema.extend({ type: import_zod15.z.literal("flip") }),
1433
+ letterFoldOptionsSchema.extend({ type: import_zod15.z.literal("letter-fold") }),
1434
+ hangOptionsSchema.extend({ type: import_zod15.z.literal("hang") }),
1435
+ flyOptionsSchema.extend({ type: import_zod15.z.literal("fly") }),
1436
+ fallOptionsSchema.extend({ type: import_zod15.z.literal("fall") }),
1437
+ carryOptionsSchema.extend({ type: import_zod15.z.literal("carry") }),
1438
+ flightOptionsSchema.extend({ type: import_zod15.z.literal("flight") }),
1439
+ crumpleBehaviorOptionsSchema.extend({ type: import_zod15.z.literal("crumple") }),
1440
+ settleOptionsSchema.extend({ type: import_zod15.z.literal("settle") }),
1441
+ ribbonOptionsSchema.extend({ type: import_zod15.z.literal("ribbon") })
1233
1442
  ]);
1234
- var deformerInstanceSchema = import_zod14.z.object({
1235
- type: import_zod14.z.string(),
1236
- options: import_zod14.z.record(import_zod14.z.unknown()).default({}),
1237
- enabled: import_zod14.z.boolean().default(true)
1443
+ var deformerInstanceSchema = import_zod15.z.object({
1444
+ type: import_zod15.z.string(),
1445
+ options: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.unknown()).default({}),
1446
+ enabled: import_zod15.z.boolean().default(true)
1238
1447
  });
1239
1448
  var physicsNames = ["none", "float", "tumble", "dangle", "taped", "breeze"];
1240
- var clothConfigSchema = import_zod14.z.object({
1241
- type: import_zod14.z.literal("cloth"),
1242
- pins: import_zod14.z.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1243
- wind: import_zod14.z.number().min(0).max(1).default(0.3),
1449
+ var clothConfigSchema = import_zod15.z.object({
1450
+ type: import_zod15.z.literal("cloth"),
1451
+ pins: import_zod15.z.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1452
+ wind: import_zod15.z.number().min(0).max(1).default(0.3),
1244
1453
  /** Bend stiffness: 1 = crisp paper, 0 = silk. */
1245
- stiffness: import_zod14.z.number().min(0).max(1).default(0.8),
1246
- gravity: import_zod14.z.number().min(0).max(2).default(1),
1454
+ stiffness: import_zod15.z.number().min(0).max(1).default(0.8),
1455
+ gravity: import_zod15.z.number().min(0).max(2).default(1),
1247
1456
  /** Local-space ground plane the sheet settles onto. */
1248
- floor: import_zod14.z.number().min(-5).max(0).default(-1.4)
1457
+ floor: import_zod15.z.number().min(-5).max(0).default(-1.4)
1249
1458
  });
1250
- var physicsSchema = import_zod14.z.union([
1251
- import_zod14.z.enum(physicsNames),
1252
- import_zod14.z.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1253
- clothConfigSchema
1459
+ var stripConfigSchema = import_zod15.z.object({
1460
+ type: import_zod15.z.literal("strip"),
1461
+ /** How far the page has scrolled, in world units of paper asked for. */
1462
+ scroll: import_zod15.z.number().min(-1e3).max(1e3).default(0),
1463
+ /** Thin layers and many turns, or few and fat. */
1464
+ tightness: import_zod15.z.number().min(0).max(1).default(0.6),
1465
+ /** Radius of the cardboard tube — the roll never pays out past it. */
1466
+ core: import_zod15.z.number().min(0.01).max(0.5).default(0.09),
1467
+ /** Paper already hanging before the first scroll. A roll always has a leaf out. */
1468
+ tail: import_zod15.z.number().min(0).max(20).default(1.1),
1469
+ /** Spacing of the perforations: one sheet's worth of strip, in world units. */
1470
+ perforation: import_zod15.z.number().min(0.05).max(5).default(1),
1471
+ /**
1472
+ * How much a perforation remembers being folded. 0 = a fresh roll, 1 = one
1473
+ * that has been used.
1474
+ *
1475
+ * The default is high on purpose: below about 0.6 the landed paper flops
1476
+ * over in flat panels and spreads across the floor, and at 0.7 it holds its
1477
+ * folds and stacks into an accordion. The pile is the point.
1478
+ */
1479
+ crease: import_zod15.z.number().min(0).max(1).default(0.7),
1480
+ /** Bend stiffness between perforations. 1 = card, 0 = cloth. */
1481
+ stiffness: import_zod15.z.number().min(0).max(1).default(0.55),
1482
+ /** Broadside air drag — what makes paper float down rather than drop. */
1483
+ drag: import_zod15.z.number().min(0).max(1).default(0.55),
1484
+ gravity: import_zod15.z.number().min(0).max(2).default(1),
1485
+ /**
1486
+ * How far below the roll the paper lands. A DISTANCE below the roll's axis,
1487
+ * matching `unroll.floor`, not a signed y like `cloth.floor` — the roll
1488
+ * family measures drops, and the composition is centred on the origin so
1489
+ * an absolute y would not survive the offset anyway.
1490
+ */
1491
+ floor: import_zod15.z.number().min(0.1).max(30).default(1.2),
1492
+ /** How long the roll coasts after the scroll stops. */
1493
+ inertia: import_zod15.z.number().min(0).max(1).default(0.45)
1494
+ });
1495
+ var physicsSchema = import_zod15.z.union([
1496
+ import_zod15.z.enum(physicsNames),
1497
+ import_zod15.z.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1498
+ import_zod15.z.literal("strip").transform(() => stripConfigSchema.parse({ type: "strip" })),
1499
+ clothConfigSchema,
1500
+ stripConfigSchema
1254
1501
  ]);
1255
1502
  var lightingNames = [
1256
1503
  "studio",
@@ -1263,9 +1510,9 @@ var lightingNames = [
1263
1510
  "lightbox"
1264
1511
  ];
1265
1512
  var filmNames = ["agx", "neutral", "filmic"];
1266
- var lightSchema = import_zod14.z.object({
1513
+ var lightSchema = import_zod15.z.object({
1267
1514
  /** Tone-mapping exposure — the stop the whole picture is printed at. */
1268
- exposure: import_zod14.z.number().min(0.1).max(4).optional(),
1515
+ exposure: import_zod15.z.number().min(0.1).max(4).optional(),
1269
1516
  /**
1270
1517
  * The tone curve — the film, where `exposure` is the stop.
1271
1518
  *
@@ -1274,33 +1521,33 @@ var lightSchema = import_zod14.z.object({
1274
1521
  * the wrong film: it desaturates and drags bright neutrals toward
1275
1522
  * yellow-green, which is the sepia cast a lit sheet used to pick up.
1276
1523
  */
1277
- film: import_zod14.z.enum(filmNames).optional(),
1524
+ film: import_zod15.z.enum(filmNames).optional(),
1278
1525
  /** Key light strength. */
1279
- key: import_zod14.z.number().min(0).max(12).optional(),
1526
+ key: import_zod15.z.number().min(0).max(12).optional(),
1280
1527
  /** Key light colour. */
1281
- color: import_zod14.z.string().optional().describe("color"),
1528
+ color: import_zod15.z.string().optional().describe("color"),
1282
1529
  /**
1283
1530
  * Where the key stands, degrees around the vertical. 0° is straight in
1284
1531
  * front of the paper (+Z, beside the camera), 90° is off to the right,
1285
1532
  * and ±180° is directly behind it — which is where `nave` puts it, and
1286
1533
  * why that preset is carried by light coming THROUGH the paper.
1287
1534
  */
1288
- direction: import_zod14.z.number().min(-180).max(180).optional(),
1535
+ direction: import_zod15.z.number().min(-180).max(180).optional(),
1289
1536
  /** How high the key stands, degrees above the horizon. */
1290
- height: import_zod14.z.number().min(-30).max(89).optional(),
1537
+ height: import_zod15.z.number().min(-30).max(89).optional(),
1291
1538
  /** Flat fill from every direction at once. Cheap, and it kills form — reach for `studio` first. */
1292
- ambient: import_zod14.z.number().min(0).max(2).optional(),
1539
+ ambient: import_zod15.z.number().min(0).max(2).optional(),
1293
1540
  /** The room's own light: an environment map built from `sky`. Directional fill, and the only thing paper's sheen has to reflect. */
1294
- studio: import_zod14.z.number().min(0).max(3).optional(),
1541
+ studio: import_zod15.z.number().min(0).max(3).optional(),
1295
1542
  /** Distance haze, as a multiple of the preset's. 0 clears the air entirely; 2 halves the distance you can see. */
1296
- haze: import_zod14.z.number().min(0).max(3).optional()
1543
+ haze: import_zod15.z.number().min(0).max(3).optional()
1297
1544
  });
1298
- var backdropSchema = import_zod14.z.object({
1545
+ var backdropSchema = import_zod15.z.object({
1299
1546
  /** Behind everything, and behind the picture where it does not reach. */
1300
- color: import_zod14.z.string().default("#171717").describe("color"),
1547
+ color: import_zod15.z.string().default("#171717").describe("color"),
1301
1548
  /** A URL, or an uploaded picture. Empty is the colour on its own. */
1302
- image: import_zod14.z.string().default(""),
1303
- fit: import_zod14.z.enum(["cover", "contain"]).default("cover"),
1549
+ image: import_zod15.z.string().default(""),
1550
+ fit: import_zod15.z.enum(["cover", "contain"]).default("cover"),
1304
1551
  /**
1305
1552
  * Toward the colour, so the paper stays the subject.
1306
1553
  *
@@ -1309,14 +1556,35 @@ var backdropSchema = import_zod14.z.object({
1309
1556
  * of the light, and what this solves by mixing it back toward the ground
1310
1557
  * it sits on.
1311
1558
  */
1312
- fade: import_zod14.z.number().min(0).max(1).default(0.25),
1559
+ fade: import_zod15.z.number().min(0).max(1).default(0.25),
1313
1560
  /** Out of focus, for the same reason. */
1314
- blur: import_zod14.z.number().min(0).max(1).default(0.2)
1561
+ blur: import_zod15.z.number().min(0).max(1).default(0.2)
1315
1562
  });
1316
- var sceneSchema = import_zod14.z.object({
1317
- lighting: import_zod14.z.enum(lightingNames).default("studio"),
1563
+ var sceneSchema = import_zod15.z.object({
1564
+ lighting: import_zod15.z.enum(lightingNames).default("studio"),
1318
1565
  /** What is behind the sheet. Unset leaves the canvas alone. */
1319
1566
  backdrop: backdropSchema.optional(),
1567
+ /**
1568
+ * Degrees the whole composition is turned about its vertical axis, so a
1569
+ * preset can choose the angle it is READ from.
1570
+ *
1571
+ * Every camera in the library is fixed and head-on — `<Paper>` sits at
1572
+ * `(0, 0.35, 2.4)` looking down -Z, and neither it nor the editor fits a
1573
+ * camera to its content. That is the right default for a sheet, which is
1574
+ * flat and faces you. It is the wrong one for anything whose shape lives
1575
+ * in DEPTH: the `strip` sim folds in z by construction, so head-on its
1576
+ * roll and the whole accordion of its pile are edge-on and the preset
1577
+ * renders as a blank white column.
1578
+ *
1579
+ * A camera field would have been the other way to fix it, and is worse: it
1580
+ * is meaningless inside `<PaperField>` and `<PaperMesh>`, where the caller
1581
+ * owns the camera and there may be a dozen papers sharing it. Turning the
1582
+ * paper works everywhere, because it is a property of the paper.
1583
+ *
1584
+ * Additive with the `rotation` prop rather than overriding it — the prop
1585
+ * is the caller's, and a preset does not get to overrule it.
1586
+ */
1587
+ turn: import_zod15.z.number().min(-180).max(180).default(0),
1320
1588
  /**
1321
1589
  * Overrides on the named preset — the same authorable half stage mode has
1322
1590
  * always had, and which a lone sheet had no way to reach.
@@ -1330,56 +1598,65 @@ var sceneSchema = import_zod14.z.object({
1330
1598
  });
1331
1599
  var coreStateNames = ["rest", "hover", "pressed", "picked", "placed"];
1332
1600
  var isStateName = (s) => coreStateNames.includes(s) || s.startsWith("custom:");
1333
- var stateNameSchema = import_zod14.z.string().refine(isStateName, {
1601
+ var stateNameSchema = import_zod15.z.string().refine(isStateName, {
1334
1602
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1335
1603
  });
1336
- var stateTransitionSchema = import_zod14.z.object({
1337
- duration: import_zod14.z.number().min(0).max(5).default(0.35),
1604
+ var stateTransitionSchema = import_zod15.z.object({
1605
+ duration: import_zod15.z.number().min(0).max(5).default(0.35),
1338
1606
  /** GSAP ease name. */
1339
- ease: import_zod14.z.string().default("power2.out")
1607
+ ease: import_zod15.z.string().default("power2.out")
1340
1608
  });
1341
- var stateDefSchema = import_zod14.z.object({
1609
+ var stateDefSchema = import_zod15.z.object({
1342
1610
  /** Deep-partial override of the paper schema (behavior params, surface, …). */
1343
- overrides: import_zod14.z.record(import_zod14.z.unknown()).default({}),
1611
+ overrides: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.unknown()).default({}),
1344
1612
  /** Transition INTO this state. */
1345
- transition: stateTransitionSchema.default({}),
1613
+ transition: stateTransitionSchema.prefault({}),
1346
1614
  /** Chained actions after arriving. v1: 'emit:<event>' only. */
1347
- onEnter: import_zod14.z.array(import_zod14.z.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1615
+ onEnter: import_zod15.z.array(import_zod15.z.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1348
1616
  });
1349
- var paperStatesSchema = import_zod14.z.object({
1617
+ var paperStatesSchema = import_zod15.z.object({
1350
1618
  initial: stateNameSchema.default("rest"),
1351
- states: import_zod14.z.record(import_zod14.z.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1619
+ states: import_zod15.z.record(import_zod15.z.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1352
1620
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1353
1621
  }),
1354
1622
  /** World-units drag distance that flips pressed → picked (pick-enabled behaviors only). */
1355
- pickThreshold: import_zod14.z.number().min(5e-3).max(1).default(0.1)
1623
+ pickThreshold: import_zod15.z.number().min(5e-3).max(1).default(0.1)
1356
1624
  });
1357
- var metaSchema = import_zod14.z.object({
1358
- name: import_zod14.z.string().default("untitled"),
1359
- author: import_zod14.z.string().optional(),
1360
- version: import_zod14.z.string().default("0"),
1361
- tags: import_zod14.z.array(import_zod14.z.string()).default([])
1625
+ var metaSchema = import_zod15.z.object({
1626
+ name: import_zod15.z.string().default("untitled"),
1627
+ author: import_zod15.z.string().optional(),
1628
+ version: import_zod15.z.string().default("0"),
1629
+ tags: import_zod15.z.array(import_zod15.z.string()).default([])
1362
1630
  });
1363
- var paperConfigSchema = import_zod14.z.object({
1364
- meta: metaSchema.default({}),
1365
- sheet: sheetSchema.default({}),
1631
+ var paperConfigSchema = import_zod15.z.object({
1632
+ meta: metaSchema.prefault({}),
1633
+ sheet: sheetSchema.prefault({}),
1366
1634
  stock: stockSchema.default("printer"),
1367
1635
  content: contentSchema.default({ type: "blank" }),
1368
1636
  /** A behavior OR a raw deformer stack — if both are present, `deformers` wins (it's the fork). */
1369
1637
  behavior: behaviorConfigSchema.optional(),
1370
- deformers: import_zod14.z.array(deformerInstanceSchema).optional(),
1638
+ deformers: import_zod15.z.array(deformerInstanceSchema).optional(),
1371
1639
  surface: surfaceSchema.default({}),
1640
+ /**
1641
+ * What the sheet remembers being folded — creases outlive the fold.
1642
+ *
1643
+ * Present by default, and on: paper that forgets is the bug this exists
1644
+ * to fix, so remembering is not something a preset should have to ask
1645
+ * for. `memory: { set: 0 }` is the opt-out, and it is what every sheet in
1646
+ * the library did before this shipped.
1647
+ */
1648
+ memory: memorySchema.prefault({}),
1372
1649
  physics: physicsSchema.default("none"),
1373
- scene: sceneSchema.default({}),
1374
- onTwos: import_zod14.z.boolean().default(false),
1650
+ scene: sceneSchema.prefault({}),
1651
+ onTwos: import_zod15.z.boolean().default(false),
1375
1652
  /** Interaction state machine — overrides-on-base diffs. */
1376
1653
  states: paperStatesSchema.optional()
1377
1654
  }).superRefine((config, ctx) => {
1378
- if (typeof config.physics === "object" && (config.behavior || config.deformers)) {
1655
+ if (typeof config.physics === "object" && config.physics.type === "strip" && (config.behavior || config.deformers)) {
1379
1656
  ctx.addIssue({
1380
- code: import_zod14.z.ZodIssueCode.custom,
1657
+ code: import_zod15.z.ZodIssueCode.custom,
1381
1658
  path: ["physics"],
1382
- message: "cloth physics and behavior/deformers are exclusive \u2014 cloth owns the vertices (pick Shape OR Simulation)"
1659
+ 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)"
1383
1660
  });
1384
1661
  }
1385
1662
  if (config.states) {
@@ -1388,7 +1665,7 @@ var paperConfigSchema = import_zod14.z.object({
1388
1665
  if (!def) continue;
1389
1666
  if (def.overrides.states !== void 0) {
1390
1667
  ctx.addIssue({
1391
- code: import_zod14.z.ZodIssueCode.custom,
1668
+ code: import_zod15.z.ZodIssueCode.custom,
1392
1669
  path: ["states", "states", name, "overrides"],
1393
1670
  message: "state overrides cannot override `states` (no nested state machines)"
1394
1671
  });
@@ -1399,7 +1676,7 @@ var paperConfigSchema = import_zod14.z.object({
1399
1676
  if (!result.success) {
1400
1677
  const first = result.error.issues[0];
1401
1678
  ctx.addIssue({
1402
- code: import_zod14.z.ZodIssueCode.custom,
1679
+ code: import_zod15.z.ZodIssueCode.custom,
1403
1680
  path: ["states", "states", name, "overrides"],
1404
1681
  message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")} \u2014 ${first.message}` : "invalid"}`
1405
1682
  });
@@ -1529,7 +1806,9 @@ var stocks = {
1529
1806
  banding: 0,
1530
1807
  defaultSurface: { grain: 0.12 },
1531
1808
  showThrough: 0,
1532
- adhesive: false
1809
+ adhesive: false,
1810
+ // Office bond creases cleanly and holds it — the reference paper.
1811
+ takesSet: 0.6
1533
1812
  },
1534
1813
  thermal: {
1535
1814
  id: "thermal",
@@ -1542,7 +1821,9 @@ var stocks = {
1542
1821
  banding: 0.35,
1543
1822
  defaultSurface: { aging: 0.1 },
1544
1823
  showThrough: 0.06,
1545
- adhesive: false
1824
+ adhesive: false,
1825
+ // Thin and already curled off a roll; a fold in it stays folded.
1826
+ takesSet: 0.65
1546
1827
  },
1547
1828
  kraft: {
1548
1829
  id: "kraft",
@@ -1555,7 +1836,9 @@ var stocks = {
1555
1836
  banding: 0,
1556
1837
  defaultSurface: { grain: 0.5 },
1557
1838
  showThrough: 0,
1558
- adhesive: false
1839
+ adhesive: false,
1840
+ // Thick and fibrous. The crease is a break, and it never comes back.
1841
+ takesSet: 0.85
1559
1842
  },
1560
1843
  newsprint: {
1561
1844
  id: "newsprint",
@@ -1568,7 +1851,9 @@ var stocks = {
1568
1851
  banding: 0,
1569
1852
  defaultSurface: { grain: 0.7, aging: 0.15 },
1570
1853
  showThrough: 0.06,
1571
- adhesive: false
1854
+ adhesive: false,
1855
+ // Soft, short-fibred, and barely sprung — it crumples rather than resists.
1856
+ takesSet: 0.8
1572
1857
  },
1573
1858
  vellum: {
1574
1859
  id: "vellum",
@@ -1581,7 +1866,9 @@ var stocks = {
1581
1866
  banding: 0,
1582
1867
  defaultSurface: {},
1583
1868
  showThrough: 0.55,
1584
- adhesive: false
1869
+ adhesive: false,
1870
+ // Translucent and plasticky: it fights the fold and mostly wins.
1871
+ takesSet: 0.25
1585
1872
  },
1586
1873
  "photo-gloss": {
1587
1874
  id: "photo-gloss",
@@ -1594,7 +1881,9 @@ var stocks = {
1594
1881
  banding: 0,
1595
1882
  defaultSurface: {},
1596
1883
  showThrough: 0,
1597
- adhesive: false
1884
+ adhesive: false,
1885
+ // The coating resists, then cracks white — little angle kept, lots of mark.
1886
+ takesSet: 0.3
1598
1887
  },
1599
1888
  // Photo-gloss-like face, glossy near-white glue underside. The default
1600
1889
  // carrier for perforated stamp sheets.
@@ -1609,7 +1898,9 @@ var stocks = {
1609
1898
  banding: 0,
1610
1899
  defaultSurface: {},
1611
1900
  showThrough: 0,
1612
- adhesive: true
1901
+ adhesive: true,
1902
+ // A face sheet on a release liner; the liner does most of the remembering.
1903
+ takesSet: 0.5
1613
1904
  }
1614
1905
  };
1615
1906
  function getStock(name) {
@@ -1636,6 +1927,183 @@ var builtins = {
1636
1927
  behavior: { type: "unroll", progress: 0.55, tightness: 0.55, sway: 0.3 },
1637
1928
  surface: { deckle: { edges: ["bottom"], roughness: 0.6 } }
1638
1929
  },
1930
+ /**
1931
+ * A roll on a holder with a leaf already out, meant to be driven by scroll:
1932
+ * bind `behavior.progress` to how far down the page you are and the paper
1933
+ * pays out while the roll runs down toward its tube.
1934
+ *
1935
+ * The three parts that make it read as a real roll rather than a curled
1936
+ * sheet: `fixed` keeps the roll on its holder and moves the paper instead,
1937
+ * `tail` means there is always a leaf to take hold of, and `floor` gives
1938
+ * the drop somewhere to land — paper that reaches the ground creases and
1939
+ * lies down rather than hanging into the void forever. A `core` a third of
1940
+ * the full radius is a real cardboard tube, so the roll still looks like a
1941
+ * roll after it has been used down.
1942
+ */
1943
+ "paper-roll": {
1944
+ meta: { name: "Paper roll", tags: ["roll", "unroll", "scroll", "hero"] },
1945
+ sheet: { width: 1, height: 5 },
1946
+ stock: "newsprint",
1947
+ content: { type: "blank" },
1948
+ behavior: {
1949
+ type: "unroll",
1950
+ progress: 0.25,
1951
+ tightness: 0.8,
1952
+ sway: 0.15,
1953
+ from: "top",
1954
+ fixed: true,
1955
+ core: 0.12,
1956
+ tail: 0.5,
1957
+ floor: 2.4
1958
+ },
1959
+ surface: { deckle: { edges: ["bottom"], roughness: 0.5 } }
1960
+ },
1961
+ /**
1962
+ * The one that is a SIMULATION rather than a shape.
1963
+ *
1964
+ * `paper-roll` above draws this same object with a deformer stack, and for
1965
+ * a roll paying out against a wall that is the cheaper and better answer.
1966
+ * This preset exists for the half that geometry cannot reach: what happens
1967
+ * once the paper hits the ground. A deformer can bend a sheet along a curve
1968
+ * you have already chosen; it cannot discover that a strip under
1969
+ * compression buckles at its weakest hinge, and it cannot let one fold land
1970
+ * on the one beneath it. Both of those are what a pile IS.
1971
+ *
1972
+ * Bind `physics.scroll` to the page and the roll turns:
1973
+ *
1974
+ * ```tsx
1975
+ * const [scroll, setScroll] = useState(0)
1976
+ * useEffect(() => {
1977
+ * const onScroll = () => setScroll(window.scrollY / 120)
1978
+ * window.addEventListener('scroll', onScroll, { passive: true })
1979
+ * return () => window.removeEventListener('scroll', onScroll)
1980
+ * }, [])
1981
+ * <Paper preset="toilet-roll" physics={{ type: 'strip', scroll }} />
1982
+ * ```
1983
+ *
1984
+ * It is a MONOTONIC world-unit number, not a 0..1 progress — the sim
1985
+ * differentiates it, so scrolling back up rewinds the roll and drags the
1986
+ * pile taut before it lifts.
1987
+ *
1988
+ * The proportions are the real object's: a panel as wide as it is long, so
1989
+ * `perforation` equals the sheet width and the strip tears into squares.
1990
+ */
1991
+ "toilet-roll": {
1992
+ meta: { name: "Toilet roll", tags: ["roll", "scroll", "simulation", "hero"] },
1993
+ // The proportions are a real roll's, at the scale the library is viewed
1994
+ // at: `<Paper>` and the editor both look at the origin through about two
1995
+ // world units and neither fits a camera to its content, so the whole
1996
+ // composition — roll, drop and pile — has to live inside that. A panel is
1997
+ // as wide as it is long and the roll is about a panel across, which is
1998
+ // what a toilet roll is.
1999
+ //
2000
+ // Twenty-three panels of paper. Not a real roll's several hundred, but
2001
+ // enough that a full page of scrolling does not empty it: one scroll unit
2002
+ // is about one unit of paper on a fresh roll, so this is a couple of
2003
+ // screens' worth before the tube shows.
2004
+ sheet: { width: 0.6, height: 14 },
2005
+ stock: "printer",
2006
+ content: { type: "blank" },
2007
+ physics: {
2008
+ type: "strip",
2009
+ scroll: 0,
2010
+ // Wound tightly enough to hold this much paper at a believable size: the
2011
+ // outer radius lands at 0.61 of the panel width against a real roll's
2012
+ // 0.57, over about nine visible turns.
2013
+ //
2014
+ // `tightness` is doing double duty and the trade is worth knowing about.
2015
+ // A layer gap IS the paper's thickness, so it also sets how far apart
2016
+ // self-collision holds two folds — wind tighter for a neater roll and
2017
+ // the pile on the floor gets flatter, looser for a fatter pile and the
2018
+ // roll coarsens. This is the middle of that.
2019
+ //
2020
+ // It is ALSO the roll's rim, and that is the reason not to raise it on
2021
+ // looks alone. A layer gap is a real space between two wound turns, so
2022
+ // the roll's end face is concentric rings with nothing between them,
2023
+ // and off head-on you see between them — a fine sawtooth around the
2024
+ // rim that winding tighter genuinely reduces. It was tried. Tightening
2025
+ // to 0.78 also moves the roll's proportion to 0.553 of a panel width,
2026
+ // nearer a real roll's 0.57 than this is, so it looked like a free win
2027
+ // — and it throws the pile 1.33 units out in x against the 0.874 a
2028
+ // square parent can see, spreading 5.9 panel-widths. Framing beats the
2029
+ // rim. The sawtooth is what a roll wound from ONE zero-thickness
2030
+ // ribbon costs; it is not tuned away, and a caller who wants it gone
2031
+ // wants a thicker `stock` or fewer, fatter turns.
2032
+ tightness: 0.65,
2033
+ // A real cardboard tube, and a floor on how tight the spiral ever winds.
2034
+ // The innermost wrap is the coarsest thing in the roll — the same
2035
+ // arc-length step spans a bigger angle the smaller the radius — so the
2036
+ // core is what stops a nearly-empty roll turning back into a polygon.
2037
+ core: 0.12,
2038
+ // A panel and a half already hanging. A roll on a holder always has a
2039
+ // leaf out; starting from a bare cylinder reads as one still wrapped.
2040
+ tail: 0.9,
2041
+ perforation: 0.6,
2042
+ // The four numbers below were chosen TOGETHER, and by worst case rather
2043
+ // than by a good-looking run. A pile is chaotic: change `crease` by
2044
+ // 0.05 and one 14-second scroll can spread 2.0 panel-widths of floor or
2045
+ // 4.2, so a single trajectory is a sample and not a measurement. These
2046
+ // are scored over nine — three scroll depths crossed with three feed
2047
+ // rates — on how far the composition ever gets from the origin.
2048
+ //
2049
+ // What that fixed: the shipped set spread 2.9 panel-widths on average
2050
+ // and 4.0 at worst, and threw paper 1.60 units out in z. It ran off the
2051
+ // side of the frame and kept going. These hold 2.2 average, 2.8 worst,
2052
+ // and 1.09 out — while keeping the pile's height (about ten layers)
2053
+ // intact, which is the thing all of this exists to show.
2054
+ //
2055
+ // The most load-bearing of them. Below about 0.6 the landed paper flops
2056
+ // over in flat panels and runs away across the floor instead of folding
2057
+ // back; high is what makes the perforations hold and the pile
2058
+ // accordion. A used roll remembering its creases is the whole effect.
2059
+ crease: 0.9,
2060
+ // Low enough that a panel buckles rather than steering the pile: at 0.5
2061
+ // the sheet was stiff enough to push the folds already down along the
2062
+ // floor ahead of it, which is what "spreads across four panels" was.
2063
+ stiffness: 0.4,
2064
+ // High: paper is light and broad, and this is what separates it from a
2065
+ // rope hanging off a drum. It also damps the sideways travel that
2066
+ // carried the pile out of frame.
2067
+ drag: 0.85,
2068
+ gravity: 1,
2069
+ // The drop, measured from the roll's axis. Together with the roll's own
2070
+ // radius this IS the height of the composition, which is centred on the
2071
+ // origin — so keep their sum under about 1.7 or the roll and the pile
2072
+ // fall outside the ~1.75 units `<Paper>`'s fixed camera can see.
2073
+ //
2074
+ // Shortened from 1.2, and it is the single most effective number here:
2075
+ // a longer fall is more airtime for the strip to pick a direction and
2076
+ // glide, so it landed still travelling and slid. Worst-case spread goes
2077
+ // 4.2 panel-widths to 3.1 and the pile keeps its full depth. It buys
2078
+ // vertical room as well, which is what lets the roll sit further up.
2079
+ floor: 0.85,
2080
+ inertia: 0.5
2081
+ },
2082
+ surface: { grain: 0.25 },
2083
+ // The preset is unreadable without this, and that is not a figure of
2084
+ // speech: the strip sim folds in DEPTH, and every camera in the library
2085
+ // is fixed and head-on, so `<Paper preset="toilet-roll" />` framed the
2086
+ // roll end-on and the entire accordion edge-on and rendered a blank white
2087
+ // column. This is the three-quarter view the pile actually reads from —
2088
+ // enough to see along the folds and around the roll's rim, not so much
2089
+ // that the strip's face turns away.
2090
+ //
2091
+ // The ceiling is framing, not taste. Turning swaps the pile's DEPTH for
2092
+ // WIDTH, and depth is free — the camera has plenty — while width is not.
2093
+ // Measured over fifteen scroll trajectories the pile reaches 1.27 units
2094
+ // of depth at worst, and `halfWidth·cos θ + 1.27·sin θ` crosses the 0.874
2095
+ // half-view a square parent gets at 28°. Twenty-five leaves real margin
2096
+ // (0.809) and gives up almost nothing of the angle.
2097
+ //
2098
+ // Do not raise it without re-measuring that depth: the two numbers are
2099
+ // coupled, and the pile's depth is not stable under small changes to the
2100
+ // physics — see the note on `floor`.
2101
+ //
2102
+ // A parent narrower than square still crops the pile's far edge. That is
2103
+ // the honest limit of a fixed camera, and the caller's answer is their
2104
+ // own `rotation` prop, which this composes with rather than overrides.
2105
+ scene: { turn: 25 }
2106
+ },
1639
2107
  "letter-fold": {
1640
2108
  meta: { name: "Letter fold", tags: ["fold", "text"] },
1641
2109
  sheet: { width: 1, height: 1.4 },
@@ -1645,7 +2113,24 @@ var builtins = {
1645
2113
  text: "Dear you,\n\nSome things are worth folding carefully.\n\nYours,\nN."
1646
2114
  },
1647
2115
  behavior: { type: "letter-fold", progress: 0.4, crease: 0.3 },
1648
- surface: { creaseLines: { angle: 0, positions: [1 / 3, 2 / 3], strength: 0.5 } }
2116
+ // A letter that has been folded before. These were painted-on
2117
+ // `creaseLines` until the paper could carry real ones — two marks in the
2118
+ // shader at exactly the two places the tri-fold bends, which is a good
2119
+ // impression of a crease right up until you unfold the letter and it
2120
+ // comes back perfectly flat. As memory they bend the sheet as well as
2121
+ // marking it, and folding the letter deepens the creases it already has
2122
+ // rather than drawing a second pair on top of them.
2123
+ //
2124
+ // The lines are `letterFold`'s own: travel down and up, each a sixth of
2125
+ // the sheet from the middle. Shallower than a fold leaves (printer keeps
2126
+ // about 20°) because this letter has been folded once and put away, not
2127
+ // folded and reopened in front of you.
2128
+ memory: {
2129
+ creases: [
2130
+ { angle: 270, offset: 1.4 / 6, depth: 13 },
2131
+ { angle: 90, offset: 1.4 / 6, depth: 11 }
2132
+ ]
2133
+ }
1649
2134
  },
1650
2135
  /**
1651
2136
  * The wash, shown rather than described.
@@ -1932,8 +2417,8 @@ function barcodeBars(seed) {
1932
2417
  var money = (v) => v.toFixed(2);
1933
2418
  function paintReceipt(ctx, w, h, content, stock) {
1934
2419
  const ink = stock.inkColor;
1935
- const pad = w * 0.09;
1936
- const colWidth = w - pad * 2;
2420
+ const pad2 = w * 0.09;
2421
+ const colWidth = w - pad2 * 2;
1937
2422
  const base = Math.round(w / 15);
1938
2423
  const mono = (size, weight = 400) => `${weight} ${size}px ui-monospace, Menlo, Consolas, monospace`;
1939
2424
  let y = h * 0.045;
@@ -1946,9 +2431,9 @@ function paintReceipt(ctx, w, h, content, stock) {
1946
2431
  const row = (left, right, size = base) => {
1947
2432
  ctx.font = mono(size);
1948
2433
  ctx.textAlign = "left";
1949
- ctx.fillText(left, pad, y);
2434
+ ctx.fillText(left, pad2, y);
1950
2435
  ctx.textAlign = "right";
1951
- ctx.fillText(right, w - pad, y);
2436
+ ctx.fillText(right, w - pad2, y);
1952
2437
  };
1953
2438
  const divider = () => {
1954
2439
  ctx.font = mono(base);
@@ -2046,9 +2531,9 @@ var NOTE_TRACKING = 0.06;
2046
2531
  function paintCard(ctx, w, h, content, stock, dpr) {
2047
2532
  const ink = content.color === "#2b2620" ? stock.inkColor : content.color;
2048
2533
  const size = content.size * dpr;
2049
- const pad = content.padding * Math.min(w, h);
2050
- const maxWidth = w - pad * 2;
2051
- const x = content.align === "center" ? w / 2 : pad;
2534
+ const pad2 = content.padding * Math.min(w, h);
2535
+ const maxWidth = w - pad2 * 2;
2536
+ const x = content.align === "center" ? w / 2 : pad2;
2052
2537
  ctx.textAlign = content.align === "center" ? "center" : "left";
2053
2538
  ctx.textBaseline = "alphabetic";
2054
2539
  const titleSize = size * TITLE_RATIO;
@@ -2060,7 +2545,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2060
2545
  const noteBlock = content.note ? noteSize * 2.4 : 0;
2061
2546
  const bodyBlock = bodyLines.length * bodyStep;
2062
2547
  const total = titleBlock + ruleBlock + bodyBlock + noteBlock;
2063
- let y = Math.max(pad, (h - total) / 2) + size * 0.9;
2548
+ let y = Math.max(pad2, (h - total) / 2) + size * 0.9;
2064
2549
  if (content.title) {
2065
2550
  ctx.font = `${titleSize}px ${content.font}`;
2066
2551
  ctx.letterSpacing = `${TITLE_TRACKING}em`;
@@ -2076,8 +2561,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2076
2561
  ctx.globalAlpha = 0.28;
2077
2562
  ctx.lineWidth = Math.max(1, dpr * 0.75);
2078
2563
  ctx.beginPath();
2079
- ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad, y - titleSize * 0.5);
2080
- ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad + maxWidth, y - titleSize * 0.5);
2564
+ ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad2, y - titleSize * 0.5);
2565
+ ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad2 + maxWidth, y - titleSize * 0.5);
2081
2566
  ctx.stroke();
2082
2567
  ctx.restore();
2083
2568
  y += ruleBlock;
@@ -2091,8 +2576,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2091
2576
  for (let i = 0; i < bodyLines.length; i++) {
2092
2577
  const lineY = y + i * bodyStep + size * 0.28;
2093
2578
  ctx.beginPath();
2094
- ctx.moveTo(pad, lineY);
2095
- ctx.lineTo(pad + maxWidth, lineY);
2579
+ ctx.moveTo(pad2, lineY);
2580
+ ctx.lineTo(pad2 + maxWidth, lineY);
2096
2581
  ctx.stroke();
2097
2582
  }
2098
2583
  ctx.restore();
@@ -2100,7 +2585,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2100
2585
  ctx.font = `${size}px ${content.font}`;
2101
2586
  ctx.fillStyle = ink;
2102
2587
  for (const line of bodyLines) {
2103
- if (y > h - pad) break;
2588
+ if (y > h - pad2) break;
2104
2589
  ctx.fillText(line, x, y);
2105
2590
  y += bodyStep;
2106
2591
  }
@@ -2108,7 +2593,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2108
2593
  ctx.font = `${noteSize}px ${content.font}`;
2109
2594
  ctx.letterSpacing = `${NOTE_TRACKING}em`;
2110
2595
  ctx.globalAlpha = 0.6;
2111
- ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad));
2596
+ ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad2));
2112
2597
  ctx.globalAlpha = 1;
2113
2598
  ctx.letterSpacing = "0em";
2114
2599
  }
@@ -2238,23 +2723,23 @@ function paintImage(ctx, w, h, img, fit) {
2238
2723
  }
2239
2724
  function paintText(ctx, w, h, content, stock) {
2240
2725
  const size = content.size * DPR;
2241
- const pad = content.padding * Math.min(w, h);
2726
+ const pad2 = content.padding * Math.min(w, h);
2242
2727
  const font = `${content.weight} ${size}px ${content.font}`;
2243
2728
  ctx.font = font;
2244
2729
  ctx.fillStyle = content.color === "#2b2620" ? stock.inkColor : content.color;
2245
2730
  ctx.textBaseline = "top";
2246
2731
  ctx.textAlign = content.align;
2247
2732
  ctx.letterSpacing = `${content.tracking}em`;
2248
- const maxWidth = w - pad * 2;
2249
- const x = content.align === "left" ? pad : content.align === "right" ? w - pad : w / 2;
2733
+ const maxWidth = w - pad2 * 2;
2734
+ const x = content.align === "left" ? pad2 : content.align === "right" ? w - pad2 : w / 2;
2250
2735
  const lineStep = size * content.lineHeight;
2251
2736
  const lines = wrapLines(ctx, content.text, maxWidth, font);
2252
2737
  ctx.font = font;
2253
2738
  ctx.letterSpacing = `${content.tracking}em`;
2254
2739
  const block = lines.length * lineStep;
2255
- let y = content.valign === "center" ? Math.max(pad, (h - block) / 2) : pad;
2740
+ let y = content.valign === "center" ? Math.max(pad2, (h - block) / 2) : pad2;
2256
2741
  for (const line of lines) {
2257
- if (y > h - pad) break;
2742
+ if (y > h - pad2) break;
2258
2743
  ctx.fillText(line, x, y);
2259
2744
  y += lineStep;
2260
2745
  }
@@ -2325,77 +2810,6 @@ var THREE3 = __toESM(require("three"), 1);
2325
2810
  // src/deformers/registry.ts
2326
2811
  var import_zod21 = require("zod");
2327
2812
 
2328
- // src/deformers/roll.ts
2329
- var import_zod15 = require("zod");
2330
- var rollOptionsSchema = import_zod15.z.object({
2331
- /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
2332
- angle: import_zod15.z.number().min(-360).max(360).default(90),
2333
- /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
2334
- boundary: import_zod15.z.number().min(-20).max(20).default(0),
2335
- /** Cylinder radius — sharpness of the roll. */
2336
- radius: import_zod15.z.number().min(0.01).max(2).default(0.12),
2337
- /** Radius growth per radian so multi-turn rolls spiral instead of z-fighting. */
2338
- spiral: import_zod15.z.number().min(0).max(0.2).default(0.015)
2339
- });
2340
- var DEG2 = Math.PI / 180;
2341
- var roll = {
2342
- id: "roll",
2343
- label: "Roll",
2344
- defaults: rollOptionsSchema.parse({}),
2345
- optionsSchema: rollOptionsSchema,
2346
- geometry: {
2347
- minSegments: 48,
2348
- // The winding radius is the tightest curvature on the sheet — `spiral`
2349
- // only grows it as the roll winds outward, so the first turn is the one
2350
- // that sets the density.
2351
- autoSegments: (o, sheet2) => segmentsForArc(spanAlong(sheet2, o.angle), o.radius),
2352
- axis: (o) => o.angle
2353
- },
2354
- displace(out, _uv, o) {
2355
- const dirX = Math.cos(o.angle * DEG2);
2356
- const dirY = Math.sin(o.angle * DEG2);
2357
- const d = out.x * dirX + out.y * dirY;
2358
- const s = d - o.boundary;
2359
- if (s <= 0) return;
2360
- const theta = s / o.radius;
2361
- const r = o.radius + o.spiral * theta;
2362
- const sin = Math.sin(theta);
2363
- const cos = Math.cos(theta);
2364
- const newD = o.boundary + (r - out.z) * sin;
2365
- const newZ = r * (1 - cos) + out.z * cos;
2366
- out.x += dirX * (newD - d);
2367
- out.y += dirY * (newD - d);
2368
- out.z = newZ;
2369
- },
2370
- glsl: {
2371
- chunk: (
2372
- /* glsl */
2373
- `
2374
- void FN(inout vec3 p, vec2 uv, float t) {
2375
- vec2 dir = vec2(cos(U_angle), sin(U_angle));
2376
- float d = dot(p.xy, dir);
2377
- float s = d - U_boundary;
2378
- if (s <= 0.0) return;
2379
- float theta = s / U_radius;
2380
- float r = U_radius + U_spiral * theta;
2381
- float sn = sin(theta);
2382
- float cs = cos(theta);
2383
- float newD = U_boundary + (r - p.z) * sn;
2384
- float newZ = r * (1.0 - cs) + p.z * cs;
2385
- p.xy += dir * (newD - d);
2386
- p.z = newZ;
2387
- }
2388
- `
2389
- ),
2390
- uniforms: (o) => ({
2391
- angle: o.angle * DEG2,
2392
- boundary: o.boundary,
2393
- radius: o.radius,
2394
- spiral: o.spiral
2395
- })
2396
- }
2397
- };
2398
-
2399
2813
  // src/deformers/bend.ts
2400
2814
  var import_zod16 = require("zod");
2401
2815
  var bendOptionsSchema = import_zod16.z.object({
@@ -2577,7 +2991,7 @@ var waveOptionsSchema = import_zod18.z.object({
2577
2991
  pinnedEdge: import_zod18.z.enum(["none", "top", "bottom", "left", "right"]).default("none")
2578
2992
  });
2579
2993
  var DEG5 = Math.PI / 180;
2580
- var TAU = Math.PI * 2;
2994
+ var TAU2 = Math.PI * 2;
2581
2995
  var wave = {
2582
2996
  id: "wave",
2583
2997
  label: "Wave",
@@ -2604,7 +3018,7 @@ var wave = {
2604
3018
  const dirX = Math.cos(o.angle * DEG5);
2605
3019
  const dirY = Math.sin(o.angle * DEG5);
2606
3020
  const d = out.x * dirX + out.y * dirY;
2607
- const phase = (d / o.wavelength - o.speed * ctx.t) * TAU;
3021
+ const phase = (d / o.wavelength - o.speed * ctx.t) * TAU2;
2608
3022
  let env = 1;
2609
3023
  if (o.pinnedEdge === "top") env = 1 - uv.y;
2610
3024
  else if (o.pinnedEdge === "bottom") env = uv.y;
@@ -2660,7 +3074,7 @@ var drapeOptionsSchema = import_zod19.z.object({
2660
3074
  gather: import_zod19.z.number().min(0).max(1).default(0.5),
2661
3075
  pinnedEdge: import_zod19.z.enum(["top", "bottom"]).default("top")
2662
3076
  });
2663
- var TAU2 = Math.PI * 2;
3077
+ var TAU3 = Math.PI * 2;
2664
3078
  var drape = {
2665
3079
  id: "drape",
2666
3080
  label: "Drape",
@@ -2688,7 +3102,7 @@ var drape = {
2688
3102
  if (o.amplitude === 0) return;
2689
3103
  const drop = o.pinnedEdge === "top" ? 1 - uv.y : uv.y;
2690
3104
  const depth = drop ** o.falloff;
2691
- const u = uv.x * TAU2 * o.folds;
3105
+ const u = uv.x * TAU3 * o.folds;
2692
3106
  const fold2 = Math.sin(u) + o.irregular * 0.6 * Math.sin(u * 1.7 + 2.1);
2693
3107
  out.z += o.amplitude * depth * fold2;
2694
3108
  const pinch = o.gather * depth * Math.min(o.amplitude * o.folds * 0.8, 0.6);
@@ -2965,6 +3379,211 @@ function take(out, deformer, options, sheet2, demand) {
2965
3379
  if (y > out[1]) out[1] = y;
2966
3380
  }
2967
3381
 
3382
+ // src/deformers/memory.ts
3383
+ var MAX_SET = 0.2;
3384
+ var CREASE_RADIUS = 0.03;
3385
+ var CREASE_MIN_GROWTH = 45;
3386
+ var CREASE_DRIFT = 0.02;
3387
+ var CREASE_DRIFT_ANGLE = 2;
3388
+ var MIN_DEPTH = 1;
3389
+ var MAX_CREASES = 4;
3390
+ function canonicalLine(angle, offset) {
3391
+ let a = (angle % 360 + 360) % 360;
3392
+ let o = offset;
3393
+ if (a >= 180) {
3394
+ a -= 180;
3395
+ o = -o;
3396
+ }
3397
+ return [a, o];
3398
+ }
3399
+ function sameLine(a, b) {
3400
+ const [aa, ao] = canonicalLine(a.angle, a.offset);
3401
+ const [ba, bo] = canonicalLine(b.angle, b.offset);
3402
+ const d = Math.abs(aa - ba);
3403
+ const angleClose = Math.min(d, 180 - d) <= CREASE_DRIFT_ANGLE;
3404
+ return angleClose && Math.abs(ao - bo) <= CREASE_DRIFT;
3405
+ }
3406
+ function applyMemory(stack, creases) {
3407
+ if (creases.length === 0) return stack;
3408
+ let out = stack;
3409
+ const loose = [];
3410
+ for (const crease of creases) {
3411
+ if (Math.abs(crease.depth) < MIN_DEPTH) continue;
3412
+ const index = out.findIndex(
3413
+ (i) => i.type === "fold" && i.enabled !== false && sameLine(i.options, crease)
3414
+ );
3415
+ if (index === -1) {
3416
+ loose.push(crease);
3417
+ continue;
3418
+ }
3419
+ const live = out[index];
3420
+ const options = live.options;
3421
+ if (Math.abs(options.foldAngle) >= Math.abs(crease.depth)) continue;
3422
+ if (out === stack) out = [...stack];
3423
+ out[index] = { ...live, options: { ...options, foldAngle: crease.depth } };
3424
+ }
3425
+ if (loose.length === 0) return out;
3426
+ return [...loose.map(toFold), ...out];
3427
+ }
3428
+ function toFold(crease) {
3429
+ return {
3430
+ type: "fold",
3431
+ options: {
3432
+ angle: crease.angle,
3433
+ offset: crease.offset,
3434
+ foldAngle: crease.depth,
3435
+ radius: CREASE_RADIUS
3436
+ }
3437
+ };
3438
+ }
3439
+ var CreaseTracker = class {
3440
+ slots = /* @__PURE__ */ new Map();
3441
+ recorded = [];
3442
+ authored = [];
3443
+ /** What `observe` last handed out, so an echo can be told from an edit. */
3444
+ lastReported = [];
3445
+ constructor(authored = []) {
3446
+ this.authored = authored;
3447
+ }
3448
+ /**
3449
+ * Forget how the paper got here without forgetting the creases.
3450
+ *
3451
+ * Called when the stack is replaced wholesale (a new behavior, a new
3452
+ * sheet): the slots describe folds that no longer exist, but a crease is a
3453
+ * property of the paper and survives being put down and picked up.
3454
+ */
3455
+ reset(authored = this.creases) {
3456
+ this.slots.clear();
3457
+ this.recorded = [];
3458
+ this.authored = authored;
3459
+ }
3460
+ /**
3461
+ * Take on a crease set that came from outside, and work out which kind of
3462
+ * outside it was — because the two kinds want opposite things.
3463
+ *
3464
+ * It is USUALLY this tracker's own recording coming back, a frame or two
3465
+ * after `onCrease` handed it to the host. Then the slots must survive: the
3466
+ * fold that made the crease is very likely still closing, and resetting
3467
+ * its peak on the host's echo would stall the crease halfway into the
3468
+ * fold that was making it.
3469
+ *
3470
+ * But it can also be somebody EDITING the paper — a depth dragged down in
3471
+ * a panel, a shared link opened, a state's overrides settling. Then the
3472
+ * slots are the wrong story to keep. `merge` takes the deeper of two
3473
+ * creases on a line, so a recording of 20° would quietly outvote a human
3474
+ * asking for 5° and the slider would appear not to work; and it would go
3475
+ * on outvoting it, because the fold that recorded the 20 is still sitting
3476
+ * in a slot. An edit means the paper is what it is now, and the next
3477
+ * crease has to be earned by folding it again.
3478
+ *
3479
+ * The two are told apart by what we last reported. Anything else is an
3480
+ * edit, which is the safe way round: mistaking an echo for an edit costs
3481
+ * a crease that gets re-recorded on the next fold, while mistaking an edit
3482
+ * for an echo costs a control that does not work.
3483
+ */
3484
+ adopt(creases) {
3485
+ if (!same(creases, this.lastReported)) this.slots.clear();
3486
+ this.authored = creases;
3487
+ this.recorded = [];
3488
+ }
3489
+ /** Everything the sheet currently carries, authored and recorded merged. */
3490
+ get creases() {
3491
+ return merge(this.authored, this.recorded);
3492
+ }
3493
+ /**
3494
+ * Take one frame's reading. Returns true when the crease set changed by
3495
+ * enough to be worth telling anyone about.
3496
+ */
3497
+ observe(stack, set) {
3498
+ if (set <= 0) return false;
3499
+ for (let i = 0; i < stack.length; i++) {
3500
+ const instance = stack[i];
3501
+ if (instance.type !== "fold" || instance.enabled === false) continue;
3502
+ const o = instance.options;
3503
+ const magnitude = Math.abs(o.foldAngle);
3504
+ const slot = this.slots.get(i);
3505
+ if (!slot || Math.abs(slot.angle - o.angle) > CREASE_DRIFT_ANGLE || Math.abs(slot.offset - o.offset) > CREASE_DRIFT) {
3506
+ this.slots.set(i, {
3507
+ angle: o.angle,
3508
+ offset: o.offset,
3509
+ trough: magnitude,
3510
+ bestGrowth: 0,
3511
+ bestPeak: magnitude,
3512
+ sign: o.foldAngle < 0 ? -1 : 1
3513
+ });
3514
+ continue;
3515
+ }
3516
+ if (magnitude < slot.trough) slot.trough = magnitude;
3517
+ const growth = magnitude - slot.trough;
3518
+ if (growth > slot.bestGrowth) {
3519
+ slot.bestGrowth = growth;
3520
+ slot.bestPeak = magnitude;
3521
+ slot.sign = o.foldAngle < 0 ? -1 : 1;
3522
+ }
3523
+ }
3524
+ const next = [];
3525
+ for (const slot of this.slots.values()) {
3526
+ if (slot.bestGrowth < CREASE_MIN_GROWTH) continue;
3527
+ const depth = slot.sign * slot.bestPeak * set * MAX_SET;
3528
+ if (Math.abs(depth) < MIN_DEPTH) continue;
3529
+ next.push({ angle: slot.angle, offset: slot.offset, depth });
3530
+ }
3531
+ if (same(next, this.recorded)) return false;
3532
+ this.recorded = next;
3533
+ this.lastReported = this.creases;
3534
+ return true;
3535
+ }
3536
+ };
3537
+ function merge(authored, recorded) {
3538
+ const out = [];
3539
+ for (const crease of [...recorded, ...authored]) {
3540
+ const existing = out.find((c) => sameLine(c, crease));
3541
+ if (!existing) {
3542
+ out.push({ ...crease });
3543
+ continue;
3544
+ }
3545
+ if (Math.abs(crease.depth) > Math.abs(existing.depth)) existing.depth = crease.depth;
3546
+ }
3547
+ if (out.length > MAX_CREASES) {
3548
+ out.sort((a, b) => Math.abs(b.depth) - Math.abs(a.depth));
3549
+ out.length = MAX_CREASES;
3550
+ }
3551
+ return out;
3552
+ }
3553
+ function same(a, b) {
3554
+ if (a.length !== b.length) return false;
3555
+ return a.every((crease, i) => {
3556
+ const other = b[i];
3557
+ return sameLine(crease, other) && Math.abs(crease.depth - other.depth) < 0.5;
3558
+ });
3559
+ }
3560
+
3561
+ // src/surface/creases.ts
3562
+ var MAX_SHADED = 4;
3563
+ function resolveCreases(surface, creases, sheet2) {
3564
+ const out = [];
3565
+ const lines = surface.creaseLines;
3566
+ if (lines) {
3567
+ const angle = lines.angle + 90;
3568
+ const span = spanAlong(sheet2, angle);
3569
+ for (const position of lines.positions) {
3570
+ out.push({ angle, offset: (position - 0.5) * span, strength: lines.strength });
3571
+ }
3572
+ }
3573
+ for (const crease of creases) {
3574
+ out.push(creaseShading(crease));
3575
+ }
3576
+ if (out.length > MAX_SHADED) out.length = MAX_SHADED;
3577
+ return out;
3578
+ }
3579
+ function creaseShading(crease) {
3580
+ return {
3581
+ angle: crease.angle,
3582
+ offset: crease.offset,
3583
+ strength: Math.max(-1, Math.min(1, -crease.depth / (90 * MAX_SET)))
3584
+ };
3585
+ }
3586
+
2968
3587
  // src/behaviors/registry.ts
2969
3588
  var registry2 = /* @__PURE__ */ new Map();
2970
3589
  function registerBehavior(behavior) {
@@ -3058,12 +3677,22 @@ function getIdlePreset(name) {
3058
3677
  // src/physics/cloth.ts
3059
3678
  var FIXED_DT = 1 / 120;
3060
3679
  var SOLVER_ITERATIONS = 5;
3680
+ var AERO_TURBULENCE = 0.15;
3681
+ var GRAB_RADIUS = 0.05;
3061
3682
  var SLEEP_EPSILON = 1e-6;
3062
3683
  var SLEEP_FRAMES = 45;
3684
+ function grabFalloff(distance, radius) {
3685
+ const t = Math.min(1, Math.max(0, distance / radius));
3686
+ const s = 1 - t;
3687
+ return s * s * (3 - 2 * s);
3688
+ }
3063
3689
  var ClothSim = class {
3064
3690
  cols;
3065
3691
  rows;
3066
3692
  count;
3693
+ /** The sheet this grid was laid out on. Read by {@link adopt}. */
3694
+ width;
3695
+ height;
3067
3696
  positions;
3068
3697
  prev;
3069
3698
  pinned;
@@ -3074,17 +3703,47 @@ var ClothSim = class {
3074
3703
  accumulator = 0;
3075
3704
  stillFrames = 0;
3076
3705
  grabbedIndex = -1;
3706
+ /**
3707
+ * A normal per particle, refreshed once a frame.
3708
+ *
3709
+ * The sim needs these for one thing only — how much wind each part of the
3710
+ * sheet is actually catching — and it needs its OWN rather than the mesh's,
3711
+ * because the mesh's are computed after the fact by the adapter and a
3712
+ * deformer may be running over the top of them by then. Once a frame rather
3713
+ * than once a substep: paper does not turn far in eight milliseconds, and
3714
+ * this is the only part of the step that is not a constraint solve.
3715
+ */
3716
+ normals;
3717
+ /**
3718
+ * How hard each particle is held, 0..1. Zero for all but the handful under
3719
+ * a hand, so it doubles as the inverse mass the constraint solver wants:
3720
+ * a particle is as immovable as it is held.
3721
+ */
3722
+ grabWeights;
3723
+ /** Just the held ones, so nothing iterates the whole sheet to find six. */
3724
+ grabbed = [];
3725
+ /** Each held particle's offset from the one under the cursor, at grab time. */
3726
+ grabOffsets = new Float32Array(0);
3727
+ /** Where the hand was at the start of the last step, and how fast it moved. */
3728
+ grabAt = new Float32Array(3);
3729
+ grabWas = new Float32Array(3);
3730
+ grabVelocity = new Float32Array(3);
3077
3731
  /** True when the sim has settled and steps are skipped. */
3078
3732
  asleep = false;
3079
3733
  constructor(cols, rows, width, height, pins, params) {
3080
3734
  this.cols = cols;
3081
3735
  this.rows = rows;
3082
3736
  this.count = cols * rows;
3737
+ this.width = width;
3738
+ this.height = height;
3083
3739
  this.params = { ...params };
3084
3740
  this.positions = new Float32Array(this.count * 3);
3085
3741
  this.prev = new Float32Array(this.count * 3);
3086
3742
  this.pinned = new Uint8Array(this.count);
3087
3743
  this.pinTargets = new Float32Array(this.count * 3);
3744
+ this.grabWeights = new Float32Array(this.count);
3745
+ this.normals = new Float32Array(this.count * 3);
3746
+ for (let i = 0; i < this.count; i++) this.normals[i * 3 + 2] = 1;
3088
3747
  for (let r = 0; r < rows; r++) {
3089
3748
  for (let c = 0; c < cols; c++) {
3090
3749
  const i3 = (r * cols + c) * 3;
@@ -3124,6 +3783,54 @@ var ClothSim = class {
3124
3783
  }
3125
3784
  if (pins === "corner") pin(0, 0);
3126
3785
  }
3786
+ /**
3787
+ * Carry a previous sim's drape across a rebuild.
3788
+ *
3789
+ * Sheet dimensions are a GEOMETRY dependency, so changing them builds a new
3790
+ * mesh and a new sim, and a new sim starts flat — which means a draped sheet
3791
+ * snaps rigid the instant it is resized. Nothing about the physics requires
3792
+ * that; it is only that nobody had carried the state over.
3793
+ *
3794
+ * What carries is the FREE particles, scaled by how much the sheet grew:
3795
+ * the constraints' rest lengths are laid out afresh at the new size, so
3796
+ * scaling the drape by the same ratio leaves every constraint exactly as
3797
+ * violated as it was and the sim simply continues. Pinned particles keep
3798
+ * the new layout's own rest positions instead — a pin holds a CORNER, and
3799
+ * the corner is where the resized sheet says it is.
3800
+ *
3801
+ * Refused in one case only: a different grid, because there is no
3802
+ * correspondence between the two sets of particles and the nearest thing to
3803
+ * one would be a guess. Everything else carries — a resize, a change of
3804
+ * pins, a deformer appearing on top. The sheet's state belongs to the sheet,
3805
+ * and none of those is a reason to have never fallen.
3806
+ *
3807
+ * That matters most for the one that is not a resize at all: a shape
3808
+ * arriving over a simulation rebuilds the mesh (the stack has its own
3809
+ * opinion about tessellation) without touching a single thing the physics
3810
+ * knows about. Resetting there would mean the sheet snapped flat the instant
3811
+ * you tried to fold the sheet you were holding, which is the whole point of
3812
+ * being able to.
3813
+ *
3814
+ * Returns whether it took.
3815
+ */
3816
+ adopt(previous) {
3817
+ if (!previous || previous.cols !== this.cols || previous.rows !== this.rows) return false;
3818
+ const sx = previous.width > 0 ? this.width / previous.width : 1;
3819
+ const sy = previous.height > 0 ? this.height / previous.height : 1;
3820
+ const sz = (sx + sy) / 2;
3821
+ for (let i = 0; i < this.count; i++) {
3822
+ if (this.pinned[i]) continue;
3823
+ const i3 = i * 3;
3824
+ this.positions[i3] = previous.positions[i3] * sx;
3825
+ this.positions[i3 + 1] = previous.positions[i3 + 1] * sy;
3826
+ this.positions[i3 + 2] = previous.positions[i3 + 2] * sz;
3827
+ this.prev[i3] = previous.prev[i3] * sx;
3828
+ this.prev[i3 + 1] = previous.prev[i3 + 1] * sy;
3829
+ this.prev[i3 + 2] = previous.prev[i3 + 2] * sz;
3830
+ }
3831
+ this.wake();
3832
+ return true;
3833
+ }
3127
3834
  setParams(params) {
3128
3835
  let changed = false;
3129
3836
  for (const key of ["stiffness", "gravity", "wind", "floor"]) {
@@ -3139,6 +3846,69 @@ var ClothSim = class {
3139
3846
  this.asleep = false;
3140
3847
  this.stillFrames = 0;
3141
3848
  }
3849
+ /**
3850
+ * Take hold of the sheet at one particle.
3851
+ *
3852
+ * Separate from {@link grabNearest} because the particle a hand grabbed is
3853
+ * not always the particle nearest the point it touched: with a deformer
3854
+ * running over the simulation, what the pointer hit was a RENDERED vertex,
3855
+ * and the vertex it hit is the particle of the same index — the stack maps
3856
+ * a point to a point and never reorders them.
3857
+ *
3858
+ * What is taken hold of is a PATCH around that particle, not the particle
3859
+ * alone — see {@link GRAB_RADIUS}. The patch is measured across the GRID
3860
+ * rather than through space, because a hand holds a piece of the sheet and
3861
+ * keeps holding the same piece: measured through space, a fold that brought
3862
+ * a far corner near the fingers would silently add it to the grip.
3863
+ */
3864
+ grab(index) {
3865
+ this.grabbedIndex = index >= 0 && index < this.count ? index : -1;
3866
+ this.grabWeights.fill(0);
3867
+ this.grabbed = [];
3868
+ if (this.grabbedIndex < 0) {
3869
+ this.grabOffsets = new Float32Array(0);
3870
+ this.wake();
3871
+ return -1;
3872
+ }
3873
+ const cellX = this.cols > 1 ? this.width / (this.cols - 1) : this.width;
3874
+ const cellY = this.rows > 1 ? this.height / (this.rows - 1) : this.height;
3875
+ const cell = Math.max(cellX, cellY, 1e-6);
3876
+ const radius = Math.min(
3877
+ Math.max(GRAB_RADIUS, cell * 1.5),
3878
+ Math.max(cell, Math.min(this.width, this.height) * 0.2)
3879
+ );
3880
+ const centreRow = Math.floor(this.grabbedIndex / this.cols);
3881
+ const centreCol = this.grabbedIndex % this.cols;
3882
+ const spanRows = Math.ceil(radius / cellY);
3883
+ const spanCols = Math.ceil(radius / cellX);
3884
+ const offsets = [];
3885
+ const anchor = this.grabbedIndex * 3;
3886
+ for (let r = centreRow - spanRows; r <= centreRow + spanRows; r++) {
3887
+ if (r < 0 || r >= this.rows) continue;
3888
+ for (let c = centreCol - spanCols; c <= centreCol + spanCols; c++) {
3889
+ if (c < 0 || c >= this.cols) continue;
3890
+ const i = r * this.cols + c;
3891
+ if (this.pinned[i]) continue;
3892
+ const distance = Math.hypot((c - centreCol) * cellX, (r - centreRow) * cellY);
3893
+ const weight = i === this.grabbedIndex ? 1 : grabFalloff(distance, radius);
3894
+ if (weight <= 0) continue;
3895
+ this.grabWeights[i] = weight;
3896
+ this.grabbed.push(i);
3897
+ const i3 = i * 3;
3898
+ offsets.push(
3899
+ this.positions[i3] - this.positions[anchor],
3900
+ this.positions[i3 + 1] - this.positions[anchor + 1],
3901
+ this.positions[i3 + 2] - this.positions[anchor + 2]
3902
+ );
3903
+ }
3904
+ }
3905
+ this.grabOffsets = Float32Array.from(offsets);
3906
+ this.grabAt.set(this.positions.subarray(anchor, anchor + 3));
3907
+ this.grabWas.set(this.grabAt);
3908
+ this.grabVelocity.fill(0);
3909
+ this.wake();
3910
+ return this.grabbedIndex;
3911
+ }
3142
3912
  /** Nearest particle to a local-space point — the grab interface. */
3143
3913
  grabNearest(x, y, z25) {
3144
3914
  let best = -1;
@@ -3153,32 +3923,110 @@ var ClothSim = class {
3153
3923
  best = i;
3154
3924
  }
3155
3925
  }
3156
- this.grabbedIndex = best;
3157
- this.wake();
3158
- return best;
3926
+ return this.grab(best);
3927
+ }
3928
+ /** How hard one particle is being held, 0..1 — for tests and for the adapter. */
3929
+ grabWeightAt(index) {
3930
+ return this.grabWeights[index] ?? 0;
3159
3931
  }
3932
+ /** Where the fingers are now. The patch follows, each particle by its weight. */
3160
3933
  moveGrab(x, y, z25) {
3161
3934
  if (this.grabbedIndex < 0) return;
3162
- const i3 = this.grabbedIndex * 3;
3163
- this.positions[i3] = x;
3164
- this.positions[i3 + 1] = y;
3165
- this.positions[i3 + 2] = z25;
3166
- this.prev[i3] = x;
3167
- this.prev[i3 + 1] = y;
3168
- this.prev[i3 + 2] = z25;
3935
+ this.grabAt[0] = x;
3936
+ this.grabAt[1] = y;
3937
+ this.grabAt[2] = z25;
3169
3938
  this.wake();
3170
3939
  }
3940
+ /**
3941
+ * Let go — and let go at SPEED.
3942
+ *
3943
+ * This used to drop the paper dead. Every held particle had its previous
3944
+ * position overwritten with its current one on the way past, and in a verlet
3945
+ * integrator the gap between those two IS the velocity, so a sheet whipped
3946
+ * across the frame and released came to a perfect standstill and then fell
3947
+ * straight down. Whatever you did with your hand, the paper had never heard
3948
+ * of it.
3949
+ *
3950
+ * The hand's velocity is measured per second (see {@link step}) and spent
3951
+ * here, converted into the one-substep gap the integrator reads it back out
3952
+ * of. Measured per second rather than per frame because a frame is not a
3953
+ * fixed length and a substep is: throwing the same sheet at the same speed
3954
+ * must not depend on what the frame rate happened to be.
3955
+ */
3171
3956
  release() {
3957
+ for (const i of this.grabbed) {
3958
+ const i3 = i * 3;
3959
+ const held = this.grabWeights[i];
3960
+ this.prev[i3] = this.positions[i3] - this.grabVelocity[0] * FIXED_DT * held;
3961
+ this.prev[i3 + 1] = this.positions[i3 + 1] - this.grabVelocity[1] * FIXED_DT * held;
3962
+ this.prev[i3 + 2] = this.positions[i3 + 2] - this.grabVelocity[2] * FIXED_DT * held;
3963
+ }
3172
3964
  this.grabbedIndex = -1;
3965
+ this.grabbed = [];
3966
+ this.grabWeights.fill(0);
3967
+ this.grabVelocity.fill(0);
3968
+ this.wake();
3173
3969
  }
3174
3970
  step(delta) {
3175
3971
  if (this.asleep) return;
3972
+ if (this.grabbedIndex >= 0 && delta > 0) {
3973
+ for (let axis = 0; axis < 3; axis++) {
3974
+ this.grabVelocity[axis] = (this.grabAt[axis] - this.grabWas[axis]) / delta;
3975
+ }
3976
+ this.grabWas.set(this.grabAt);
3977
+ }
3978
+ this.updateNormals();
3176
3979
  this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
3177
3980
  while (this.accumulator >= FIXED_DT) {
3178
3981
  this.substep(FIXED_DT);
3179
3982
  this.accumulator -= FIXED_DT;
3180
3983
  }
3181
3984
  }
3985
+ /**
3986
+ * Which way each part of the sheet is facing, by central difference across
3987
+ * the grid.
3988
+ *
3989
+ * Clamped at the edges rather than wrapped or skipped: an edge particle
3990
+ * takes the one-sided difference, which is the same normal its neighbour
3991
+ * has, and an edge with no normal is an edge the wind cannot push.
3992
+ *
3993
+ * Which SIDE the normal points at is deliberately not worried about. The
3994
+ * force below is `n (n·v)`, a quadratic form, and that is unchanged by
3995
+ * flipping n — which is exactly right for paper, a surface with no front as
3996
+ * far as the air is concerned.
3997
+ */
3998
+ updateNormals() {
3999
+ const p = this.positions;
4000
+ const n = this.normals;
4001
+ for (let r = 0; r < this.rows; r++) {
4002
+ for (let c = 0; c < this.cols; c++) {
4003
+ const left = (r * this.cols + (c > 0 ? c - 1 : c)) * 3;
4004
+ const right = (r * this.cols + (c < this.cols - 1 ? c + 1 : c)) * 3;
4005
+ const up = ((r > 0 ? r - 1 : r) * this.cols + c) * 3;
4006
+ const down = ((r < this.rows - 1 ? r + 1 : r) * this.cols + c) * 3;
4007
+ const ux = p[right] - p[left];
4008
+ const uy = p[right + 1] - p[left + 1];
4009
+ const uz = p[right + 2] - p[left + 2];
4010
+ const vx = p[down] - p[up];
4011
+ const vy = p[down + 1] - p[up + 1];
4012
+ const vz = p[down + 2] - p[up + 2];
4013
+ const nx = vy * uz - vz * uy;
4014
+ const ny = vz * ux - vx * uz;
4015
+ const nz = vx * uy - vy * ux;
4016
+ const length = Math.sqrt(nx * nx + ny * ny + nz * nz);
4017
+ const i3 = (r * this.cols + c) * 3;
4018
+ if (length > 1e-12) {
4019
+ n[i3] = nx / length;
4020
+ n[i3 + 1] = ny / length;
4021
+ n[i3 + 2] = nz / length;
4022
+ } else {
4023
+ n[i3] = 0;
4024
+ n[i3 + 1] = 0;
4025
+ n[i3 + 2] = 0;
4026
+ }
4027
+ }
4028
+ }
4029
+ }
3182
4030
  substep(dt) {
3183
4031
  const { gravity, wind, stiffness, floor } = this.params;
3184
4032
  const p = this.positions;
@@ -3188,12 +4036,10 @@ var ClothSim = class {
3188
4036
  let maxTravel = 0;
3189
4037
  for (let i = 0; i < this.count; i++) {
3190
4038
  const i3 = i * 3;
3191
- if (this.pinned[i] || i === this.grabbedIndex) {
3192
- if (this.pinned[i]) {
3193
- p[i3] = this.pinTargets[i3];
3194
- p[i3 + 1] = this.pinTargets[i3 + 1];
3195
- p[i3 + 2] = this.pinTargets[i3 + 2];
3196
- }
4039
+ if (this.pinned[i]) {
4040
+ p[i3] = this.pinTargets[i3];
4041
+ p[i3 + 1] = this.pinTargets[i3 + 1];
4042
+ p[i3 + 2] = this.pinTargets[i3 + 2];
3197
4043
  this.prev[i3] = p[i3];
3198
4044
  this.prev[i3 + 1] = p[i3 + 1];
3199
4045
  this.prev[i3 + 2] = p[i3 + 2];
@@ -3202,20 +4048,38 @@ var ClothSim = class {
3202
4048
  const x = p[i3];
3203
4049
  const y = p[i3 + 1];
3204
4050
  const z25 = p[i3 + 2];
3205
- const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
3206
- const ax = gust2 * 0.25;
3207
- const az = gust2;
3208
4051
  const vx = (x - this.prev[i3]) * damping;
3209
4052
  const vy = (y - this.prev[i3 + 1]) * damping;
3210
4053
  const vz = (z25 - this.prev[i3 + 2]) * damping;
4054
+ const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
4055
+ const relX = gust2 * 0.25 - vx / dt;
4056
+ const relY = -vy / dt;
4057
+ const relZ = gust2 - vz / dt;
4058
+ const nx = this.normals[i3];
4059
+ const ny = this.normals[i3 + 1];
4060
+ const nz = this.normals[i3 + 2];
4061
+ const facing = nx * relX + ny * relY + nz * relZ;
4062
+ const ax = nx * facing * (1 - AERO_TURBULENCE) + gust2 * 0.25 * AERO_TURBULENCE;
4063
+ const ay = ny * facing * (1 - AERO_TURBULENCE);
4064
+ const az = nz * facing * (1 - AERO_TURBULENCE) + gust2 * AERO_TURBULENCE;
3211
4065
  this.prev[i3] = x;
3212
4066
  this.prev[i3 + 1] = y;
3213
4067
  this.prev[i3 + 2] = z25;
3214
4068
  p[i3] = x + vx + ax * dt2;
3215
- p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
4069
+ p[i3 + 1] = y + vy + (ay - gravity * 3.2) * dt2;
3216
4070
  p[i3 + 2] = z25 + vz + az * dt2;
3217
4071
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
3218
4072
  }
4073
+ for (let k = 0; k < this.grabbed.length; k++) {
4074
+ const i = this.grabbed[k];
4075
+ const i3 = i * 3;
4076
+ const k3 = k * 3;
4077
+ const held = this.grabWeights[i];
4078
+ for (let axis = 0; axis < 3; axis++) {
4079
+ const target = this.grabAt[axis] + this.grabOffsets[k3 + axis];
4080
+ p[i3 + axis] = p[i3 + axis] + (target - p[i3 + axis]) * held;
4081
+ }
4082
+ }
3219
4083
  for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
3220
4084
  for (const c of this.constraints) {
3221
4085
  const k = c.kind === 2 ? 0.25 + stiffness * 0.7 : c.kind === 1 ? 0.85 : 1;
@@ -3226,12 +4090,13 @@ var ClothSim = class {
3226
4090
  const dz = p[b3 + 2] - p[a3 + 2];
3227
4091
  const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
3228
4092
  if (dist === 0) continue;
3229
- const diff = (dist - c.rest) / dist * 0.5 * k;
3230
- const aPinned = this.pinned[c.a] || c.a === this.grabbedIndex;
3231
- const bPinned = this.pinned[c.b] || c.b === this.grabbedIndex;
3232
- if (aPinned && bPinned) continue;
3233
- const aw = aPinned ? 0 : bPinned ? 2 : 1;
3234
- const bw = bPinned ? 0 : aPinned ? 2 : 1;
4093
+ const ma = this.pinned[c.a] ? 0 : 1 - this.grabWeights[c.a];
4094
+ const mb = this.pinned[c.b] ? 0 : 1 - this.grabWeights[c.b];
4095
+ const total = ma + mb;
4096
+ if (total <= 0) continue;
4097
+ const diff = (dist - c.rest) / dist / total * k;
4098
+ const aw = ma;
4099
+ const bw = mb;
3235
4100
  p[a3] = p[a3] + dx * diff * aw;
3236
4101
  p[a3 + 1] = p[a3 + 1] + dy * diff * aw;
3237
4102
  p[a3 + 2] = p[a3 + 2] + dz * diff * aw;
@@ -3258,6 +4123,724 @@ var ClothSim = class {
3258
4123
  }
3259
4124
  };
3260
4125
 
4126
+ // src/physics/strip.ts
4127
+ var FIXED_DT2 = 1 / 120;
4128
+ var SOLVER_ITERATIONS2 = 8;
4129
+ var SLEEP_EPSILON2 = 1e-7;
4130
+ var SLEEP_FRAMES2 = 45;
4131
+ var LOOSE2 = 0.055;
4132
+ var TIGHT2 = 0.012;
4133
+ var NODES_PER_PANEL = 16;
4134
+ var MAX_NODES = 440;
4135
+ var MIN_NODES = 8;
4136
+ var REFERENCE_SEGMENT = 0.0375;
4137
+ var TANGENT_SPAN = 0.09;
4138
+ var PULL_STIFFNESS = 0.35;
4139
+ var COLLIDING_ITERATIONS = 4;
4140
+ var COLLISION_RELAXATION = 0.35;
4141
+ function safeSpiralRadius(segment, thickness) {
4142
+ return thickness > 0 ? segment * segment / (4 * thickness) : 0;
4143
+ }
4144
+ function layerThickness2(tightness) {
4145
+ return LOOSE2 - Math.min(1, Math.max(0, tightness)) * (LOOSE2 - TIGHT2);
4146
+ }
4147
+ function stripNodeCount(length, perforation) {
4148
+ const seg = Math.max(perforation, 1e-3) / NODES_PER_PANEL;
4149
+ return Math.min(MAX_NODES, Math.max(MIN_NODES, Math.round(length / seg) + 1));
4150
+ }
4151
+ function maxStripLength(perforation) {
4152
+ const seg = Math.max(perforation, 1e-3) / NODES_PER_PANEL;
4153
+ return (MAX_NODES - 1 - 0.5) * seg;
4154
+ }
4155
+ var StripSim = class {
4156
+ /** Chain length. Node 0 is the deepest wrap; node `count-1` is the free tip. */
4157
+ count;
4158
+ /** Rest spacing between neighbours, in world units. */
4159
+ segment;
4160
+ /** Node centreline, y/z interleaved. x is implicit: the strip never twists. */
4161
+ pos;
4162
+ prev;
4163
+ /** Arc distance from the tip, per node. */
4164
+ arc;
4165
+ /** 1 where a perforation hinges, and the sign of the crease it remembers. */
4166
+ perforated;
4167
+ /** Bend gain correction for this chain's node spacing. See {@link REFERENCE_SEGMENT}. */
4168
+ bendScale;
4169
+ /** Half-width, in nodes, of the window the drag tangent is measured over. */
4170
+ tangentNodes;
4171
+ halfWidth;
4172
+ totalLength;
4173
+ /**
4174
+ * Paper that can never be paid out, because its inner end is glued to the
4175
+ * tube — which is true of every roll you have ever used.
4176
+ *
4177
+ * Without it the roll pays out to nothing, `firstFreeIndex` reaches 0, and
4178
+ * every node the roll was made of becomes free paper and falls: the whole
4179
+ * roll drops off its holder and lands flat on the pile. One wrap held back
4180
+ * leaves a cylinder at the core radius on the holder, which is what an
4181
+ * empty roll looks like and the closest this library can get to drawing a
4182
+ * cardboard tube out of the one sheet it has.
4183
+ */
4184
+ tubeStub;
4185
+ params;
4186
+ /** Paid-out length: how much paper is off the roll. */
4187
+ paid;
4188
+ /** Angular velocity of the roll, rad/s. The thing that coasts. */
4189
+ omega = 0;
4190
+ /**
4191
+ * Whether anything has turned the roll yet — a scroll delta or a hand.
4192
+ *
4193
+ * `tail` is the opening pose, and editing it should re-pose the roll; but
4194
+ * once the host has scrolled, `paid` is the simulation's own state and
4195
+ * re-posing would yank the paper back out of the pile.
4196
+ */
4197
+ driven = false;
4198
+ lastScroll;
4199
+ primed = false;
4200
+ /** Node held by the pointer, or -1. Kinematic while held, like a wound node. */
4201
+ grabbed = -1;
4202
+ grabY = 0;
4203
+ grabZ = 0;
4204
+ stillFrames = 0;
4205
+ accumulator = 0;
4206
+ time = 0;
4207
+ asleep = false;
4208
+ // Spatial hash for self-collision. Sized once, refilled in place — the
4209
+ // per-substep collision pass allocates nothing.
4210
+ cellOf;
4211
+ bucketStart;
4212
+ bucketItems;
4213
+ bucketFill;
4214
+ tableSize;
4215
+ constructor(length, width, params) {
4216
+ this.params = { ...params };
4217
+ this.totalLength = length;
4218
+ this.halfWidth = width / 2;
4219
+ this.count = stripNodeCount(length, params.perforation);
4220
+ this.segment = length / (this.count - 1);
4221
+ this.bendScale = Math.min(1, this.segment / REFERENCE_SEGMENT);
4222
+ this.tangentNodes = Math.max(1, Math.round(TANGENT_SPAN / this.segment));
4223
+ this.pos = new Float64Array(this.count * 2);
4224
+ this.prev = new Float64Array(this.count * 2);
4225
+ this.arc = new Float64Array(this.count);
4226
+ this.perforated = new Int8Array(this.count);
4227
+ for (let i = 0; i < this.count; i++) {
4228
+ this.arc[i] = (this.count - 1 - i) * this.segment;
4229
+ }
4230
+ const spacing = Math.max(params.perforation, this.segment * 2);
4231
+ for (let i = 0; i < this.count - 1; i++) {
4232
+ const here = Math.floor(this.arc[i] / spacing);
4233
+ const next = Math.floor(this.arc[i + 1] / spacing);
4234
+ if (here !== next) this.perforated[i] = here % 2 === 0 ? 1 : -1;
4235
+ }
4236
+ this.tableSize = 1 << Math.ceil(Math.log2(Math.max(16, this.count * 2)));
4237
+ this.cellOf = new Int32Array(this.count);
4238
+ this.bucketStart = new Int32Array(this.tableSize + 1);
4239
+ this.bucketItems = new Int32Array(this.count);
4240
+ this.bucketFill = new Int32Array(this.tableSize);
4241
+ this.tubeStub = this.stubFor(params.core);
4242
+ this.paid = Math.min(params.tail, this.usableLength);
4243
+ this.lastScroll = params.scroll;
4244
+ this.layOut();
4245
+ }
4246
+ /**
4247
+ * Local-space y the pile builds on, after centring. A host placing a
4248
+ * shadow catcher or a contact shadow needs this; it cannot derive it,
4249
+ * because the composition is offset to sit on the origin.
4250
+ */
4251
+ get floorY() {
4252
+ return -this.params.floor + this.centreOffset;
4253
+ }
4254
+ /**
4255
+ * How far the whole composition is lifted so that it straddles the origin.
4256
+ *
4257
+ * The roll's axis is the sim's origin, which would park a long drop
4258
+ * entirely below the frame — `<Paper>` and the editor both look at the
4259
+ * origin through about two world units, and neither fits a camera to the
4260
+ * content. `unroll` learned the same lesson and left a note about it: a
4261
+ * composition anchored anywhere but the origin needs a bespoke camera at
4262
+ * every call site.
4263
+ *
4264
+ * Measured against the FULL radius rather than the current one, so it is a
4265
+ * constant for a given config. Deriving it from the live radius would drift
4266
+ * the entire pile upward as the roll ran down.
4267
+ */
4268
+ get centreOffset() {
4269
+ return (this.params.floor - this.outerRadius) / 2;
4270
+ }
4271
+ /**
4272
+ * The same centring, along the axis the pile actually grows in.
4273
+ *
4274
+ * Everything here is built around the DROP LINE — the z the paper leaves
4275
+ * the roll at — and that line is not the origin and does not stay put. It
4276
+ * starts at 0 on a full roll and travels back to `core - outerRadius` as
4277
+ * the roll runs down (see {@link tangentZ}), because the spiral's centre is
4278
+ * fixed and the tangent point walks in toward it. The pile builds around
4279
+ * wherever the line has been, so a composition that ignores this is offset
4280
+ * by half that travel before a single fold has landed, and z is DEPTH —
4281
+ * the axis a fixed head-on camera has the least room in.
4282
+ *
4283
+ * A constant for a given config, for the reason {@link centreOffset} is:
4284
+ * derived from the live radius it would slide the whole composition
4285
+ * backwards as the roll emptied, which reads far worse than sitting still
4286
+ * slightly off centre. Centring the line's travel splits the difference
4287
+ * between a full roll and an empty one.
4288
+ */
4289
+ get centreShift() {
4290
+ return (this.outerRadius - this.params.core) / 2;
4291
+ }
4292
+ /** Outer radius of what is still wound. Shrinks to `core` as the roll empties. */
4293
+ get radius() {
4294
+ return rollRadius(this.totalLength - this.paid, this.params.core, layerThickness2(this.params.tightness));
4295
+ }
4296
+ /** Radius of the full roll. Fixes the spiral's centre, which must not move. */
4297
+ get outerRadius() {
4298
+ return rollRadius(this.totalLength, this.params.core, layerThickness2(this.params.tightness));
4299
+ }
4300
+ /**
4301
+ * The wrap that never leaves: one full turn around the core, but never so
4302
+ * much of a short sheet that there is nothing left to unroll.
4303
+ *
4304
+ * A method rather than a constant because `core` is a live control. It was
4305
+ * a constructor-time `readonly`, which meant dragging `core` moved the
4306
+ * radius the spiral is drawn at while `usableLength` and the end stop kept
4307
+ * answering for the old tube — the roll would run past its own floor, or
4308
+ * stop short of it, depending on which way the slider went.
4309
+ */
4310
+ stubFor(core) {
4311
+ return Math.min(Math.PI * 2 * core, this.totalLength * 0.3);
4312
+ }
4313
+ /** Paper that can actually leave the roll — everything but the glued stub. */
4314
+ get usableLength() {
4315
+ return this.totalLength - this.tubeStub;
4316
+ }
4317
+ /** How much paper is left to give. `0` is down to the tube, `1` is untouched. */
4318
+ get remaining() {
4319
+ return 1 - this.paid / this.usableLength;
4320
+ }
4321
+ /**
4322
+ * Where the paper leaves the roll: the frontmost point of the current
4323
+ * outer wrap. The spiral's CENTRE is fixed (a lesson the `roll` deformer
4324
+ * paid for — see its notes), so as the roll runs down the tangent point
4325
+ * travels back toward the holder and the strip hangs closer to it. That
4326
+ * drift is real and worth keeping.
4327
+ */
4328
+ get tangentZ() {
4329
+ return this.radius - this.outerRadius;
4330
+ }
4331
+ /** Place every node: the wound ones on the spiral, the free ones straight down. */
4332
+ layOut() {
4333
+ const firstFree = this.firstFreeIndex();
4334
+ for (let i = 0; i < this.count; i++) {
4335
+ const p = i * 2;
4336
+ if (i < firstFree) {
4337
+ this.spiralPoint(this.arc[i] - this.paid, p, this.pos);
4338
+ } else {
4339
+ this.pos[p] = -(this.paid - this.arc[i]);
4340
+ this.pos[p + 1] = this.tangentZ;
4341
+ }
4342
+ }
4343
+ this.prev.set(this.pos);
4344
+ }
4345
+ /** First node that has come off the roll. Everything below it is kinematic. */
4346
+ firstFreeIndex() {
4347
+ const free = Math.ceil(this.count - 1 - this.paid / this.segment);
4348
+ return Math.min(this.count - 1, Math.max(0, free));
4349
+ }
4350
+ /**
4351
+ * Position of a point `wound` along the spiral from the tangent point,
4352
+ * written into `out` at offset `o`.
4353
+ *
4354
+ * Angle 0 is the tangent point at the front of the roll; winding runs up
4355
+ * over the top, which is the "over" hang. The radius falls by one layer
4356
+ * per turn, so the wraps are concentric and exactly a thickness apart.
4357
+ */
4358
+ spiralPoint(wound, o, out) {
4359
+ const r0 = this.radius;
4360
+ const thickness = layerThickness2(this.params.tightness);
4361
+ const k = thickness / (Math.PI * 2);
4362
+ const phi = windAngle(Math.max(0, wound), r0, k);
4363
+ const floor = Math.max(this.params.core, safeSpiralRadius(this.segment, thickness));
4364
+ const r = Math.max(floor, r0 - k * phi);
4365
+ out[o] = r * Math.sin(phi);
4366
+ out[o + 1] = -this.outerRadius + r * Math.cos(phi);
4367
+ }
4368
+ setParams(params) {
4369
+ let changed = false;
4370
+ const tailBefore = this.params.tail;
4371
+ for (const key of [
4372
+ "scroll",
4373
+ "tightness",
4374
+ "core",
4375
+ "tail",
4376
+ "crease",
4377
+ "stiffness",
4378
+ "drag",
4379
+ "gravity",
4380
+ "floor",
4381
+ "inertia"
4382
+ ]) {
4383
+ const value = params[key];
4384
+ if (value !== void 0 && value !== this.params[key]) {
4385
+ this.params[key] = value;
4386
+ changed = true;
4387
+ }
4388
+ }
4389
+ if (params.core !== void 0) {
4390
+ this.tubeStub = this.stubFor(params.core);
4391
+ this.paid = Math.min(this.paid, this.usableLength);
4392
+ }
4393
+ if (params.tail !== void 0 && tailBefore !== this.params.tail) {
4394
+ if (!this.driven) {
4395
+ this.paid = Math.min(this.params.tail, this.usableLength);
4396
+ this.layOut();
4397
+ }
4398
+ }
4399
+ if (changed) this.wake();
4400
+ }
4401
+ wake() {
4402
+ this.asleep = false;
4403
+ this.stillFrames = 0;
4404
+ }
4405
+ /**
4406
+ * Take hold of the paper at a point, in the same y–z the vertex buffer is
4407
+ * written in — mesh-local coordinates, the lift already applied. Returns the
4408
+ * node caught, or -1 when there is no free paper to catch.
4409
+ *
4410
+ * Speaking the buffer's coordinates rather than the solver's is the whole
4411
+ * contract here: a caller has a raycast hit on the mesh and nothing else,
4412
+ * and the centring lift is an internal detail it has no way to know.
4413
+ *
4414
+ * Only paper that has left the roll can be caught — a wound node belongs to
4415
+ * the spiral, and pinning one would be pinning the roll itself.
4416
+ */
4417
+ grabNearest(y, z25) {
4418
+ const firstFree = this.firstFreeIndex();
4419
+ const localY = y - this.centreOffset;
4420
+ const localZ = z25 - this.centreShift;
4421
+ let best = -1;
4422
+ let bestDist = Infinity;
4423
+ for (let i = firstFree; i < this.count; i++) {
4424
+ const dy = this.pos[i * 2] - localY;
4425
+ const dz = this.pos[i * 2 + 1] - localZ;
4426
+ const d = dy * dy + dz * dz;
4427
+ if (d < bestDist) {
4428
+ bestDist = d;
4429
+ best = i;
4430
+ }
4431
+ }
4432
+ this.grabbed = best;
4433
+ this.grabY = localY;
4434
+ this.grabZ = localZ;
4435
+ this.wake();
4436
+ return best;
4437
+ }
4438
+ moveGrab(y, z25) {
4439
+ if (this.grabbed < 0) return;
4440
+ this.grabY = y - this.centreOffset;
4441
+ this.grabZ = z25 - this.centreShift;
4442
+ this.wake();
4443
+ }
4444
+ release() {
4445
+ this.grabbed = -1;
4446
+ }
4447
+ /** Whether a pointer currently holds the paper. */
4448
+ get held() {
4449
+ return this.grabbed >= 0;
4450
+ }
4451
+ step(delta) {
4452
+ if (this.params.scroll !== this.lastScroll) this.wake();
4453
+ if (this.asleep) return;
4454
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT2 * 4);
4455
+ while (this.accumulator >= FIXED_DT2) {
4456
+ this.substep(FIXED_DT2);
4457
+ this.accumulator -= FIXED_DT2;
4458
+ }
4459
+ }
4460
+ substep(dt) {
4461
+ this.time += dt;
4462
+ this.driveRoll(dt);
4463
+ this.pullFeed(dt);
4464
+ this.integrate(dt);
4465
+ const firstFree = this.firstFreeIndex();
4466
+ this.pinWound(firstFree);
4467
+ for (let iter = 0; iter < SOLVER_ITERATIONS2; iter++) {
4468
+ this.solveDistance(firstFree);
4469
+ this.solveBend(firstFree);
4470
+ if (iter >= SOLVER_ITERATIONS2 - COLLIDING_ITERATIONS) {
4471
+ this.solveSelfCollision(firstFree);
4472
+ this.solveFloor(firstFree);
4473
+ }
4474
+ this.pinWound(firstFree);
4475
+ }
4476
+ this.solveFloor(firstFree);
4477
+ }
4478
+ /**
4479
+ * Turn the roll. Scroll enters as an angle the roll OWES, which a leaky
4480
+ * integrator spends into angular velocity — so a steady scroll gives a
4481
+ * steady spin and a flick spikes and coasts down.
4482
+ *
4483
+ * `ΔL = R·Δθ` with the CURRENT radius, which is why the radius has to be
4484
+ * read inside the loop: a nearly-empty roll spins fast and gives up very
4485
+ * little paper for the same scroll, and that is the whole tell that a roll
4486
+ * is running out.
4487
+ */
4488
+ driveRoll(dt) {
4489
+ const scroll = this.params.scroll;
4490
+ if (!this.primed) {
4491
+ this.lastScroll = scroll;
4492
+ this.primed = true;
4493
+ }
4494
+ const impulse = (scroll - this.lastScroll) / this.outerRadius;
4495
+ this.lastScroll = scroll;
4496
+ const tau = 0.012 + this.params.inertia * 0.55;
4497
+ this.omega = this.omega * Math.exp(-dt / tau) + impulse / tau;
4498
+ if (Math.abs(this.omega) < 1e-6) this.omega = 0;
4499
+ const next = this.paid + this.radius * this.omega * dt;
4500
+ if (next !== this.paid) this.driven = true;
4501
+ this.paid = Math.min(this.usableLength, Math.max(0, next));
4502
+ if (this.paid === 0 || this.paid === this.usableLength) this.omega = 0;
4503
+ }
4504
+ /**
4505
+ * Take hold of the paper and pull, and the roll turns — the interaction the
4506
+ * real object is famous for.
4507
+ *
4508
+ * It is driven by TENSION rather than by mapping hand travel to an angle.
4509
+ * Paper does not stretch, so if the hand is further from the roll than there
4510
+ * is paper to reach it, the only way the constraint can be satisfied is for
4511
+ * the roll to give up more. That one rule gets the whole behaviour for free:
4512
+ * a slow pull feeds smoothly, a fast yank spins the roll and it carries on
4513
+ * after release, and pushing the paper back toward the roll does nothing at
4514
+ * all — which is exactly right, because slack does not rewind a roll. Only
4515
+ * scrolling up does.
4516
+ */
4517
+ pullFeed(dt) {
4518
+ if (this.grabbed < 0 || this.grabbed < this.firstFreeIndex()) return;
4519
+ const reach = Math.hypot(this.grabY, this.grabZ - this.tangentZ);
4520
+ const available = this.paid - this.arc[this.grabbed];
4521
+ const over = reach - available;
4522
+ if (over <= 1e-6) return;
4523
+ const feed = Math.min(over * PULL_STIFFNESS, this.usableLength - this.paid);
4524
+ if (feed <= 0) return;
4525
+ this.paid += feed;
4526
+ this.omega = feed / (this.radius * dt);
4527
+ }
4528
+ /**
4529
+ * Verlet with ANISOTROPIC drag. A strip of paper barely notices the air
4530
+ * when it moves along its own length and is stopped almost dead when it
4531
+ * moves broadside, which is the difference between paper that floats down
4532
+ * and a rope that drops. Damping the velocity uniformly — the obvious
4533
+ * thing, and what cloth does — gets neither.
4534
+ */
4535
+ integrate(dt) {
4536
+ const { gravity, drag } = this.params;
4537
+ const p = this.pos;
4538
+ const dt2 = dt * dt;
4539
+ const firstFree = this.firstFreeIndex();
4540
+ const across = Math.exp(-(0.6 + drag * 11) * dt);
4541
+ const along = Math.exp(-(0.4 + drag * 0.8) * dt);
4542
+ let travel = 0;
4543
+ for (let i = firstFree; i < this.count; i++) {
4544
+ const o = i * 2;
4545
+ if (i === this.grabbed) {
4546
+ p[o] = this.grabY;
4547
+ p[o + 1] = this.grabZ;
4548
+ continue;
4549
+ }
4550
+ const y = p[o];
4551
+ const z25 = p[o + 1];
4552
+ let vy = y - this.prev[o];
4553
+ let vz = z25 - this.prev[o + 1];
4554
+ const a = Math.max(firstFree, i - this.tangentNodes) * 2;
4555
+ const b = Math.min(this.count - 1, i + this.tangentNodes) * 2;
4556
+ let ty = p[b] - p[a];
4557
+ let tz = p[b + 1] - p[a + 1];
4558
+ const len = Math.hypot(ty, tz);
4559
+ if (len > 1e-9) {
4560
+ ty /= len;
4561
+ tz /= len;
4562
+ const vt = vy * ty + vz * tz;
4563
+ const ny = vy - vt * ty;
4564
+ const nz = vz - vt * tz;
4565
+ vy = vt * ty * along + ny * across;
4566
+ vz = vt * tz * along + nz * across;
4567
+ }
4568
+ this.prev[o] = y;
4569
+ this.prev[o + 1] = z25;
4570
+ const noise = Math.sin(this.time * 2.3 + i * 1.7) * 6e-5;
4571
+ p[o] = y + vy - gravity * 3.2 * dt2;
4572
+ p[o + 1] = z25 + vz + noise;
4573
+ travel = Math.max(travel, vy * vy + vz * vz);
4574
+ }
4575
+ if (this.omega === 0 && this.grabbed < 0) {
4576
+ if (travel < SLEEP_EPSILON2) {
4577
+ if (++this.stillFrames > SLEEP_FRAMES2) this.asleep = true;
4578
+ } else this.stillFrames = 0;
4579
+ } else this.stillFrames = 0;
4580
+ }
4581
+ /** Wound nodes are the roll's, not the solver's: rewrite them every pass. */
4582
+ pinWound(firstFree) {
4583
+ for (let i = 0; i < firstFree; i++) {
4584
+ this.spiralPoint(this.arc[i] - this.paid, i * 2, this.pos);
4585
+ }
4586
+ const tz = this.tangentZ;
4587
+ for (let i = firstFree; i < Math.min(this.count, firstFree + 2); i++) {
4588
+ if (i === this.grabbed) continue;
4589
+ const o = i * 2;
4590
+ this.pos[o + 1] = this.pos[o + 1] + (tz - this.pos[o + 1]) * 0.5;
4591
+ }
4592
+ if (this.grabbed >= firstFree) {
4593
+ this.pos[this.grabbed * 2] = this.grabY;
4594
+ this.pos[this.grabbed * 2 + 1] = this.grabZ;
4595
+ }
4596
+ }
4597
+ /** Paper does not stretch. Distance constraints run at full strength. */
4598
+ solveDistance(firstFree) {
4599
+ const p = this.pos;
4600
+ const rest = this.segment;
4601
+ for (let i = Math.max(0, firstFree - 1); i < this.count - 1; i++) {
4602
+ const a = i * 2;
4603
+ const b = (i + 1) * 2;
4604
+ const dy = p[b] - p[a];
4605
+ const dz = p[b + 1] - p[a + 1];
4606
+ const dist = Math.hypot(dy, dz);
4607
+ if (dist < 1e-9) continue;
4608
+ const diff = (dist - rest) / dist * 0.5;
4609
+ const aFixed = i < firstFree || i === this.grabbed;
4610
+ const bFixed = i + 1 === this.grabbed;
4611
+ if (aFixed && bFixed) continue;
4612
+ const aw = aFixed ? 0 : bFixed ? 2 : 1;
4613
+ const bw = bFixed ? 0 : aFixed ? 2 : 1;
4614
+ p[a] = p[a] + dy * diff * aw;
4615
+ p[a + 1] = p[a + 1] + dz * diff * aw;
4616
+ p[b] = p[b] - dy * diff * bw;
4617
+ p[b + 1] = p[b + 1] - dz * diff * bw;
4618
+ }
4619
+ }
4620
+ /**
4621
+ * The bend constraint, and the reason this is not a rope preset.
4622
+ *
4623
+ * Every joint wants to be straight. A joint AT A PERFORATION wants it far
4624
+ * less, and may want a slight fold instead — those are the hinges the pile
4625
+ * folds at, and the alternating sign is what turns a heap into an
4626
+ * accordion. Uniform stiffness gives a coil; no stiffness gives a rope.
4627
+ */
4628
+ solveBend(firstFree) {
4629
+ const p = this.pos;
4630
+ const { stiffness, crease } = this.params;
4631
+ const start = Math.max(firstFree + 1, 1);
4632
+ for (let i = start; i < this.count - 1; i++) {
4633
+ const hinge = this.perforated[i];
4634
+ const k = (hinge !== 0 ? stiffness * 0.06 : stiffness) * 0.45 * this.bendScale;
4635
+ if (k <= 0) continue;
4636
+ const a = (i - 1) * 2;
4637
+ const m = i * 2;
4638
+ const b = (i + 1) * 2;
4639
+ let ty = (p[a] + p[b]) * 0.5;
4640
+ let tz = (p[a + 1] + p[b + 1]) * 0.5;
4641
+ if (hinge !== 0 && crease > 0) {
4642
+ let cy = p[b] - p[a];
4643
+ let cz = p[b + 1] - p[a + 1];
4644
+ const len = Math.hypot(cy, cz);
4645
+ if (len > 1e-9) {
4646
+ cy /= len;
4647
+ cz /= len;
4648
+ const bow = crease * this.segment * 0.35 * hinge;
4649
+ ty += -cz * bow;
4650
+ tz += cy * bow;
4651
+ }
4652
+ }
4653
+ const dy = (ty - p[m]) * k;
4654
+ const dz = (tz - p[m + 1]) * k;
4655
+ if (i !== this.grabbed) {
4656
+ p[m] = p[m] + dy;
4657
+ p[m + 1] = p[m + 1] + dz;
4658
+ }
4659
+ if (i - 1 >= firstFree && i - 1 !== this.grabbed) {
4660
+ p[a] = p[a] - dy * 0.5;
4661
+ p[a + 1] = p[a + 1] - dz * 0.5;
4662
+ }
4663
+ if (i + 1 !== this.grabbed) {
4664
+ p[b] = p[b] - dy * 0.5;
4665
+ p[b + 1] = p[b + 1] - dz * 0.5;
4666
+ }
4667
+ }
4668
+ }
4669
+ /**
4670
+ * Folds stack on each other instead of passing through. Without this the
4671
+ * pile collapses into a single flat line on the floor and there is nothing
4672
+ * to look at, which is the entire reason the preset exists.
4673
+ *
4674
+ * **Segment against segment, not node against node.** The obvious version
4675
+ * puts a sphere on every node and pushes overlapping pairs apart, and it
4676
+ * leaks: paper is thinner than the chain is finely cut — the layer gap here
4677
+ * is 0.027 against a node spacing of 0.037 — so those spheres do not touch
4678
+ * each other, and the chain is a string of beads with gaps between them
4679
+ * rather than a continuous tube. Another fold threads straight through a
4680
+ * gap, and once it is through, the point test pushes it out the far side
4681
+ * instead of back. It shows up exactly as a sheet edge buried in a surface.
4682
+ *
4683
+ * Testing the SEGMENTS closes the gaps, because a segment is the whole
4684
+ * length of paper between two nodes and not just its ends. The cost is a
4685
+ * closest-approach solve per candidate pair instead of a subtraction, which
4686
+ * is why the hash cell is sized to `segment + d`: a pair that can possibly
4687
+ * touch has its midpoints inside that, so a 3×3 neighbourhood is a complete
4688
+ * search rather than a hopeful one.
4689
+ *
4690
+ * Spatial hash over preallocated arrays, refilled by counting sort — no
4691
+ * allocation, and it does not degrade when the whole strip lands in one
4692
+ * cell, which is exactly what a pile IS.
4693
+ */
4694
+ solveSelfCollision(firstFree) {
4695
+ this.freeFrom = firstFree;
4696
+ const p = this.pos;
4697
+ const d = Math.max(layerThickness2(this.params.tightness), 1e-4);
4698
+ const n = this.count;
4699
+ const last = n - 2;
4700
+ if (last < firstFree + 2) return;
4701
+ const cell = this.segment + d;
4702
+ const inv = 1 / cell;
4703
+ const table = this.tableSize;
4704
+ const mask = table - 1;
4705
+ const starts = this.bucketStart;
4706
+ const items = this.bucketItems;
4707
+ const placed = this.bucketFill;
4708
+ starts.fill(0);
4709
+ placed.fill(0);
4710
+ for (let i = firstFree; i <= last; i++) {
4711
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
4712
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
4713
+ const h = (Math.floor(my * inv) * 92837111 ^ Math.floor(mz * inv) * 689287499) >>> 0 & mask;
4714
+ this.cellOf[i] = h;
4715
+ starts[h + 1]++;
4716
+ }
4717
+ for (let c = 0; c < table; c++) starts[c + 1] += starts[c];
4718
+ for (let i = firstFree; i <= last; i++) {
4719
+ const h = this.cellOf[i];
4720
+ items[starts[h] + placed[h]++] = i;
4721
+ }
4722
+ for (let i = firstFree; i <= last; i++) {
4723
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
4724
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
4725
+ const cy = Math.floor(my * inv);
4726
+ const cz = Math.floor(mz * inv);
4727
+ for (let oy = -1; oy <= 1; oy++) {
4728
+ for (let oz = -1; oz <= 1; oz++) {
4729
+ const h = ((cy + oy) * 92837111 ^ (cz + oz) * 689287499) >>> 0 & mask;
4730
+ const end = starts[h + 1];
4731
+ for (let e = starts[h]; e < end; e++) {
4732
+ const j = items[e];
4733
+ if (j >= i + 2) this.separate(i, j, d);
4734
+ }
4735
+ }
4736
+ }
4737
+ }
4738
+ }
4739
+ /**
4740
+ * Push segments `(i, i+1)` and `(j, j+1)` apart to `d`, if they are closer.
4741
+ *
4742
+ * Closest approach between two 2D segments, then the correction shared over
4743
+ * the four endpoints by how near each is to the touching point — so a fold
4744
+ * caught at its middle moves bodily, and one grazed at its tip barely
4745
+ * pivots. Wound and held nodes are the roll's and the hand's, so they take
4746
+ * none of it and their partner takes the lot.
4747
+ */
4748
+ separate(i, j, d) {
4749
+ const p = this.pos;
4750
+ const a = i * 2;
4751
+ const b = (i + 1) * 2;
4752
+ const c = j * 2;
4753
+ const e = (j + 1) * 2;
4754
+ const uy = p[b] - p[a];
4755
+ const uz = p[b + 1] - p[a + 1];
4756
+ const vy = p[e] - p[c];
4757
+ const vz = p[e + 1] - p[c + 1];
4758
+ const wy = p[a] - p[c];
4759
+ const wz = p[a + 1] - p[c + 1];
4760
+ const uu = uy * uy + uz * uz;
4761
+ const vv = vy * vy + vz * vz;
4762
+ if (uu < 1e-12 || vv < 1e-12) return;
4763
+ const uv = uy * vy + uz * vz;
4764
+ const uw = uy * wy + uz * wz;
4765
+ const vw = vy * wy + vz * wz;
4766
+ const denom = uu * vv - uv * uv;
4767
+ let s = denom > 1e-12 ? (uv * vw - vv * uw) / denom : 0;
4768
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
4769
+ let t = (uv * s + vw) / vv;
4770
+ if (t < 0) {
4771
+ t = 0;
4772
+ s = -uw / uu;
4773
+ } else if (t > 1) {
4774
+ t = 1;
4775
+ s = (uv - uw) / uu;
4776
+ }
4777
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
4778
+ let ny = p[c] + vy * t - (p[a] + uy * s);
4779
+ let nz = p[c + 1] + vz * t - (p[a + 1] + uz * s);
4780
+ let dist = Math.hypot(ny, nz);
4781
+ if (dist >= d) return;
4782
+ if (dist < 1e-9) {
4783
+ ny = -uz;
4784
+ nz = uy;
4785
+ dist = Math.hypot(ny, nz);
4786
+ if (dist < 1e-12) return;
4787
+ ny = -ny;
4788
+ nz = -nz;
4789
+ }
4790
+ const push = (d - dist) / dist * COLLISION_RELAXATION;
4791
+ const gy = ny * push;
4792
+ const gz = nz * push;
4793
+ const ki = 0.5 / ((1 - s) * (1 - s) + s * s);
4794
+ const kj = 0.5 / ((1 - t) * (1 - t) + t * t);
4795
+ this.nudge(i, -gy * (1 - s) * ki, -gz * (1 - s) * ki);
4796
+ this.nudge(i + 1, -gy * s * ki, -gz * s * ki);
4797
+ this.nudge(j, gy * (1 - t) * kj, gz * (1 - t) * kj);
4798
+ this.nudge(j + 1, gy * t * kj, gz * t * kj);
4799
+ }
4800
+ /** First free node for the substep in flight — `nudge` runs in the innermost
4801
+ * collision loop and must not recompute it per call. */
4802
+ freeFrom = 0;
4803
+ /** Move a node, unless the roll or the hand owns it. */
4804
+ nudge(i, dy, dz) {
4805
+ if (i === this.grabbed || i < this.freeFrom) return;
4806
+ this.pos[i * 2] = this.pos[i * 2] + dy;
4807
+ this.pos[i * 2 + 1] = this.pos[i * 2 + 1] + dz;
4808
+ }
4809
+ /** Restitution 0, friction high. Paper lands and stays; it does not slide. */
4810
+ solveFloor(firstFree) {
4811
+ const p = this.pos;
4812
+ const floor = -this.params.floor;
4813
+ for (let i = firstFree; i < this.count; i++) {
4814
+ const o = i * 2;
4815
+ if (i === this.grabbed || p[o] >= floor) continue;
4816
+ p[o] = floor;
4817
+ this.prev[o] = floor;
4818
+ this.prev[o + 1] = this.prev[o + 1] + (p[o + 1] - this.prev[o + 1]) * 0.92;
4819
+ }
4820
+ }
4821
+ /**
4822
+ * Write the chain into a 2×N quad strip, in PlaneGeometry vertex order:
4823
+ * row-major, top row first, x left→right. Row `i` is node `i`, so the
4824
+ * content texture runs down the strip and folds with it.
4825
+ */
4826
+ writeInto(out) {
4827
+ const lift = this.centreOffset;
4828
+ const shift = this.centreShift;
4829
+ for (let i = 0; i < this.count; i++) {
4830
+ const o = i * 2;
4831
+ const y = this.pos[o] + lift;
4832
+ const z25 = this.pos[o + 1] + shift;
4833
+ const v = i * 6;
4834
+ out[v] = -this.halfWidth;
4835
+ out[v + 1] = y;
4836
+ out[v + 2] = z25;
4837
+ out[v + 3] = this.halfWidth;
4838
+ out[v + 4] = y;
4839
+ out[v + 5] = z25;
4840
+ }
4841
+ }
4842
+ };
4843
+
3261
4844
  // src/surface/PaperMaterial.tsx
3262
4845
  var THREE6 = __toESM(require("three"), 1);
3263
4846
  var import_react4 = require("react");
@@ -3553,6 +5136,7 @@ function translucencyUniforms(translucency, lighting) {
3553
5136
  }
3554
5137
 
3555
5138
  // src/surface/compose.ts
5139
+ var SHADED_CREASE = 0.35;
3556
5140
  var VERTEX = (
3557
5141
  /* glsl */
3558
5142
  `
@@ -3569,6 +5153,89 @@ var HELPERS = (
3569
5153
  `
3570
5154
  varying vec2 vPaperUv;
3571
5155
  uniform float uBackDarken;
5156
+ uniform vec2 uSheetSize;
5157
+
5158
+ /**
5159
+ * Where this fragment is on the sheet, in the sheet's OWN local space \u2014
5160
+ * the same coordinates the deformers displace, centred on the sheet.
5161
+ *
5162
+ * Every effect below measures in these rather than in UV, and the difference
5163
+ * is not cosmetic. UV divides the sheet's aspect out, so a 1.2 x 1.5 sheet is
5164
+ * a unit square as far as the shader is concerned: fibre drawn round comes out
5165
+ * stretched, a tear bites deeper into the short edge than the long one, and a
5166
+ * crease line scored at 45 degrees renders at 51. Worse, all three change when
5167
+ * the sheet is RESIZED, which makes the paper's own material a function of how
5168
+ * big the piece is. Grain is a property of the stock and a crease is a broken
5169
+ * fibre; neither knows how large a sheet it was cut from.
5170
+ */
5171
+ vec2 plLocal() {
5172
+ return (vPaperUv - 0.5) * uSheetSize;
5173
+ }
5174
+
5175
+ /**
5176
+ * The paper's relief, in world units above the sheet the mesh describes.
5177
+ *
5178
+ * Accumulated by whichever effects have a shape as well as a colour, and
5179
+ * spent once at the end of main by {@link plPerturb}. One shared field rather
5180
+ * than a perturbation per effect, because two effects that both tilt the
5181
+ * surface tilt it TOGETHER \u2014 a crease across a grained sheet is one surface,
5182
+ * not a crease lit on top of a grain lit on top of the paper.
5183
+ */
5184
+ float plHeight;
5185
+
5186
+ /**
5187
+ * The relief, turned into the normal the lighting actually runs on.
5188
+ *
5189
+ * This is the change that makes the surface effects respond to light at all.
5190
+ * They used to be painted: a crease multiplied a grey band into the albedo
5191
+ * and added a fixed white sheen beside it, so the mark looked identical from
5192
+ * every angle and under every rig, and turning the sheet under the key light
5193
+ * did nothing to it. Real creased paper is two facets meeting at a line \u2014
5194
+ * swing it and the crease flips from a dark line to a bright one. Only a
5195
+ * normal can do that, so now the effects describe a HEIGHT and the standard
5196
+ * material lights it.
5197
+ *
5198
+ * The maths is Mikkelsen's surface-gradient bump, which is what three's own
5199
+ * perturbNormalArb implements, with one deliberate difference: three
5200
+ * normalises the screen-space position derivatives, which makes a bump map
5201
+ * look the same at any scale and is the right call for a texture. Ours is a
5202
+ * real depth in world units \u2014 a crease is as deep as it is however close you
5203
+ * stand \u2014 so the raw derivatives stay, and the ratio between them and the
5204
+ * height's is a true surface slope.
5205
+ *
5206
+ * Analytic height plus screen derivatives also anti-aliases itself for free:
5207
+ * as a crease shrinks below a pixel the derivative flattens and the mark
5208
+ * fades, rather than crawling.
5209
+ */
5210
+ vec3 plPerturb(vec3 n, float height) {
5211
+ vec2 dH = vec2(dFdx(height), dFdy(height));
5212
+ if (dH.x == 0.0 && dH.y == 0.0) return n;
5213
+ // View-space position: the varying is its negation, by three's convention.
5214
+ vec3 sigmaX = dFdx(-vViewPosition);
5215
+ vec3 sigmaY = dFdy(-vViewPosition);
5216
+ vec3 r1 = cross(sigmaY, n);
5217
+ vec3 r2 = cross(n, sigmaX);
5218
+ float det = dot(sigmaX, r1) * (gl_FrontFacing ? 1.0 : -1.0);
5219
+ if (abs(det) < 1e-12) return n;
5220
+ vec3 grad = sign(det) * (dH.x * r1 + dH.y * r2);
5221
+ return normalize(abs(det) * n - grad);
5222
+ }
5223
+
5224
+ /**
5225
+ * A gaussian bell of unit width, pre-filtered against this fragment's own
5226
+ * footprint.
5227
+ *
5228
+ * Convolving a gaussian with the pixel broadens it and flattens it by the
5229
+ * same factor, which conserves the integral: a crease seen from across the
5230
+ * room dims instead of breaking into a dotted line. s is the distance
5231
+ * across the feature in units of its own width, so a caller only ever has to
5232
+ * decide how wide the thing is.
5233
+ */
5234
+ float plBell(float s) {
5235
+ float px = fwidth(s);
5236
+ float widen = sqrt(1.0 + px * px);
5237
+ return exp(-(s * s) / (widen * widen)) / widen;
5238
+ }
3572
5239
 
3573
5240
  float plHash(vec2 p) {
3574
5241
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
@@ -3609,12 +5276,54 @@ var GRAIN_CHUNK = (
3609
5276
  uniform float uGrainAmount;
3610
5277
  uniform float uGrainBanding;
3611
5278
 
5279
+ /**
5280
+ * Fibre density, per world unit.
5281
+ *
5282
+ * Per WORLD UNIT and not per UV, which is the whole fix: the fibre in a sheet
5283
+ * is the stock's, and it does not get coarser because someone cut a bigger
5284
+ * piece or stretch oval because the piece is taller than it is wide.
5285
+ *
5286
+ * The number is carried over from when it was a UV frequency, so a sheet one
5287
+ * world unit wide is unchanged across its width. Its height is not, and that
5288
+ * is the point: a 1.4-tall sheet used to fit the same 240 cycles into a
5289
+ * longer span and its fibre ran visibly coarser the other way.
5290
+ */
5291
+ const float PL_FIBRE = 240.0;
5292
+
5293
+ /**
5294
+ * The coarser structure underneath it \u2014 paper's tooth, the part that has a
5295
+ * SHAPE and not just a colour.
5296
+ *
5297
+ * Separate from the fibre, and much lower, for a reason worth writing down:
5298
+ * the relief is differentiated in screen space, and a field at the fibre's own
5299
+ * frequency is a few pixels per cycle at any sane viewing distance, so its
5300
+ * derivative is noise and the sheet would sparkle. The tooth is safely above
5301
+ * the sampling rate, and it is the scale at which paper actually catches a
5302
+ * raking light anyway.
5303
+ */
5304
+ const float PL_TOOTH = 70.0;
5305
+
5306
+ /**
5307
+ * How far the tooth stands proud, in world units, at full grain.
5308
+ *
5309
+ * Real paper's surface relief is tens of microns. Against a sheet whose width
5310
+ * is one world unit \u2014 call it A4 \u2014 0.00035 is about 70 microns, and at the
5311
+ * tooth's wavelength that is a surface tilting by four degrees or so. Enough
5312
+ * to break a specular highlight into paper, not enough to look pebbled.
5313
+ */
5314
+ const float PL_TOOTH_RELIEF = 0.00035;
5315
+
3612
5316
  void plGrain(inout vec4 color, inout float rough) {
3613
- float fiber = plFbm(vPaperUv * 240.0);
3614
- float fleck = plNoise(vPaperUv * 900.0);
5317
+ vec2 local = plLocal();
5318
+ float fiber = plFbm(local * PL_FIBRE);
5319
+ float fleck = plNoise(local * (PL_FIBRE * 3.75));
3615
5320
  float g = mix(0.5, fiber * 0.75 + fleck * 0.25, uGrainAmount);
3616
5321
  color.rgb *= 0.92 + g * 0.16;
3617
5322
  rough = clamp(rough + (g - 0.5) * uGrainAmount * 0.35, 0.0, 1.0);
5323
+ // The tooth, handed to the lighting rather than drawn. A single octave: the
5324
+ // relief only needs the scale the eye reads as texture, and the fbm above
5325
+ // is already carrying everything finer as colour.
5326
+ plHeight += (plNoise(local * PL_TOOTH) - 0.5) * PL_TOOTH_RELIEF * uGrainAmount;
3618
5327
  // Thermal-printer banding: faint horizontal density stripes.
3619
5328
  if (uGrainBanding > 0.0) {
3620
5329
  float band = sin(vPaperUv.y * 700.0) * 0.5 + 0.5;
@@ -3629,18 +5338,33 @@ var DECKLE_CHUNK = (
3629
5338
  uniform vec4 uDeckleEdges; // top, right, bottom, left
3630
5339
  uniform float uDeckleRoughness;
3631
5340
 
5341
+ /** Gnaw frequency along a torn edge, per world unit \u2014 see {@link plLocal}. */
5342
+ const float PL_DECKLE_GNAW = 26.0;
5343
+
3632
5344
  void plDeckle(inout vec4 color) {
3633
5345
  // Distance to each selected edge, gnawed by low-frequency noise.
3634
- float depth = 0.012 + uDeckleRoughness * 0.05;
5346
+ //
5347
+ // The depth is in world units, taken against the sheet's mean dimension.
5348
+ // Against the MEAN rather than each edge's own span, which is what UV
5349
+ // amounted to: one roughness used to bite a third deeper into the short
5350
+ // edges of a 1 x 1.4 sheet than the long ones, for no reason anybody chose.
5351
+ //
5352
+ // Still proportional to the sheet rather than absolute, which is a decision
5353
+ // and not an oversight. A real deckle is a fibre length and would be the
5354
+ // same depth on any size of sheet; roughness is a 0..1 knob someone types,
5355
+ // and an absolute one would vanish on a poster and swallow a stamp.
5356
+ float depth = (0.012 + uDeckleRoughness * 0.05) * (uSheetSize.x + uSheetSize.y) * 0.5;
3635
5357
  float tear = 1.0;
3636
5358
  float fiberBand = 0.0;
3637
5359
  vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3638
5360
  vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
5361
+ vec4 distScale = vec4(uSheetSize.y, uSheetSize.x, uSheetSize.y, uSheetSize.x);
5362
+ vec4 alongScale = vec4(uSheetSize.x, uSheetSize.y, uSheetSize.x, uSheetSize.y);
3639
5363
  for (int e = 0; e < 4; e++) {
3640
5364
  if (uDeckleEdges[e] < 0.5) continue;
3641
- float n = plFbm(vec2(alongs[e] * 26.0, float(e) * 7.31)) - 0.5;
5365
+ float n = plFbm(vec2(alongs[e] * alongScale[e] * PL_DECKLE_GNAW, float(e) * 7.31)) - 0.5;
3642
5366
  float boundary = depth * (0.55 + n * 1.6);
3643
- float d = dists[e] - boundary;
5367
+ float d = dists[e] * distScale[e] - boundary;
3644
5368
  tear = min(tear, step(0.0, d));
3645
5369
  // Lightened fiber band just inside the tear.
3646
5370
  fiberBand = max(fiberBand, smoothstep(depth * 1.4, 0.0, d) * step(0.0, d));
@@ -3653,23 +5377,55 @@ void plDeckle(inout vec4 color) {
3653
5377
  var CREASE_CHUNK = (
3654
5378
  /* glsl */
3655
5379
  `
3656
- uniform float uCreaseAngle;
3657
- uniform float uCreaseStrength;
3658
- uniform float uCreasePositions[4];
5380
+ uniform float uCreaseAngles[4];
5381
+ uniform float uCreaseStrengths[4];
5382
+ uniform float uCreaseOffsets[4];
5383
+ uniform float uCreaseWidth;
3659
5384
  uniform int uCreaseCount;
3660
5385
 
5386
+ /**
5387
+ * Peak tilt of a crease's own facets, as a slope.
5388
+ *
5389
+ * A gaussian groove of amplitude A and width w reaches a maximum slope of
5390
+ * about 0.86 A/w, so an amplitude of 0.55 w peaks near 25 degrees \u2014 steep
5391
+ * enough that turning the sheet visibly flips the line from dark to bright,
5392
+ * shallow enough that it never reads as a fold in its own right. Held as a
5393
+ * SLOPE rather than a depth because that is the quantity the lighting
5394
+ * responds to, and the only one that stays honest when the width changes.
5395
+ */
5396
+ const float PL_CREASE_TILT = 0.55;
5397
+
5398
+ /** How much grime a crease traps, at full strength. */
5399
+ const float PL_CREASE_SOIL = 0.1;
5400
+
3661
5401
  void plCrease(inout vec4 color, inout float rough) {
3662
- vec2 dir = vec2(cos(uCreaseAngle), sin(uCreaseAngle));
3663
- // Coordinate across the crease lines (0..1 over the sheet).
3664
- float t = dot(vPaperUv - 0.5, vec2(-dir.y, dir.x)) + 0.5;
5402
+ vec2 p = plLocal();
3665
5403
  for (int i = 0; i < 4; i++) {
3666
5404
  if (i >= uCreaseCount) break;
3667
- float d = abs(t - uCreasePositions[i]);
3668
- float shadow = smoothstep(0.014, 0.0, d);
3669
- float sheen = smoothstep(0.02, 0.006, d) - smoothstep(0.006, 0.0, d);
3670
- color.rgb *= 1.0 - shadow * uCreaseStrength * 0.28;
3671
- color.rgb += sheen * uCreaseStrength * 0.05;
3672
- rough = clamp(rough + shadow * uCreaseStrength * 0.2, 0.0, 1.0);
5405
+ vec2 dir = vec2(cos(uCreaseAngles[i]), sin(uCreaseAngles[i]));
5406
+ // The identical measurement the fold deformer displaces by: signed
5407
+ // distance across the line, in the sheet's own space. Shading and
5408
+ // geometry cannot place a crease differently when the number they place
5409
+ // it by is the same number.
5410
+ float s = (dot(p, dir) - uCreaseOffsets[i]) / uCreaseWidth;
5411
+ float strength = uCreaseStrengths[i];
5412
+ float bell = plBell(s);
5413
+
5414
+ // The relief. This is the fine burnished line where the fibres broke, and
5415
+ // it is deliberately narrower than the hinge the fold deformer bends
5416
+ // over: the mesh carries the wide bend, the shader carries the crease
5417
+ // inside it, and the two add up instead of competing. Signed, so a
5418
+ // mountain stands proud and a valley cuts in \u2014 the same crease read from
5419
+ // the other side of the sheet is the other one.
5420
+ plHeight += strength * uCreaseWidth * PL_CREASE_TILT * bell;
5421
+
5422
+ // What is left for the albedo once the lighting is doing the work: a
5423
+ // crease collects dirt and its broken fibres scatter wider. The grey band
5424
+ // and the painted-on sheen that used to live here were standing in for a
5425
+ // normal, and there is one now.
5426
+ float mark = bell * abs(strength);
5427
+ color.rgb *= 1.0 - mark * PL_CREASE_SOIL;
5428
+ rough = clamp(rough + mark * 0.3, 0.0, 1.0);
3673
5429
  }
3674
5430
  }
3675
5431
  `
@@ -3681,7 +5437,6 @@ uniform vec4 uPerfEdges; // top, right, bottom, left enabled
3681
5437
  uniform vec4 uPerfTorn; // 1 = ripped-through profile, 0 = clean punches
3682
5438
  uniform float uPerfRadius; // world units
3683
5439
  uniform float uPerfSpacing;
3684
- uniform vec2 uSheetSize;
3685
5440
 
3686
5441
  void plPerforation(inout vec4 color) {
3687
5442
  // Per-edge distance/along coordinates, converted from UV to world units so
@@ -3732,11 +5487,10 @@ void plAging(inout vec4 color) {
3732
5487
  }
3733
5488
  `
3734
5489
  );
3735
- function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
5490
+ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio", creases = resolveCreases(surface, [], sheet2)) {
3736
5491
  const grain = surface.grain ?? stock.defaultSurface.grain;
3737
5492
  const aging = surface.aging ?? stock.defaultSurface.aging;
3738
5493
  const deckle = surface.deckle;
3739
- const creases = surface.creaseLines;
3740
5494
  const perforation = surface.perforation;
3741
5495
  const banding = stock.banding;
3742
5496
  const showThrough = stock.adhesive ? 0 : surface.showThrough ?? stock.showThrough;
@@ -3749,6 +5503,9 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
3749
5503
  value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
3750
5504
  },
3751
5505
  uStockColor: { value: new THREE5.Color(stock.color) },
5506
+ // Always present, not just when something asks for it: every effect that
5507
+ // measures anything measures in the sheet's own space now — see plLocal.
5508
+ uSheetSize: { value: new THREE5.Vector2(sheet2.width, sheet2.height) },
3752
5509
  uOpacity: { value: stock.opacity },
3753
5510
  uShowThrough: { value: showThrough },
3754
5511
  // Always compiled in: the shader early-outs at zero translucency, which
@@ -3781,21 +5538,22 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
3781
5538
  };
3782
5539
  uniforms.uPerfRadius = { value: perforation.holeRadius };
3783
5540
  uniforms.uPerfSpacing = { value: perforation.spacing };
3784
- uniforms.uSheetSize = { value: new THREE5.Vector2(sheet2.width, sheet2.height) };
3785
5541
  }
3786
- if (creases) {
5542
+ if (creases.length > 0) {
3787
5543
  chunks.push(CREASE_CHUNK);
3788
5544
  calls.push("plCrease(csm_DiffuseColor, csm_Roughness);");
3789
- uniforms.uCreaseAngle = { value: creases.angle * Math.PI / 180 };
3790
- uniforms.uCreaseStrength = { value: creases.strength };
3791
- uniforms.uCreasePositions = { value: padPositions(creases.positions) };
3792
- uniforms.uCreaseCount = { value: Math.min(creases.positions.length, 4) };
5545
+ uniforms.uCreaseAngles = { value: pad(creases.map((c) => c.angle * Math.PI / 180)) };
5546
+ uniforms.uCreaseStrengths = { value: pad(creases.map((c) => c.strength)) };
5547
+ uniforms.uCreaseOffsets = { value: pad(creases.map((c) => c.offset)) };
5548
+ uniforms.uCreaseWidth = { value: CREASE_RADIUS * SHADED_CREASE };
5549
+ uniforms.uCreaseCount = { value: Math.min(creases.length, 4) };
3793
5550
  }
3794
5551
  if (aging !== void 0) {
3795
5552
  chunks.push(AGING_CHUNK);
3796
5553
  calls.push("plAging(csm_DiffuseColor);");
3797
5554
  uniforms.uAgingAmount = { value: aging };
3798
5555
  }
5556
+ const relief = grain !== void 0 || creases.length > 0;
3799
5557
  const frontExpr = maps.hasFrontMap ? "texture2D(uFrontMap, vPaperUv).rgb" : "uStockColor";
3800
5558
  const backBaseExpr = stock.adhesive ? "vec3(0.965, 0.96, 0.945)" : maps.hasBackMap ? "texture2D(uBackMap, vec2(1.0 - vPaperUv.x, vPaperUv.y)).rgb" : "uStockColor";
3801
5559
  const fragmentShader = (
@@ -3810,6 +5568,7 @@ ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
3810
5568
  ${TRANSLUCENCY_FRAGMENT}
3811
5569
  ${chunks.join("\n")}
3812
5570
  void main() {
5571
+ plHeight = 0.0;
3813
5572
  vec3 front = ${frontExpr};
3814
5573
  if (gl_FrontFacing) {
3815
5574
  csm_DiffuseColor = vec4(front, uOpacity);
@@ -3818,6 +5577,7 @@ void main() {
3818
5577
  csm_DiffuseColor = vec4(backBase * mix(vec3(1.0), front, uShowThrough), uOpacity);
3819
5578
  }
3820
5579
  ${calls.join("\n ")}
5580
+ ${relief ? " // The relief every effect above described, spent once \u2014 see plPerturb.\n csm_FragNormal = plPerturb(csm_FragNormal, plHeight);" : ""}
3821
5581
  if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
3822
5582
  ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
3823
5583
  // What the key light pushes through the sheet, filtered by the ink on it.
@@ -3829,7 +5589,7 @@ void main() {
3829
5589
  structureKey: `${[
3830
5590
  grain !== void 0 || banding > 0 ? "g" : "",
3831
5591
  deckle ? "d" : "",
3832
- creases ? "c" : "",
5592
+ creases.length > 0 ? "c" : "",
3833
5593
  aging !== void 0 ? "a" : "",
3834
5594
  perforation ? "p" : "",
3835
5595
  stock.adhesive ? "A" : ""
@@ -3840,9 +5600,9 @@ void main() {
3840
5600
  alphaTest: deckle || perforation ? 0.5 : 0
3841
5601
  };
3842
5602
  }
3843
- function padPositions(positions) {
3844
- const out = positions.slice(0, 4);
3845
- while (out.length < 4) out.push(-1);
5603
+ function pad(values, fill = 0) {
5604
+ const out = values.slice(0, 4);
5605
+ while (out.length < 4) out.push(fill);
3846
5606
  return out;
3847
5607
  }
3848
5608
 
@@ -3866,7 +5626,8 @@ function PaperMaterial({
3866
5626
  surface,
3867
5627
  thickness,
3868
5628
  sheet: sheet2,
3869
- lighting = "studio"
5629
+ lighting = "studio",
5630
+ creases
3870
5631
  }) {
3871
5632
  const rig = useLightRig(lighting);
3872
5633
  const composed = composeSurface(
@@ -3878,7 +5639,8 @@ function PaperMaterial({
3878
5639
  hasBackMap: Boolean(backTexture)
3879
5640
  },
3880
5641
  sheet2,
3881
- rig
5642
+ rig,
5643
+ creases ?? resolveCreases(surface, [], sheet2 ?? { width: 1, height: 1.4 })
3882
5644
  );
3883
5645
  const bound = (0, import_react4.useMemo)(() => composed.uniforms, [composed.structureKey]);
3884
5646
  (0, import_react4.useEffect)(() => {
@@ -4334,6 +6096,7 @@ function configInputs(props) {
4334
6096
  props.behavior ?? null,
4335
6097
  props.deformers ?? null,
4336
6098
  props.surface ?? null,
6099
+ props.memory ?? null,
4337
6100
  props.scene ?? null,
4338
6101
  props.physics ?? null,
4339
6102
  props.onTwos ?? null
@@ -4352,6 +6115,7 @@ function resolveConfig(props) {
4352
6115
  if (props.behavior) overrides.behavior = props.behavior;
4353
6116
  if (props.deformers) overrides.deformers = props.deformers;
4354
6117
  if (props.surface) overrides.surface = { ...base.surface, ...props.surface };
6118
+ if (props.memory) overrides.memory = { ...base.memory, ...props.memory };
4355
6119
  if (props.scene) overrides.scene = { ...base.scene, ...props.scene };
4356
6120
  if (props.physics) overrides.physics = props.physics;
4357
6121
  if (props.onTwos !== void 0) overrides.onTwos = props.onTwos;
@@ -4379,7 +6143,9 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4379
6143
  machineRef.current = machine;
4380
6144
  const resolvedRef = (0, import_react7.useRef)(resolved);
4381
6145
  resolvedRef.current = resolved;
4382
- const isCloth = !reduced && typeof config.physics === "object";
6146
+ const simKind = !reduced && typeof config.physics === "object" ? config.physics.type : null;
6147
+ const isCloth = simKind === "cloth";
6148
+ const isStrip = simKind === "strip";
4383
6149
  const idle = !reduced && typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4384
6150
  const meshRef = (0, import_react7.useRef)(null);
4385
6151
  const groupRef = (0, import_react7.useRef)(null);
@@ -4391,19 +6157,33 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4391
6157
  const draggingRef = (0, import_react7.useRef)(null);
4392
6158
  const configRef = (0, import_react7.useRef)(config);
4393
6159
  configRef.current = config;
6160
+ const baseRotation = (0, import_react7.useMemo)(() => {
6161
+ const [rx, ry, rz] = props.rotation ?? [0, 0, 0];
6162
+ return [rx, ry + config.scene.turn * Math.PI / 180, rz];
6163
+ }, [props.rotation, config.scene.turn]);
4394
6164
  const controls = (0, import_fiber.useThree)((s) => s.controls);
4395
6165
  const camera = (0, import_fiber.useThree)((s) => s.camera);
4396
6166
  const behaviorKey = JSON.stringify(config.behavior ?? null);
4397
6167
  const deformersKey = JSON.stringify(config.deformers ?? null);
4398
6168
  const sheetKey = JSON.stringify(config.sheet);
4399
- const physicsKey = JSON.stringify(config.physics);
6169
+ const physicsKey = typeof config.physics === "object" ? config.physics.type : config.physics;
6170
+ const memoryKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}`).join("|");
6171
+ const creaseKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}:${c.depth}`).join("|");
6172
+ const creasesRef = (0, import_react7.useRef)(null);
6173
+ creasesRef.current ??= new CreaseTracker(config.memory.creases);
6174
+ const creases = creasesRef.current;
4400
6175
  (0, import_react7.useEffect)(() => {
4401
6176
  if (!draggingRef.current && !playingRef.current) overridesRef.current = {};
6177
+ creases.reset();
4402
6178
  dirtyRef.current = true;
4403
6179
  }, [behaviorKey, deformersKey, sheetKey, physicsKey]);
6180
+ (0, import_react7.useEffect)(() => {
6181
+ creases.adopt(configRef.current.memory.creases);
6182
+ dirtyRef.current = true;
6183
+ }, [creaseKey]);
4404
6184
  const { minSegments, autoSegments, animatedStack } = (0, import_react7.useMemo)(() => {
4405
6185
  const cfg = configRef.current;
4406
- const probe2 = buildStack(cfg, {});
6186
+ const probe2 = withMemory(buildStack(cfg, {}), cfg);
4407
6187
  if (!probe2) {
4408
6188
  return {
4409
6189
  minSegments: [2, 2],
@@ -4416,7 +6196,7 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4416
6196
  if (cfg.behavior && !cfg.deformers) {
4417
6197
  const param = getBehavior(cfg.behavior.type).progressParam;
4418
6198
  for (const p of PROGRESS_SAMPLES) {
4419
- const at = buildStack(cfg, { [param]: p });
6199
+ const at = withMemory(buildStack(cfg, { [param]: p }), cfg);
4420
6200
  if (!at) continue;
4421
6201
  const [x, y] = stackAutoSegments(at, cfg.sheet);
4422
6202
  if (x > want[0]) want[0] = x;
@@ -4429,30 +6209,65 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4429
6209
  autoSegments: want,
4430
6210
  animatedStack: animated
4431
6211
  };
4432
- }, [behaviorKey, deformersKey, physicsKey]);
6212
+ }, [behaviorKey, deformersKey, physicsKey, memoryKey]);
4433
6213
  const geometry = (0, import_react7.useMemo)(() => {
6214
+ if (isStrip) {
6215
+ const strip = configRef.current.physics;
6216
+ const nodes = stripNodeCount(config.sheet.height, strip.perforation);
6217
+ return new THREE7.PlaneGeometry(config.sheet.width, config.sheet.height, 1, nodes - 1);
6218
+ }
4434
6219
  if (!isCloth) return createSheetGeometry(config.sheet, minSegments, autoSegments);
4435
- const [sx, sy] = resolveSegments(config.sheet, 2);
6220
+ const [sx, sy] = resolveSegments(config.sheet, minSegments);
4436
6221
  const capped = Math.min(Math.max(sx, sy), CLOTH_MAX_SEGMENTS);
4437
6222
  return new THREE7.PlaneGeometry(config.sheet.width, config.sheet.height, capped, capped);
4438
- }, [sheetKey, minSegments, autoSegments, isCloth]);
6223
+ }, [
6224
+ sheetKey,
6225
+ minSegments,
6226
+ autoSegments,
6227
+ isCloth,
6228
+ isStrip,
6229
+ isStrip ? config.physics.perforation : 0
6230
+ ]);
4439
6231
  (0, import_react7.useEffect)(() => () => geometry.dispose(), [geometry]);
4440
6232
  const basePositions = (0, import_react7.useMemo)(
4441
6233
  () => Float32Array.from(geometry.attributes.position.array),
4442
6234
  [geometry]
4443
6235
  );
6236
+ const stripSim = (0, import_react7.useMemo)(() => {
6237
+ if (!isStrip) return null;
6238
+ const strip = configRef.current.physics;
6239
+ return new StripSim(config.sheet.height, config.sheet.width, {
6240
+ scroll: strip.scroll,
6241
+ tightness: strip.tightness,
6242
+ core: strip.core,
6243
+ tail: strip.tail,
6244
+ perforation: strip.perforation,
6245
+ crease: strip.crease,
6246
+ stiffness: strip.stiffness,
6247
+ drag: strip.drag,
6248
+ gravity: strip.gravity,
6249
+ floor: strip.floor,
6250
+ inertia: strip.inertia
6251
+ });
6252
+ }, [geometry, isStrip]);
6253
+ const lastSimRef = (0, import_react7.useRef)(null);
4444
6254
  const sim = (0, import_react7.useMemo)(() => {
4445
6255
  if (!isCloth) return null;
4446
6256
  const cloth = configRef.current.physics;
4447
6257
  const cols = geometry.parameters.widthSegments + 1;
4448
6258
  const rows = geometry.parameters.heightSegments + 1;
4449
- return new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
6259
+ const next = new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
4450
6260
  stiffness: cloth.stiffness,
4451
6261
  gravity: cloth.gravity,
4452
6262
  wind: cloth.wind,
4453
6263
  floor: cloth.floor
4454
6264
  });
6265
+ next.adopt(lastSimRef.current);
6266
+ return next;
4455
6267
  }, [geometry, isCloth, isCloth ? config.physics.pins : ""]);
6268
+ (0, import_react7.useEffect)(() => {
6269
+ lastSimRef.current = sim;
6270
+ }, [sim]);
4456
6271
  const stock = getStock(config.stock);
4457
6272
  const texture = useContentTexture(config.content, config.sheet, stock);
4458
6273
  const backTexture = useContentTexture(config.content.back, config.sheet, stock);
@@ -4547,6 +6362,10 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4547
6362
  placeProgrammatic: () => machineRef.current?.placeProgrammatic() ?? false,
4548
6363
  returnProgrammatic: () => machineRef.current?.returnProgrammatic() ?? false
4549
6364
  }));
6365
+ const shadedCreases = (0, import_react7.useMemo)(
6366
+ () => resolveCreases(config.surface, config.memory.creases, config.sheet),
6367
+ [config.surface, config.memory.creases, config.sheet]
6368
+ );
4550
6369
  const idlePose = (0, import_react7.useRef)({ position: [0, 0, 0], rotation: [0, 0, 0] });
4551
6370
  (0, import_fiber.useFrame)(({ clock }, delta) => {
4552
6371
  const cfg = liveConfig();
@@ -4559,10 +6378,10 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4559
6378
  idle?.transform?.(now, pose);
4560
6379
  if (hasBehaviorTransform) {
4561
6380
  const o = effectiveOptions(now);
4562
- if (o) behavior.transform(o, now, pose);
6381
+ if (o) behavior.transform(o, now, pose, cfg.sheet);
4563
6382
  }
4564
6383
  const base = props.position ?? [0, 0, 0];
4565
- const baseRot = props.rotation ?? [0, 0, 0];
6384
+ const baseRot = baseRotation;
4566
6385
  groupRef.current.position.set(
4567
6386
  base[0] + pose.position[0],
4568
6387
  base[1] + pose.position[1],
@@ -4574,6 +6393,56 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4574
6393
  baseRot[2] + pose.rotation[2]
4575
6394
  );
4576
6395
  }
6396
+ if (isStrip && stripSim) {
6397
+ const strip = cfg.physics;
6398
+ stripSim.setParams({
6399
+ scroll: strip.scroll,
6400
+ tightness: strip.tightness,
6401
+ core: strip.core,
6402
+ tail: strip.tail,
6403
+ crease: strip.crease,
6404
+ stiffness: strip.stiffness,
6405
+ drag: strip.drag,
6406
+ gravity: strip.gravity,
6407
+ floor: strip.floor,
6408
+ inertia: strip.inertia
6409
+ });
6410
+ stripSim.step(delta);
6411
+ if (!stripSim.asleep) {
6412
+ const position = geometry.attributes.position;
6413
+ stripSim.writeInto(position.array);
6414
+ position.needsUpdate = true;
6415
+ computeSheetNormals(geometry);
6416
+ geometry.computeBoundingSphere();
6417
+ }
6418
+ return;
6419
+ }
6420
+ const applyShape = (base, force) => {
6421
+ const animated = !reduced && animatedStack;
6422
+ const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
6423
+ const machineAnimating = Boolean(machineRef.current?.transitioning);
6424
+ if (!force && !dirtyRef.current && !hasLoop && !animated && !machineAnimating) return false;
6425
+ const raw = buildStack(cfg, overridesRef.current, behavior, now);
6426
+ const setAmount = cfg.memory.set ?? stock.takesSet;
6427
+ if (raw && creases.observe(raw, setAmount)) props.onCrease?.(creases.creases);
6428
+ const stack = withMemory(raw, cfg, creases.creases);
6429
+ if (!stack) return false;
6430
+ dirtyRef.current = false;
6431
+ const ctx = { t: now, sheet: cfg.sheet };
6432
+ applyDeformerStack(geometry, base, stack, ctx);
6433
+ if (props.interactive && behavior?.handles) {
6434
+ const o = effectiveOptions(now);
6435
+ behavior.handles.forEach((h, i) => {
6436
+ const mesh = handleRefs.current[i];
6437
+ if (!mesh || !o) return;
6438
+ const [u, v] = h.anchor(o, cfg.sheet);
6439
+ anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
6440
+ displacePoint(anchorScratch, u, v, stack, ctx);
6441
+ mesh.position.copy(anchorScratch);
6442
+ });
6443
+ }
6444
+ return true;
6445
+ };
4577
6446
  if (isCloth && sim) {
4578
6447
  const cloth = cfg.physics;
4579
6448
  sim.setParams({
@@ -4583,7 +6452,8 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4583
6452
  floor: cloth.floor
4584
6453
  });
4585
6454
  sim.step(delta);
4586
- if (!sim.asleep) {
6455
+ const moved = !sim.asleep;
6456
+ if (!applyShape(sim.positions, moved) && moved) {
4587
6457
  const position = geometry.attributes.position;
4588
6458
  position.array.set(sim.positions);
4589
6459
  position.needsUpdate = true;
@@ -4591,26 +6461,7 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4591
6461
  }
4592
6462
  return;
4593
6463
  }
4594
- const animated = !reduced && animatedStack;
4595
- const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
4596
- const machineAnimating = Boolean(machineRef.current?.transitioning);
4597
- if (!dirtyRef.current && !hasLoop && !animated && !machineAnimating) return;
4598
- const stack = buildStack(cfg, overridesRef.current, behavior, now);
4599
- if (!stack) return;
4600
- dirtyRef.current = false;
4601
- const ctx = { t: now, sheet: cfg.sheet };
4602
- applyDeformerStack(geometry, basePositions, stack, ctx);
4603
- if (props.interactive && behavior?.handles) {
4604
- const o = effectiveOptions(now);
4605
- behavior.handles.forEach((h, i) => {
4606
- const mesh = handleRefs.current[i];
4607
- if (!mesh || !o) return;
4608
- const [u, v] = h.anchor(o, cfg.sheet);
4609
- anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
4610
- displacePoint(anchorScratch, u, v, stack, ctx);
4611
- mesh.position.copy(anchorScratch);
4612
- });
4613
- }
6464
+ applyShape(basePositions, false);
4614
6465
  });
4615
6466
  const localDragPoint = (e) => {
4616
6467
  const group = groupRef.current;
@@ -4634,13 +6485,22 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4634
6485
  if (typeof p === "number") props.onProgress?.(p);
4635
6486
  };
4636
6487
  const grabAnchor = (0, import_react7.useRef)(new THREE7.Vector3());
6488
+ const grabOffset = (0, import_react7.useRef)(new THREE7.Vector3());
4637
6489
  const clothDown = (e) => {
4638
6490
  if (!isCloth || !sim || !props.interactive || !groupRef.current) return;
4639
6491
  e.stopPropagation();
4640
6492
  if (controls) controls.enabled = false;
4641
6493
  grabAnchor.current.copy(e.point);
4642
6494
  const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
4643
- sim.grabNearest(local.x, local.y, local.z);
6495
+ const drawn = geometry.attributes.position.array;
6496
+ const index = nearestVertex(drawn, sim.count, local.x, local.y, local.z);
6497
+ sim.grab(index);
6498
+ const i3 = index * 3;
6499
+ grabOffset.current.set(
6500
+ drawn[i3] - sim.positions[i3],
6501
+ drawn[i3 + 1] - sim.positions[i3 + 1],
6502
+ drawn[i3 + 2] - sim.positions[i3 + 2]
6503
+ );
4644
6504
  draggingRef.current = "cloth";
4645
6505
  e.target.setPointerCapture(e.pointerId);
4646
6506
  };
@@ -4651,7 +6511,8 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4651
6511
  const hit = e.ray.intersectPlane(dragPlane, dragPoint);
4652
6512
  if (!hit) return;
4653
6513
  groupRef.current.worldToLocal(hit);
4654
- sim.moveGrab(hit.x, hit.y, hit.z);
6514
+ const offset = grabOffset.current;
6515
+ sim.moveGrab(hit.x - offset.x, hit.y - offset.y, hit.z - offset.z);
4655
6516
  };
4656
6517
  const clothUp = (e) => {
4657
6518
  if (draggingRef.current !== "cloth" || !sim) return;
@@ -4660,8 +6521,34 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4660
6521
  sim.release();
4661
6522
  e.target.releasePointerCapture(e.pointerId);
4662
6523
  };
6524
+ const stripDown = (e) => {
6525
+ if (!isStrip || !stripSim || !props.interactive || !groupRef.current) return;
6526
+ e.stopPropagation();
6527
+ const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
6528
+ if (stripSim.grabNearest(local.y, local.z) < 0) return;
6529
+ if (controls) controls.enabled = false;
6530
+ grabAnchor.current.copy(e.point);
6531
+ draggingRef.current = "strip";
6532
+ e.target.setPointerCapture(e.pointerId);
6533
+ };
6534
+ const stripMove = (e) => {
6535
+ if (draggingRef.current !== "strip" || !stripSim || !groupRef.current) return;
6536
+ camera.getWorldDirection(planeNormal);
6537
+ dragPlane.setFromNormalAndCoplanarPoint(planeNormal, grabAnchor.current);
6538
+ const hit = e.ray.intersectPlane(dragPlane, dragPoint);
6539
+ if (!hit) return;
6540
+ groupRef.current.worldToLocal(hit);
6541
+ stripSim.moveGrab(hit.y, hit.z);
6542
+ };
6543
+ const stripUp = (e) => {
6544
+ if (draggingRef.current !== "strip" || !stripSim) return;
6545
+ draggingRef.current = null;
6546
+ if (controls) controls.enabled = true;
6547
+ stripSim.release();
6548
+ e.target.releasePointerCapture(e.pointerId);
6549
+ };
4663
6550
  const sendState = (event) => machineRef.current?.send(event);
4664
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("group", { ref: groupRef, position: props.position, rotation: props.rotation, children: [
6551
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("group", { ref: groupRef, position: props.position, rotation: baseRotation, children: [
4665
6552
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
4666
6553
  "mesh",
4667
6554
  {
@@ -4672,13 +6559,15 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4672
6559
  frustumCulled: false,
4673
6560
  onPointerOver: statesLive ? () => sendState("enter") : void 0,
4674
6561
  onPointerOut: statesLive ? () => sendState("leave") : void 0,
4675
- onPointerDown: isCloth || statesLive ? (e) => {
6562
+ onPointerDown: isCloth || isStrip || statesLive ? (e) => {
4676
6563
  if (isCloth) clothDown(e);
6564
+ if (isStrip) stripDown(e);
4677
6565
  if (statesLive) sendState("down");
4678
6566
  } : void 0,
4679
- onPointerMove: isCloth ? clothMove : void 0,
4680
- onPointerUp: isCloth || statesLive ? (e) => {
6567
+ onPointerMove: isCloth ? clothMove : isStrip ? stripMove : void 0,
6568
+ onPointerUp: isCloth || isStrip || statesLive ? (e) => {
4681
6569
  if (isCloth) clothUp(e);
6570
+ if (isStrip) stripUp(e);
4682
6571
  if (statesLive) sendState("up");
4683
6572
  } : void 0,
4684
6573
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
@@ -4690,12 +6579,17 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4690
6579
  surface: config.surface,
4691
6580
  thickness: config.sheet.thickness,
4692
6581
  sheet: config.sheet,
4693
- lighting: config.scene.lighting
6582
+ lighting: config.scene.lighting,
6583
+ creases: shadedCreases
4694
6584
  }
4695
6585
  )
4696
6586
  }
4697
6587
  ),
4698
- props.interactive && !isCloth && behavior?.handles?.map((h, i) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
6588
+ props.interactive && // Any simulation, not cloth alone: a sim owns the vertices, so there
6589
+ // is no deformer stack for a handle to drive. Harmless as `!isCloth`
6590
+ // only because the schema makes a sim and a behavior exclusive — the
6591
+ // intent is what is written here.
6592
+ !simKind && behavior?.handles?.map((h, i) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
4699
6593
  "mesh",
4700
6594
  {
4701
6595
  userData: { paperlabChrome: true },
@@ -4726,8 +6620,32 @@ var PaperMesh = (0, import_react7.forwardRef)(function PaperMesh2(props, ref) {
4726
6620
  ))
4727
6621
  ] });
4728
6622
  });
6623
+ function withMemory(stack, config, creases = config.memory.creases) {
6624
+ if (isStripConfig(config.physics)) return null;
6625
+ const out = applyMemory(stack ?? [], creases);
6626
+ return out.length > 0 ? out : null;
6627
+ }
6628
+ function nearestVertex(array, count, x, y, z25) {
6629
+ let best = -1;
6630
+ let bestDist = Infinity;
6631
+ for (let i = 0; i < count; i++) {
6632
+ const i3 = i * 3;
6633
+ const dx = array[i3] - x;
6634
+ const dy = array[i3 + 1] - y;
6635
+ const dz = array[i3 + 2] - z25;
6636
+ const d = dx * dx + dy * dy + dz * dz;
6637
+ if (d < bestDist) {
6638
+ bestDist = d;
6639
+ best = i;
6640
+ }
6641
+ }
6642
+ return best;
6643
+ }
6644
+ function isStripConfig(physics) {
6645
+ return typeof physics === "object" && physics.type === "strip";
6646
+ }
4729
6647
  function buildStack(config, overrides, behavior, t = 0) {
4730
- if (typeof config.physics === "object") return null;
6648
+ if (isStripConfig(config.physics)) return null;
4731
6649
  const idle = typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4732
6650
  const idleStack = idle?.stack?.() ?? [];
4733
6651
  let shapeStack = [];
@@ -5669,7 +7587,7 @@ function getWalkPath(options) {
5669
7587
 
5670
7588
  // src/field/layouts/index.ts
5671
7589
  var DEFAULT_SHEET = { width: 1, height: 1.4 };
5672
- var TAU3 = Math.PI * 2;
7590
+ var TAU4 = Math.PI * 2;
5673
7591
  var DEG7 = Math.PI / 180;
5674
7592
  function jitter2(seed, i) {
5675
7593
  let h = Math.imul(seed * 1e3 + i + 1 ^ 2654435769, 2654435761);
@@ -5689,7 +7607,7 @@ var ring = {
5689
7607
  defaults: ringSchema.parse({}),
5690
7608
  optionsSchema: ringSchema,
5691
7609
  pose(i, n, o, phase) {
5692
- const theta = (i / n + phase) * TAU3;
7610
+ const theta = (i / n + phase) * TAU4;
5693
7611
  return {
5694
7612
  position: [Math.sin(theta) * o.radius, 0, Math.cos(theta) * o.radius],
5695
7613
  // Face radially OUTWARD so the papers nearest the camera show their
@@ -5850,7 +7768,7 @@ var sweepSchema = import_zod24.z.object({
5850
7768
  from: import_zod24.z.number().min(0).max(1).default(0),
5851
7769
  to: import_zod24.z.number().min(0).max(1).default(1)
5852
7770
  });
5853
- var sweep = {
7771
+ var sweep2 = {
5854
7772
  id: "sweep",
5855
7773
  label: "Sweep",
5856
7774
  defaults: sweepSchema.parse({}),
@@ -5965,7 +7883,7 @@ var rack = {
5965
7883
  };
5966
7884
  var colonnadeSchema = import_zod24.z.object({
5967
7885
  /** The walk the colonnade is built along — see `stage/path`. */
5968
- path: walkPathSchema.default({}),
7886
+ path: walkPathSchema.prefault({}),
5969
7887
  /** Half-width of the clear aisle: how far each banner stands off the walk line. */
5970
7888
  aisle: import_zod24.z.number().min(0.2).max(20).default(2.4),
5971
7889
  /** How much that gap opens and closes along the walk. Nothing hung by hand is a corridor. */
@@ -6059,7 +7977,7 @@ registerLayout(spread);
6059
7977
  registerLayout(pile);
6060
7978
  registerLayout(wall);
6061
7979
  registerLayout(spill);
6062
- registerLayout(sweep);
7980
+ registerLayout(sweep2);
6063
7981
  registerLayout(book);
6064
7982
  registerLayout(accordion);
6065
7983
  registerLayout(rack);
@@ -6262,7 +8180,8 @@ function FieldGroup({
6262
8180
  behaviorTransform.transform(
6263
8181
  { ...config.behavior, [behaviorTransform.progressParam]: progressRef.current },
6264
8182
  t,
6265
- pose2
8183
+ pose2,
8184
+ shared.sheet
6266
8185
  );
6267
8186
  scratchObj.position.x += pose2.position[0];
6268
8187
  scratchObj.position.y += pose2.position[1];
@@ -7167,6 +9086,8 @@ function diffConfig(config) {
7167
9086
  }
7168
9087
  if (config.deformers) out.deformers = config.deformers;
7169
9088
  if (Object.keys(config.surface).length > 0) out.surface = config.surface;
9089
+ const memory = diffAgainst(config.memory, memorySchema.parse({}));
9090
+ if (Object.keys(memory).length > 0) out.memory = memory;
7170
9091
  if (typeof config.physics === "object") {
7171
9092
  const defaults = clothConfigSchema.parse({ type: "cloth" });
7172
9093
  out.physics = { type: "cloth", ...diffAgainst(config.physics, defaults) };
@@ -7241,8 +9162,9 @@ function describeConfig(config) {
7241
9162
  if (config.content.type === "receipt") contentPhrase = `a store receipt for "${config.content.store}"`;
7242
9163
  const parts = [`${contentPhrase} on ${stock.label.toLowerCase()} paper stock (${size})`];
7243
9164
  if (typeof config.physics === "object") {
9165
+ const sim = config.physics;
7244
9166
  parts.push(
7245
- config.physics.pins === "none" ? "falling and settling as cloth" : `pinned (${config.physics.pins}) and moving like cloth in wind`
9167
+ 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`
7246
9168
  );
7247
9169
  } else if (config.behavior) {
7248
9170
  const phrase = BEHAVIOR_PHRASES[config.behavior.type];
@@ -7323,8 +9245,8 @@ function nonDefault(value, defaults) {
7323
9245
  const out = {};
7324
9246
  for (const [k, v] of Object.entries(value ?? {})) {
7325
9247
  if (v === void 0) continue;
7326
- const same = v === defaults[k] || JSON.stringify(v) === JSON.stringify(defaults[k]);
7327
- if (!same) out[k] = v;
9248
+ const same2 = v === defaults[k] || JSON.stringify(v) === JSON.stringify(defaults[k]);
9249
+ if (!same2) out[k] = v;
7328
9250
  }
7329
9251
  return out;
7330
9252
  }
@@ -7490,13 +9412,13 @@ var PARITY_EPSILON = 5e-4;
7490
9412
  var parityCases = [
7491
9413
  {
7492
9414
  name: "roll: defaults",
7493
- stack: [{ type: "roll", options: { angle: 90, boundary: 0, radius: 0.12, spiral: 0.015 } }],
9415
+ stack: [{ type: "roll", options: { angle: 90, boundary: 0, radius: 0.12, thickness: 0.04 } }],
7494
9416
  sheet: { width: 1, height: 1.4 },
7495
9417
  t: 0
7496
9418
  },
7497
9419
  {
7498
9420
  name: "roll: tight receipt roll, rolling down",
7499
- stack: [{ type: "roll", options: { angle: 270, boundary: -0.4, radius: 0.07, spiral: 0.02 } }],
9421
+ stack: [{ type: "roll", options: { angle: 270, boundary: -0.4, radius: 0.07, thickness: 6e-3 } }],
7500
9422
  sheet: { width: 1, height: 2.6 },
7501
9423
  t: 0
7502
9424
  },
@@ -7639,7 +9561,7 @@ var parityCases = [
7639
9561
  name: "stacked: bend \u2218 roll \u2218 wave",
7640
9562
  stack: [
7641
9563
  { type: "bend", options: { curvature: 0.6, angle: 0 } },
7642
- { type: "roll", options: { angle: 90, boundary: 0.1, radius: 0.15, spiral: 0 } },
9564
+ { type: "roll", options: { angle: 90, boundary: 0.1, radius: 0.15, thickness: 0 } },
7643
9565
  {
7644
9566
  type: "wave",
7645
9567
  options: { amplitude: 0.02, wavelength: 0.6, speed: 0.7, angle: 45, pinnedEdge: "none" }
@@ -7786,9 +9708,12 @@ function runParityHarness(canvas) {
7786
9708
  // Annotate the CommonJS export names for ESM import in node:
7787
9709
  0 && (module.exports = {
7788
9710
  AUTO_CEILING,
9711
+ CreaseTracker,
7789
9712
  DropZone,
7790
9713
  FLAT_SEGMENTS,
7791
9714
  LightRig,
9715
+ MAX_CREASES,
9716
+ MAX_SET,
7792
9717
  PARITY_EPSILON,
7793
9718
  Paper,
7794
9719
  PaperBackdrop,
@@ -7797,6 +9722,7 @@ function runParityHarness(canvas) {
7797
9722
  PaperLighting,
7798
9723
  PaperMesh,
7799
9724
  SAG_TOL,
9725
+ applyMemory,
7800
9726
  backdropSchema,
7801
9727
  behaviorConfigSchema,
7802
9728
  buildAgentPayload,
@@ -7807,6 +9733,7 @@ function runParityHarness(canvas) {
7807
9733
  contentNames,
7808
9734
  contentSchemaFor,
7809
9735
  coreStateNames,
9736
+ creaseSchema,
7810
9737
  describeConfig,
7811
9738
  describeFieldConfig,
7812
9739
  diffConfig,
@@ -7825,6 +9752,8 @@ function runParityHarness(canvas) {
7825
9752
  listDeformers,
7826
9753
  listLayouts,
7827
9754
  listPresets,
9755
+ maxStripLength,
9756
+ memorySchema,
7828
9757
  mergeConfig,
7829
9758
  mergeWithDeletes,
7830
9759
  paperConfigSchema,
@@ -7851,6 +9780,7 @@ function runParityHarness(canvas) {
7851
9780
  stateDefSchema,
7852
9781
  stockNames,
7853
9782
  stocks,
9783
+ stripConfigSchema,
7854
9784
  supportsWebGL,
7855
9785
  uniquePresetName,
7856
9786
  unregisterPreset,