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/stage.cjs CHANGED
@@ -72,7 +72,9 @@ var stocks = {
72
72
  banding: 0,
73
73
  defaultSurface: { grain: 0.12 },
74
74
  showThrough: 0,
75
- adhesive: false
75
+ adhesive: false,
76
+ // Office bond creases cleanly and holds it — the reference paper.
77
+ takesSet: 0.6
76
78
  },
77
79
  thermal: {
78
80
  id: "thermal",
@@ -85,7 +87,9 @@ var stocks = {
85
87
  banding: 0.35,
86
88
  defaultSurface: { aging: 0.1 },
87
89
  showThrough: 0.06,
88
- adhesive: false
90
+ adhesive: false,
91
+ // Thin and already curled off a roll; a fold in it stays folded.
92
+ takesSet: 0.65
89
93
  },
90
94
  kraft: {
91
95
  id: "kraft",
@@ -98,7 +102,9 @@ var stocks = {
98
102
  banding: 0,
99
103
  defaultSurface: { grain: 0.5 },
100
104
  showThrough: 0,
101
- adhesive: false
105
+ adhesive: false,
106
+ // Thick and fibrous. The crease is a break, and it never comes back.
107
+ takesSet: 0.85
102
108
  },
103
109
  newsprint: {
104
110
  id: "newsprint",
@@ -111,7 +117,9 @@ var stocks = {
111
117
  banding: 0,
112
118
  defaultSurface: { grain: 0.7, aging: 0.15 },
113
119
  showThrough: 0.06,
114
- adhesive: false
120
+ adhesive: false,
121
+ // Soft, short-fibred, and barely sprung — it crumples rather than resists.
122
+ takesSet: 0.8
115
123
  },
116
124
  vellum: {
117
125
  id: "vellum",
@@ -124,7 +132,9 @@ var stocks = {
124
132
  banding: 0,
125
133
  defaultSurface: {},
126
134
  showThrough: 0.55,
127
- adhesive: false
135
+ adhesive: false,
136
+ // Translucent and plasticky: it fights the fold and mostly wins.
137
+ takesSet: 0.25
128
138
  },
129
139
  "photo-gloss": {
130
140
  id: "photo-gloss",
@@ -137,7 +147,9 @@ var stocks = {
137
147
  banding: 0,
138
148
  defaultSurface: {},
139
149
  showThrough: 0,
140
- adhesive: false
150
+ adhesive: false,
151
+ // The coating resists, then cracks white — little angle kept, lots of mark.
152
+ takesSet: 0.3
141
153
  },
142
154
  // Photo-gloss-like face, glossy near-white glue underside. The default
143
155
  // carrier for perforated stamp sheets.
@@ -152,7 +164,9 @@ var stocks = {
152
164
  banding: 0,
153
165
  defaultSurface: {},
154
166
  showThrough: 0,
155
- adhesive: true
167
+ adhesive: true,
168
+ // A face sheet on a release liner; the liner does most of the remembering.
169
+ takesSet: 0.5
156
170
  }
157
171
  };
158
172
  function getStock(name) {
@@ -182,8 +196,8 @@ function barcodeBars(seed) {
182
196
  var money = (v) => v.toFixed(2);
183
197
  function paintReceipt(ctx, w, h, content, stock) {
184
198
  const ink = stock.inkColor;
185
- const pad = w * 0.09;
186
- const colWidth = w - pad * 2;
199
+ const pad2 = w * 0.09;
200
+ const colWidth = w - pad2 * 2;
187
201
  const base = Math.round(w / 15);
188
202
  const mono = (size, weight = 400) => `${weight} ${size}px ui-monospace, Menlo, Consolas, monospace`;
189
203
  let y = h * 0.045;
@@ -196,9 +210,9 @@ function paintReceipt(ctx, w, h, content, stock) {
196
210
  const row = (left, right, size = base) => {
197
211
  ctx.font = mono(size);
198
212
  ctx.textAlign = "left";
199
- ctx.fillText(left, pad, y);
213
+ ctx.fillText(left, pad2, y);
200
214
  ctx.textAlign = "right";
201
- ctx.fillText(right, w - pad, y);
215
+ ctx.fillText(right, w - pad2, y);
202
216
  };
203
217
  const divider = () => {
204
218
  ctx.font = mono(base);
@@ -279,7 +293,7 @@ var import_fiber7 = require("@react-three/fiber");
279
293
  var import_react17 = require("react");
280
294
 
281
295
  // src/config/schema.ts
282
- var import_zod14 = require("zod");
296
+ var import_zod15 = require("zod");
283
297
 
284
298
  // src/config/merge.ts
285
299
  function mergeConfig(base, override) {
@@ -499,15 +513,169 @@ var peel = {
499
513
  };
500
514
 
501
515
  // src/behaviors/unroll.ts
516
+ var import_zod4 = require("zod");
517
+
518
+ // src/deformers/roll.ts
502
519
  var import_zod3 = require("zod");
503
- var unrollOptionsSchema = import_zod3.z.object({
520
+ var rollOptionsSchema = import_zod3.z.object({
521
+ /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
522
+ angle: import_zod3.z.number().min(-360).max(360).default(90),
523
+ /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
524
+ boundary: import_zod3.z.number().min(-20).max(20).default(0),
525
+ /** Radius of the OUTERMOST wrap — the one the flat sheet leaves the roll on. */
526
+ radius: import_zod3.z.number().min(0.01).max(2).default(0.12),
527
+ /** Gap between consecutive wraps, in world units. 0 = a bare cylinder. */
528
+ thickness: import_zod3.z.number().min(0).max(0.2).default(0.015)
529
+ });
530
+ var DEG2 = Math.PI / 180;
531
+ var TAU = Math.PI * 2;
532
+ var MIN_RADIUS_FRACTION = 0.08;
533
+ function windAngle(s, r0, k) {
534
+ if (k <= 0) return s / r0;
535
+ const rMin = r0 * MIN_RADIUS_FRACTION;
536
+ const thetaFloor = (r0 - rMin) / k;
537
+ const sFloor = r0 * thetaFloor - k * thetaFloor * thetaFloor / 2;
538
+ if (s >= sFloor) return thetaFloor + (s - sFloor) / rMin;
539
+ return 2 * s / (r0 + Math.sqrt(r0 * r0 - 2 * k * s));
540
+ }
541
+ function windRadius(theta, r0, k) {
542
+ return Math.max(r0 - k * theta, r0 * MIN_RADIUS_FRACTION);
543
+ }
544
+ function rollRadius(length, core, thickness) {
545
+ return Math.sqrt(core * core + Math.max(0, length) * thickness / Math.PI);
546
+ }
547
+ var roll = {
548
+ id: "roll",
549
+ label: "Roll",
550
+ defaults: rollOptionsSchema.parse({}),
551
+ optionsSchema: rollOptionsSchema,
552
+ geometry: {
553
+ minSegments: 48,
554
+ // The INNERMOST wrap is the tightest curvature on the sheet, so it sets
555
+ // the density — the outer one used to, back when the spiral grew outward.
556
+ // Floored at a fraction of the outer radius because segment count scales
557
+ // as 1/√r: a roll wound to a hair's breadth would otherwise ask for a
558
+ // grid nobody can afford, to resolve a few square millimetres at the core.
559
+ autoSegments: (o, sheet2) => {
560
+ const span = spanAlong(sheet2, o.angle);
561
+ const wound = Math.max(0, span / 2 - o.boundary);
562
+ const k = o.thickness / TAU;
563
+ return segmentsForArc(span, windRadius(windAngle(wound, o.radius, k), o.radius, k));
564
+ },
565
+ axis: (o) => o.angle
566
+ },
567
+ displace(out, _uv, o) {
568
+ const dirX = Math.cos(o.angle * DEG2);
569
+ const dirY = Math.sin(o.angle * DEG2);
570
+ const d = out.x * dirX + out.y * dirY;
571
+ const s = d - o.boundary;
572
+ if (s <= 0) return;
573
+ const k = o.thickness / TAU;
574
+ const theta = windAngle(s, o.radius, k);
575
+ const r = windRadius(theta, o.radius, k) - out.z;
576
+ const newD = o.boundary + r * Math.sin(theta);
577
+ const newZ = o.radius - r * Math.cos(theta);
578
+ out.x += dirX * (newD - d);
579
+ out.y += dirY * (newD - d);
580
+ out.z = newZ;
581
+ },
582
+ glsl: {
583
+ chunk: (
584
+ /* glsl */
585
+ `
586
+ void FN(inout vec3 p, vec2 uv, float t) {
587
+ vec2 dir = vec2(cos(U_angle), sin(U_angle));
588
+ float d = dot(p.xy, dir);
589
+ float s = d - U_boundary;
590
+ if (s <= 0.0) return;
591
+ float k = U_thickness / 6.2831853071795864;
592
+ float rMin = U_radius * 0.08;
593
+ float thetaFloor = (U_radius - rMin) / max(k, 1e-9);
594
+ float sFloor = U_radius * thetaFloor - 0.5 * k * thetaFloor * thetaFloor;
595
+ float theta = k <= 0.0
596
+ ? s / U_radius
597
+ : (s >= sFloor
598
+ ? thetaFloor + (s - sFloor) / rMin
599
+ : (2.0 * s) / (U_radius + sqrt(U_radius * U_radius - 2.0 * k * s)));
600
+ float r = max(U_radius - k * theta, rMin) - p.z;
601
+ float newD = U_boundary + r * sin(theta);
602
+ float newZ = U_radius - r * cos(theta);
603
+ p.xy += dir * (newD - d);
604
+ p.z = newZ;
605
+ }
606
+ `
607
+ ),
608
+ uniforms: (o) => ({
609
+ angle: o.angle * DEG2,
610
+ boundary: o.boundary,
611
+ radius: o.radius,
612
+ thickness: o.thickness
613
+ })
614
+ }
615
+ };
616
+
617
+ // src/behaviors/unroll.ts
618
+ var unrollOptionsSchema = import_zod4.z.object({
504
619
  /** 0 = fully rolled cylinder, 1 = flat sheet. */
505
- progress: import_zod3.z.number().min(0).max(1).default(0.5),
506
- /** How tightly the paper is wound. */
507
- tightness: import_zod3.z.number().min(0).max(1).default(0.5),
620
+ progress: import_zod4.z.number().min(0).max(1).default(0.5),
621
+ /** How tightly the paper is wound — thin layers and many turns, or few and fat. */
622
+ tightness: import_zod4.z.number().min(0).max(1).default(0.5),
508
623
  /** Idle rocking of the rolled end. */
509
- sway: import_zod3.z.number().min(0).max(1).default(0.25)
624
+ sway: import_zod4.z.number().min(0).max(1).default(0.25),
625
+ /** Which end of the sheet holds the roll. `top` hangs the paper below it. */
626
+ from: import_zod4.z.enum(["bottom", "top"]).default("bottom"),
627
+ /** Radius of the tube the paper is wound onto — the roll never shrinks past it. */
628
+ core: import_zod4.z.number().min(5e-3).max(0.5).default(0.03),
629
+ /**
630
+ * Paper already hanging at `progress` 0, in world units.
631
+ *
632
+ * A roll on a holder is never a bare cylinder: there is always a leaf out,
633
+ * because that is what you take hold of. Starting from nothing showing
634
+ * reads as a fresh roll still in its wrapper.
635
+ */
636
+ tail: import_zod4.z.number().min(0).max(20).default(0),
637
+ /**
638
+ * How far below the roll the paper lands, in world units. Omit and it
639
+ * hangs forever.
640
+ *
641
+ * Paper that reaches the ground does not stop and does not carry on
642
+ * through it: it creases and runs out flat. Everything past this distance
643
+ * turns a right angle and lies down.
644
+ */
645
+ floor: import_zod4.z.number().min(0.1).max(50).optional(),
646
+ /**
647
+ * Hold the roll still in space and let the paper hang off it, instead of
648
+ * letting the roll ride along with the shrinking wound region.
649
+ */
650
+ fixed: import_zod4.z.boolean().default(false)
510
651
  });
652
+ var LOOSE = 0.1;
653
+ var TIGHT = 0.02;
654
+ function layerThickness(tightness) {
655
+ return LOOSE - tightness * (LOOSE - TIGHT);
656
+ }
657
+ function sweep(o, sheet2) {
658
+ const maxRadius = rollRadius(sheet2.height, o.core, layerThickness(o.tightness));
659
+ const tail = Math.min(o.tail, sheet2.height);
660
+ return { start: -sheet2.height / 2 + tail, end: sheet2.height / 2 + maxRadius * 2 };
661
+ }
662
+ function rollBoundary(o, sheet2) {
663
+ const { start, end } = sweep(o, sheet2);
664
+ return start + o.progress * (end - start);
665
+ }
666
+ var LANDING_RADIUS = 0.035;
667
+ function landing(o, sheet2, boundary, rollRadiusNow) {
668
+ const radius = Math.min(0.5, Math.max(0.02, sheet2.height * LANDING_RADIUS));
669
+ const hingeDrop = radius / (Math.PI / 2);
670
+ const below = Math.max(o.floor, rollRadiusNow + radius);
671
+ const floorLine = o.from === "top" ? boundary - below : -boundary + below;
672
+ return {
673
+ type: "fold",
674
+ // Travel points from the roll toward the floor, so "past the crease"
675
+ // means "the length that has arrived", not the drop above it.
676
+ options: o.from === "top" ? { angle: -90, offset: -floorLine - hingeDrop, foldAngle: 90, radius } : { angle: 90, offset: floorLine - hingeDrop, foldAngle: 90, radius }
677
+ };
678
+ }
511
679
  var unroll = {
512
680
  id: "unroll",
513
681
  label: "Unroll",
@@ -518,46 +686,65 @@ var unroll = {
518
686
  duration: 3,
519
687
  loopMode: "yoyo",
520
688
  stack(o, sheet2) {
521
- const radius = 0.28 - o.tightness * 0.22;
522
- const start = -sheet2.height / 2;
523
- const end = sheet2.height / 2 + radius * 2;
524
- return [
689
+ const thickness = layerThickness(o.tightness);
690
+ const boundary = rollBoundary(o, sheet2);
691
+ const wound = Math.max(0, sheet2.height / 2 - boundary);
692
+ const radius = rollRadius(wound, o.core, thickness);
693
+ const stack = [
525
694
  {
526
695
  type: "roll",
527
696
  options: {
528
- angle: 270,
529
- boundary: start + o.progress * (end - start),
697
+ // Both directions sweep the same boundary; only which half of the
698
+ // sheet counts as "past" it changes. 270 winds the bottom (a
699
+ // receipt feeding downward), 90 winds the top (paper hanging below).
700
+ angle: o.from === "top" ? 90 : 270,
701
+ boundary,
530
702
  radius,
531
- spiral: 0.02
703
+ thickness
532
704
  }
533
705
  }
534
706
  ];
707
+ if (o.floor !== void 0) stack.push(landing(o, sheet2, boundary, radius));
708
+ return stack;
535
709
  },
536
710
  loop(o, t) {
537
711
  if (o.sway === 0) return {};
538
712
  const wobble = Math.sin(t * 1.5) * 0.01 * o.sway;
539
713
  return { progress: Math.min(1, Math.max(0, o.progress + wobble)) };
540
714
  },
715
+ transform(o, _t, pose, sheet2) {
716
+ if (!o.fixed) return;
717
+ const travel = sweep(o, sheet2).end - rollBoundary(o, sheet2);
718
+ pose.position[1] += o.from === "top" ? travel : -travel;
719
+ },
541
720
  handles: [
542
721
  {
543
722
  id: "roll-edge",
544
- anchor: (o) => [0.5, Math.max(0.02, Math.min(0.98, 1 - o.progress))],
545
- drag(local, _o, sheet2) {
546
- return { progress: Math.min(1, Math.max(0, 0.5 - local.y / sheet2.height)) };
723
+ // The grab point is the edge the paper leaves the roll on, so it tracks
724
+ // the boundary — which runs top-to-bottom for a roll at the bottom and
725
+ // bottom-to-top for one at the top.
726
+ anchor: (o) => {
727
+ const v = o.from === "top" ? o.progress : 1 - o.progress;
728
+ return [0.5, Math.max(0.02, Math.min(0.98, v))];
729
+ },
730
+ drag(local, o, sheet2) {
731
+ const along = local.y / sheet2.height;
732
+ const p = o.from === "top" ? 0.5 + along : 0.5 - along;
733
+ return { progress: Math.min(1, Math.max(0, p)) };
547
734
  }
548
735
  }
549
736
  ]
550
737
  };
551
738
 
552
739
  // src/behaviors/flip.ts
553
- var import_zod4 = require("zod");
554
- var flipOptionsSchema = import_zod4.z.object({
740
+ var import_zod5 = require("zod");
741
+ var flipOptionsSchema = import_zod5.z.object({
555
742
  /** 0 = flat, 1 = page fully turned over the spine. */
556
- progress: import_zod4.z.number().min(0).max(1).default(0.3),
743
+ progress: import_zod5.z.number().min(0).max(1).default(0.3),
557
744
  /** Which edge is the spine. */
558
- spine: import_zod4.z.enum(["left", "right"]).default("left"),
745
+ spine: import_zod5.z.enum(["left", "right"]).default("left"),
559
746
  /** Softness of the turning curl. */
560
- radius: import_zod4.z.number().min(0.1).max(0.8).default(0.3)
747
+ radius: import_zod5.z.number().min(0.1).max(0.8).default(0.3)
561
748
  });
562
749
  var flip = {
563
750
  id: "flip",
@@ -579,7 +766,7 @@ var flip = {
579
766
  angle,
580
767
  boundary: start + o.progress * (end - start),
581
768
  radius: o.radius,
582
- spiral: 0
769
+ thickness: 0
583
770
  }
584
771
  }
585
772
  ];
@@ -597,12 +784,12 @@ var flip = {
597
784
  };
598
785
 
599
786
  // src/behaviors/letter-fold.ts
600
- var import_zod5 = require("zod");
601
- var letterFoldOptionsSchema = import_zod5.z.object({
787
+ var import_zod6 = require("zod");
788
+ var letterFoldOptionsSchema = import_zod6.z.object({
602
789
  /** 0 = flat letter, 1 = fully tri-folded. */
603
- progress: import_zod5.z.number().min(0).max(1).default(0.4),
790
+ progress: import_zod6.z.number().min(0).max(1).default(0.4),
604
791
  /** Softness of the two creases. */
605
- crease: import_zod5.z.number().min(0).max(1).default(0.3)
792
+ crease: import_zod6.z.number().min(0).max(1).default(0.3)
606
793
  });
607
794
  var letterFold = {
608
795
  id: "letter-fold",
@@ -652,12 +839,12 @@ var letterFold = {
652
839
  };
653
840
 
654
841
  // src/behaviors/hang.ts
655
- var import_zod6 = require("zod");
656
- var hangOptionsSchema = import_zod6.z.object({
842
+ var import_zod7 = require("zod");
843
+ var hangOptionsSchema = import_zod7.z.object({
657
844
  /** Wind strength driving the ripple. */
658
- wind: import_zod6.z.number().min(0).max(1).default(0.4),
845
+ wind: import_zod7.z.number().min(0).max(1).default(0.4),
659
846
  /** Gravity bulge of the hanging sheet. */
660
- sag: import_zod6.z.number().min(0).max(1).default(0.3)
847
+ sag: import_zod7.z.number().min(0).max(1).default(0.3)
661
848
  });
662
849
  var hang = {
663
850
  id: "hang",
@@ -686,12 +873,12 @@ var hang = {
686
873
  };
687
874
 
688
875
  // src/behaviors/fly.ts
689
- var import_zod7 = require("zod");
690
- var flyOptionsSchema = import_zod7.z.object({
876
+ var import_zod8 = require("zod");
877
+ var flyOptionsSchema = import_zod8.z.object({
691
878
  /** Ripple energy. */
692
- flutter: import_zod7.z.number().min(0).max(1).default(0.5),
879
+ flutter: import_zod8.z.number().min(0).max(1).default(0.5),
693
880
  /** Aerodynamic arc of the sheet. */
694
- curve: import_zod7.z.number().min(0).max(1).default(0.4)
881
+ curve: import_zod8.z.number().min(0).max(1).default(0.4)
695
882
  });
696
883
  var fly = {
697
884
  id: "fly",
@@ -720,12 +907,12 @@ var fly = {
720
907
  };
721
908
 
722
909
  // src/behaviors/fall.ts
723
- var import_zod8 = require("zod");
724
- var fallOptionsSchema = import_zod8.z.object({
910
+ var import_zod9 = require("zod");
911
+ var fallOptionsSchema = import_zod9.z.object({
725
912
  /** Air resistance ripple while falling. */
726
- flutter: import_zod8.z.number().min(0).max(1).default(0.6),
913
+ flutter: import_zod9.z.number().min(0).max(1).default(0.6),
727
914
  /** A falling sheet always lifts a corner. */
728
- curl: import_zod8.z.number().min(0).max(1).default(0.3)
915
+ curl: import_zod9.z.number().min(0).max(1).default(0.3)
729
916
  });
730
917
  var fall = {
731
918
  id: "fall",
@@ -757,21 +944,21 @@ var fall = {
757
944
  };
758
945
 
759
946
  // src/behaviors/carry.ts
760
- var import_zod9 = require("zod");
761
- var carryOptionsSchema = import_zod9.z.object({
947
+ var import_zod10 = require("zod");
948
+ var carryOptionsSchema = import_zod10.z.object({
762
949
  /**
763
950
  * The grab point — where the pointer was on the paper at pick time.
764
951
  * 'auto' is resolved by the carry controller (usually the peeled corner:
765
952
  * continuity from peel → carry is the immersion moment).
766
953
  */
767
- grab: import_zod9.z.enum([...cornerNames, "auto"]).default("auto"),
954
+ grab: import_zod10.z.enum([...cornerNames, "auto"]).default("auto"),
768
955
  /** From stock feel: a stamp is stiff — it flutters, it doesn't flow. */
769
- stiffness: import_zod9.z.number().min(0).max(1).default(0.7),
770
- flutter: import_zod9.z.number().min(0).max(1).default(0.5),
956
+ stiffness: import_zod10.z.number().min(0).max(1).default(0.7),
957
+ flutter: import_zod10.z.number().min(0).max(1).default(0.5),
771
958
  /** How far the paper's yaw trails the drag direction (runtime transform). */
772
- lag: import_zod9.z.number().min(0).max(1).default(0.35),
959
+ lag: import_zod10.z.number().min(0).max(1).default(0.35),
773
960
  /** Drag-speed drive (0..1). Written live by the carry controller. */
774
- drive: import_zod9.z.number().min(0).max(1).default(0.25)
961
+ drive: import_zod10.z.number().min(0).max(1).default(0.25)
775
962
  });
776
963
  var concreteGrab = (g) => g === "auto" ? "top-left" : g;
777
964
  var DROOP_ANGLE = {
@@ -818,7 +1005,7 @@ var carry = {
818
1005
  };
819
1006
 
820
1007
  // src/behaviors/flight.ts
821
- var import_zod10 = require("zod");
1008
+ var import_zod11 = require("zod");
822
1009
 
823
1010
  // src/physics/aero.ts
824
1011
  function dampTo(state, target, smoothing, dt) {
@@ -860,17 +1047,17 @@ function carryDrive(speed) {
860
1047
  }
861
1048
 
862
1049
  // src/behaviors/flight.ts
863
- var flightOptionsSchema = import_zod10.z.object({
1050
+ var flightOptionsSchema = import_zod11.z.object({
864
1051
  /** Directional wind vector — paper travels ACROSS the scene, not just down. */
865
- 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]),
866
- gustiness: import_zod10.z.number().min(0).max(1).default(0.4),
867
- tumble: import_zod10.z.number().min(0).max(1).default(0.6),
1052
+ 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]),
1053
+ gustiness: import_zod11.z.number().min(0).max(1).default(0.4),
1054
+ tumble: import_zod11.z.number().min(0).max(1).default(0.6),
868
1055
  /** 'loop' is a seamless idle cycle; 'drift' travels along the wind. */
869
- path: import_zod10.z.enum(["drift", "loop"]).default("drift"),
1056
+ path: import_zod11.z.enum(["drift", "loop"]).default("drift"),
870
1057
  /** Drift only: exit the scene → re-enter the opposite side. */
871
- respawn: import_zod10.z.boolean().default(true),
1058
+ respawn: import_zod11.z.boolean().default(true),
872
1059
  /** Half-extent of the travel before respawn wraps it. */
873
- range: import_zod10.z.number().min(0.5).max(12).default(3.5)
1060
+ range: import_zod11.z.number().min(0.5).max(12).default(3.5)
874
1061
  });
875
1062
  var flight = {
876
1063
  id: "flight",
@@ -902,16 +1089,16 @@ var flight = {
902
1089
  };
903
1090
 
904
1091
  // src/behaviors/crumple.ts
905
- var import_zod11 = require("zod");
906
- var crumpleBehaviorOptionsSchema = import_zod11.z.object({
1092
+ var import_zod12 = require("zod");
1093
+ var crumpleBehaviorOptionsSchema = import_zod12.z.object({
907
1094
  /** 0 = flat sheet, 1 = crushed. */
908
- progress: import_zod11.z.number().min(0).max(1).default(0.55),
1095
+ progress: import_zod12.z.number().min(0).max(1).default(0.55),
909
1096
  /** Few big facets at 0, many small ones at 1. */
910
- coarseness: import_zod11.z.number().min(0).max(1).default(0.35),
1097
+ coarseness: import_zod12.z.number().min(0).max(1).default(0.35),
911
1098
  /** How far the sheet curls in on itself as it crushes. */
912
- ball: import_zod11.z.number().min(0).max(1).default(0.5),
1099
+ ball: import_zod12.z.number().min(0).max(1).default(0.5),
913
1100
  /** A different crush of the same paper. */
914
- seed: import_zod11.z.number().int().min(0).max(7).default(0)
1101
+ seed: import_zod12.z.number().int().min(0).max(7).default(0)
915
1102
  });
916
1103
  var crumpleBehavior = {
917
1104
  id: "crumple",
@@ -944,8 +1131,8 @@ var crumpleBehavior = {
944
1131
  };
945
1132
 
946
1133
  // src/behaviors/settle.ts
947
- var import_zod12 = require("zod");
948
- var settleOptionsSchema = import_zod12.z.object({
1134
+ var import_zod13 = require("zod");
1135
+ var settleOptionsSchema = import_zod13.z.object({
949
1136
  /**
950
1137
  * How long ago it landed, 0..1.
951
1138
  *
@@ -953,7 +1140,7 @@ var settleOptionsSchema = import_zod12.z.object({
953
1140
  * a sheet that has been lying there, where its own weight has flattened
954
1141
  * out everything except what its stiffness refuses to give up.
955
1142
  */
956
- relax: import_zod12.z.number().min(0).max(1).default(0.45),
1143
+ relax: import_zod13.z.number().min(0).max(1).default(0.45),
957
1144
  /**
958
1145
  * How hard the paper resists lying flat, 0..1.
959
1146
  *
@@ -961,14 +1148,14 @@ var settleOptionsSchema = import_zod12.z.object({
961
1148
  * never does. It is the whole reason a settled sheet reads as PAPER and
962
1149
  * not as a decal — at 0 the mesh is a rectangle painted on the floor.
963
1150
  */
964
- lift: import_zod12.z.number().min(0).max(1).default(0.45),
1151
+ lift: import_zod13.z.number().min(0).max(1).default(0.45),
965
1152
  /** Which corner stayed up. */
966
- corner: import_zod12.z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
1153
+ corner: import_zod13.z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("top-right"),
967
1154
  /**
968
1155
  * Slack across the middle — the low, long undulation of a sheet that is
969
1156
  * touching a floor in two places and bridging between them.
970
1157
  */
971
- slack: import_zod12.z.number().min(0).max(1).default(0.4)
1158
+ slack: import_zod13.z.number().min(0).max(1).default(0.4)
972
1159
  });
973
1160
  var settle = {
974
1161
  id: "settle",
@@ -1021,8 +1208,8 @@ var settle = {
1021
1208
  };
1022
1209
 
1023
1210
  // src/behaviors/ribbon.ts
1024
- var import_zod13 = require("zod");
1025
- var ribbonOptionsSchema = import_zod13.z.object({
1211
+ var import_zod14 = require("zod");
1212
+ var ribbonOptionsSchema = import_zod14.z.object({
1026
1213
  /**
1027
1214
  * How much of the drop is lying on the floor, as a fraction of the height.
1028
1215
  *
@@ -1031,11 +1218,11 @@ var ribbonOptionsSchema = import_zod13.z.object({
1031
1218
  * turns over reads as paper meeting a floor, which is the thing the
1032
1219
  * reference installations are actually about.
1033
1220
  */
1034
- pool: import_zod13.z.number().min(0).max(0.5).default(0.16),
1221
+ pool: import_zod14.z.number().min(0).max(0.5).default(0.16),
1035
1222
  /** How tightly it turns where it lands. Low is a soft slump, high is a curl. */
1036
- curl: import_zod13.z.number().min(0).max(1).default(0.45),
1223
+ curl: import_zod14.z.number().min(0).max(1).default(0.45),
1037
1224
  /** Folds running down the length. A printed strip is never a flat plane. */
1038
- drape: import_zod13.z.number().min(0).max(1).default(0.5)
1225
+ drape: import_zod14.z.number().min(0).max(1).default(0.5)
1039
1226
  });
1040
1227
  var ribbon = {
1041
1228
  id: "ribbon",
@@ -1143,12 +1330,12 @@ var ribbon = {
1143
1330
  };
1144
1331
 
1145
1332
  // src/config/schema.ts
1146
- var sheetSchema = import_zod14.z.object({
1333
+ var sheetSchema = import_zod15.z.object({
1147
1334
  /** World units. A letter sheet is ~1 × 1.4, a receipt ~1 × 2.6. */
1148
- width: import_zod14.z.number().positive().max(20).default(1),
1149
- height: import_zod14.z.number().positive().max(20).default(1.4),
1335
+ width: import_zod15.z.number().positive().max(20).default(1),
1336
+ height: import_zod15.z.number().positive().max(20).default(1.4),
1150
1337
  /** Visual thickness in mm-ish units; drives edge/shadow treatment, not geometry (yet). */
1151
- thickness: import_zod14.z.number().min(0).max(2).default(0.2),
1338
+ thickness: import_zod15.z.number().min(0).max(2).default(0.2),
1152
1339
  /**
1153
1340
  * `'auto'` sizes the grid from the active deformers' needs — genuinely, as
1154
1341
  * of 0.3.0. It asks each one what these options require (a gentle bend and
@@ -1164,8 +1351,8 @@ var sheetSchema = import_zod14.z.object({
1164
1351
  * still raises it, because that is a correctness floor rather than a
1165
1352
  * preference.
1166
1353
  */
1167
- segments: import_zod14.z.union([import_zod14.z.literal("auto"), import_zod14.z.number().int().min(2).max(256)]).default("auto"),
1168
- cornerRadius: import_zod14.z.number().min(0).max(0.5).default(0)
1354
+ segments: import_zod15.z.union([import_zod15.z.literal("auto"), import_zod15.z.number().int().min(2).max(256)]).default("auto"),
1355
+ cornerRadius: import_zod15.z.number().min(0).max(0.5).default(0)
1169
1356
  });
1170
1357
  var stockNames = [
1171
1358
  "printer",
@@ -1176,37 +1363,37 @@ var stockNames = [
1176
1363
  "photo-gloss",
1177
1364
  "sticker"
1178
1365
  ];
1179
- var stockSchema = import_zod14.z.enum(stockNames);
1180
- var washSchema = import_zod14.z.object({
1366
+ var stockSchema = import_zod15.z.enum(stockNames);
1367
+ var washSchema = import_zod15.z.object({
1181
1368
  /** The first pigment. */
1182
- color: import_zod14.z.string().default("#4a5b8c").describe("color"),
1369
+ color: import_zod15.z.string().default("#4a5b8c").describe("color"),
1183
1370
  /** The second. Blooms alternate, and overlaps multiply into a third. */
1184
- secondary: import_zod14.z.string().default("#b06a6a").describe("color"),
1371
+ secondary: import_zod15.z.string().default("#b06a6a").describe("color"),
1185
1372
  /** How many pools of colour. */
1186
- blooms: import_zod14.z.number().int().min(1).max(24).default(7),
1373
+ blooms: import_zod15.z.number().int().min(1).max(24).default(7),
1187
1374
  /** How far a pool runs before it dries — its size against the sheet. */
1188
- spread: import_zod14.z.number().min(0.1).max(1).default(0.7),
1375
+ spread: import_zod15.z.number().min(0.1).max(1).default(0.7),
1189
1376
  /** Softness of the wet edge. 0 is a hard cut, 1 is a pool still moving. */
1190
- bleed: import_zod14.z.number().min(0).max(1).default(0.5),
1377
+ bleed: import_zod15.z.number().min(0).max(1).default(0.5),
1191
1378
  /** How much pigment is in the water. */
1192
- intensity: import_zod14.z.number().min(0).max(1).default(0.55),
1379
+ intensity: import_zod15.z.number().min(0).max(1).default(0.55),
1193
1380
  /**
1194
1381
  * Edge darkening — the ring of pigment left where a pool dried.
1195
1382
  *
1196
1383
  * The signature of the medium, and the one thing a plain gradient cannot
1197
1384
  * fake. Without it a wash reads as an airbrush.
1198
1385
  */
1199
- edge: import_zod14.z.number().min(0).max(1).default(0.6),
1386
+ edge: import_zod15.z.number().min(0).max(1).default(0.6),
1200
1387
  /** Pigment settling into the tooth of the paper. */
1201
- granulation: import_zod14.z.number().min(0).max(1).default(0.35),
1388
+ granulation: import_zod15.z.number().min(0).max(1).default(0.35),
1202
1389
  /** Fixed so a preset paints the same wash every time. */
1203
- seed: import_zod14.z.number().int().min(0).max(99).default(0)
1390
+ seed: import_zod15.z.number().int().min(0).max(99).default(0)
1204
1391
  });
1205
- var blankContentBase = import_zod14.z.object({
1206
- type: import_zod14.z.literal("blank")
1392
+ var blankContentBase = import_zod15.z.object({
1393
+ type: import_zod15.z.literal("blank")
1207
1394
  });
1208
- var imageContentBase = import_zod14.z.object({
1209
- type: import_zod14.z.literal("image"),
1395
+ var imageContentBase = import_zod15.z.object({
1396
+ type: import_zod15.z.literal("image"),
1210
1397
  /**
1211
1398
  * Empty means "no picture yet", and renders as bare stock rather than as
1212
1399
  * a failure. That is what lets a built-in preset be an image preset
@@ -1214,30 +1401,30 @@ var imageContentBase = import_zod14.z.object({
1214
1401
  * `postage-stamp` are containers for the caller's own art, handed over via
1215
1402
  * `<PaperField images={...} />` or `content.src`.
1216
1403
  */
1217
- src: import_zod14.z.string().default(""),
1218
- fit: import_zod14.z.enum(["cover", "contain"]).default("cover"),
1404
+ src: import_zod15.z.string().default(""),
1405
+ fit: import_zod15.z.enum(["cover", "contain"]).default("cover"),
1219
1406
  /** Read by the hidden DOM mirror and the no-WebGL fallback. */
1220
- alt: import_zod14.z.string().optional()
1407
+ alt: import_zod15.z.string().optional()
1221
1408
  });
1222
- var textContentBase = import_zod14.z.object({
1223
- type: import_zod14.z.literal("text"),
1224
- text: import_zod14.z.string().default("Dear reader,"),
1225
- font: import_zod14.z.string().default('Georgia, "Times New Roman", serif'),
1409
+ var textContentBase = import_zod15.z.object({
1410
+ type: import_zod15.z.literal("text"),
1411
+ text: import_zod15.z.string().default("Dear reader,"),
1412
+ font: import_zod15.z.string().default('Georgia, "Times New Roman", serif'),
1226
1413
  /** px at texture resolution (long edge = 1024 logical px before DPR). */
1227
- size: import_zod14.z.number().min(8).max(256).default(44),
1228
- weight: import_zod14.z.number().min(100).max(900).default(400),
1229
- color: import_zod14.z.string().default("#2b2620").describe("color"),
1230
- align: import_zod14.z.enum(["left", "center", "right"]).default("left"),
1414
+ size: import_zod15.z.number().min(8).max(256).default(44),
1415
+ weight: import_zod15.z.number().min(100).max(900).default(400),
1416
+ color: import_zod15.z.string().default("#2b2620").describe("color"),
1417
+ align: import_zod15.z.enum(["left", "center", "right"]).default("left"),
1231
1418
  /** Fraction of the short edge. */
1232
- padding: import_zod14.z.number().min(0).max(0.4).default(0.09),
1233
- lineHeight: import_zod14.z.number().min(0.8).max(3).default(1.45),
1419
+ padding: import_zod15.z.number().min(0).max(0.4).default(0.09),
1420
+ lineHeight: import_zod15.z.number().min(0.8).max(3).default(1.45),
1234
1421
  /**
1235
1422
  * Letter-spacing, in em. The one control display type cannot do without:
1236
1423
  * a line set large enough to be read across a room needs its tracking
1237
1424
  * pulled IN, and a small line of uppercase small-print needs it pushed
1238
1425
  * out, and neither is achievable by changing the size.
1239
1426
  */
1240
- tracking: import_zod14.z.number().min(-0.1).max(0.6).default(0),
1427
+ tracking: import_zod15.z.number().min(-0.1).max(0.6).default(0),
1241
1428
  /**
1242
1429
  * Where the block sits down the sheet.
1243
1430
  *
@@ -1246,26 +1433,26 @@ var textContentBase = import_zod14.z.object({
1246
1433
  * poster wants — a block of type optically centred in the sheet rather
1247
1434
  * than hung from its top edge.
1248
1435
  */
1249
- valign: import_zod14.z.enum(["top", "center"]).default("top")
1436
+ valign: import_zod15.z.enum(["top", "center"]).default("top")
1250
1437
  });
1251
- var cardContentBase = import_zod14.z.object({
1252
- type: import_zod14.z.literal("card"),
1438
+ var cardContentBase = import_zod15.z.object({
1439
+ type: import_zod15.z.literal("card"),
1253
1440
  /** Small, tracked, uppercase by convention — the label at the top. */
1254
- title: import_zod14.z.string().default(""),
1441
+ title: import_zod15.z.string().default(""),
1255
1442
  /** The card's reason for existing. */
1256
- body: import_zod14.z.string().default(""),
1443
+ body: import_zod15.z.string().default(""),
1257
1444
  /** Attribution, catalogue number, date — the line in small print at the foot. */
1258
- note: import_zod14.z.string().default(""),
1445
+ note: import_zod15.z.string().default(""),
1259
1446
  /** A hairline under the title. What separates a label from a paragraph. */
1260
- rule: import_zod14.z.boolean().default(true),
1447
+ rule: import_zod15.z.boolean().default(true),
1261
1448
  /**
1262
1449
  * Ruled writing lines behind the body, as on an index card.
1263
1450
  *
1264
1451
  * Drawn UNDER the type and in the stock's own ink at low alpha, so they
1265
1452
  * read as printed on the card rather than as underlines on the words.
1266
1453
  */
1267
- ruled: import_zod14.z.boolean().default(false),
1268
- font: import_zod14.z.string().default('Georgia, "Times New Roman", serif'),
1454
+ ruled: import_zod15.z.boolean().default(false),
1455
+ font: import_zod15.z.string().default('Georgia, "Times New Roman", serif'),
1269
1456
  /**
1270
1457
  * Body size, px at texture resolution. Title and note derive from it.
1271
1458
  *
@@ -1275,28 +1462,28 @@ var cardContentBase = import_zod14.z.object({
1275
1462
  * third of the stock empty above and below it, which reads as a page that
1276
1463
  * was cropped rather than as a card that was set.
1277
1464
  */
1278
- size: import_zod14.z.number().min(8).max(256).default(58),
1279
- color: import_zod14.z.string().default("#2b2620").describe("color"),
1280
- align: import_zod14.z.enum(["left", "center"]).default("left"),
1281
- padding: import_zod14.z.number().min(0).max(0.4).default(0.1)
1465
+ size: import_zod15.z.number().min(8).max(256).default(58),
1466
+ color: import_zod15.z.string().default("#2b2620").describe("color"),
1467
+ align: import_zod15.z.enum(["left", "center"]).default("left"),
1468
+ padding: import_zod15.z.number().min(0).max(0.4).default(0.1)
1282
1469
  });
1283
- var receiptContentBase = import_zod14.z.object({
1284
- type: import_zod14.z.literal("receipt"),
1285
- store: import_zod14.z.string().default("PAPERLAB"),
1286
- address: import_zod14.z.string().default("124 PAPER ST"),
1287
- items: import_zod14.z.array(import_zod14.z.object({ name: import_zod14.z.string(), price: import_zod14.z.number() })).default([
1470
+ var receiptContentBase = import_zod15.z.object({
1471
+ type: import_zod15.z.literal("receipt"),
1472
+ store: import_zod15.z.string().default("PAPERLAB"),
1473
+ address: import_zod15.z.string().default("124 PAPER ST"),
1474
+ items: import_zod15.z.array(import_zod15.z.object({ name: import_zod15.z.string(), price: import_zod15.z.number() })).default([
1288
1475
  { name: "CURL, TRUE", price: 12 },
1289
1476
  { name: "ROLL, TIGHT", price: 8.5 },
1290
1477
  { name: "SHEET, ONE", price: 0.99 }
1291
1478
  ]),
1292
- taxRate: import_zod14.z.number().min(0).max(1).default(0.08),
1293
- barcode: import_zod14.z.boolean().default(true),
1479
+ taxRate: import_zod15.z.number().min(0).max(1).default(0.08),
1480
+ barcode: import_zod15.z.boolean().default(true),
1294
1481
  /** Fixed so presets render deterministically; omit for "now". */
1295
- timestamp: import_zod14.z.string().optional(),
1296
- footer: import_zod14.z.string().default("KEEP FOR YOUR RECORDS")
1482
+ timestamp: import_zod15.z.string().optional(),
1483
+ footer: import_zod15.z.string().default("KEEP FOR YOUR RECORDS")
1297
1484
  });
1298
1485
  var withWash = { wash: washSchema.optional() };
1299
- var backContentSchema = import_zod14.z.discriminatedUnion("type", [
1486
+ var backContentSchema = import_zod15.z.discriminatedUnion("type", [
1300
1487
  blankContentBase.extend(withWash),
1301
1488
  imageContentBase.extend(withWash),
1302
1489
  textContentBase.extend(withWash),
@@ -1309,7 +1496,7 @@ var imageContentSchema = imageContentBase.extend(withBack);
1309
1496
  var textContentSchema = textContentBase.extend(withBack);
1310
1497
  var cardContentSchema = cardContentBase.extend(withBack);
1311
1498
  var receiptContentSchema = receiptContentBase.extend(withBack);
1312
- var contentSchema = import_zod14.z.discriminatedUnion("type", [
1499
+ var contentSchema = import_zod15.z.discriminatedUnion("type", [
1313
1500
  blankContentSchema,
1314
1501
  imageContentSchema,
1315
1502
  textContentSchema,
@@ -1320,80 +1507,146 @@ var contentNames = contentSchema.options.map(
1320
1507
  (option) => option.shape.type.value
1321
1508
  );
1322
1509
  var paperEdges = ["top", "right", "bottom", "left"];
1323
- var surfaceSchema = import_zod14.z.object({
1510
+ var surfaceSchema = import_zod15.z.object({
1324
1511
  /** Paper fiber noise, 0..1. */
1325
- grain: import_zod14.z.number().min(0).max(1).optional(),
1512
+ grain: import_zod15.z.number().min(0).max(1).optional(),
1326
1513
  /** Light passing through the sheet from behind, 0..1. Stock defaults apply. */
1327
- translucency: import_zod14.z.number().min(0).max(1).optional(),
1514
+ translucency: import_zod15.z.number().min(0).max(1).optional(),
1328
1515
  /** Torn-edge alpha with a lightened fiber band. */
1329
- deckle: import_zod14.z.object({
1330
- edges: import_zod14.z.array(import_zod14.z.enum(paperEdges)).default(["bottom"]),
1331
- roughness: import_zod14.z.number().min(0).max(1).default(0.5)
1516
+ deckle: import_zod15.z.object({
1517
+ edges: import_zod15.z.array(import_zod15.z.enum(paperEdges)).default(["bottom"]),
1518
+ roughness: import_zod15.z.number().min(0).max(1).default(0.5)
1332
1519
  }).optional(),
1333
1520
  /** Visual AO/highlight companion to the fold deformer. */
1334
- creaseLines: import_zod14.z.object({
1521
+ creaseLines: import_zod15.z.object({
1335
1522
  /** Crease line direction, degrees (0 = horizontal lines). */
1336
- angle: import_zod14.z.number().min(-360).max(360).default(0),
1523
+ angle: import_zod15.z.number().min(-360).max(360).default(0),
1337
1524
  /** Positions across the sheet, 0..1 fractions. */
1338
- positions: import_zod14.z.array(import_zod14.z.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1339
- strength: import_zod14.z.number().min(0).max(1).default(0.5)
1525
+ positions: import_zod15.z.array(import_zod15.z.number().min(0).max(1)).default([1 / 3, 2 / 3]),
1526
+ strength: import_zod15.z.number().min(0).max(1).default(0.5)
1340
1527
  }).optional(),
1341
1528
  /** Yellowing + foxing spots, 0..1. */
1342
- aging: import_zod14.z.number().min(0).max(1).optional(),
1529
+ aging: import_zod15.z.number().min(0).max(1).optional(),
1343
1530
  /** Reversed front-content ghost on the backside, 0..1. Stock defaults apply. */
1344
- showThrough: import_zod14.z.number().min(0).max(1).optional(),
1531
+ showThrough: import_zod15.z.number().min(0).max(1).optional(),
1345
1532
  /**
1346
1533
  * Postage-stamp perforation: alpha-punched semicircular holes along chosen
1347
1534
  * edges. `state` flips an edge to a ripped-through profile (torn) — set
1348
1535
  * automatically when a paper detaches from a `sheet` field, manual wins.
1349
1536
  */
1350
- perforation: import_zod14.z.object({
1351
- edges: import_zod14.z.union([import_zod14.z.array(import_zod14.z.enum(paperEdges)), import_zod14.z.literal("all")]).default("all"),
1537
+ perforation: import_zod15.z.object({
1538
+ edges: import_zod15.z.union([import_zod15.z.array(import_zod15.z.enum(paperEdges)), import_zod15.z.literal("all")]).default("all"),
1352
1539
  /** World units — default tuned to stamp scale. */
1353
- holeRadius: import_zod14.z.number().min(2e-3).max(0.1).default(0.016),
1354
- spacing: import_zod14.z.number().min(0.01).max(0.5).default(0.055),
1355
- state: import_zod14.z.object({
1356
- top: import_zod14.z.enum(["intact", "torn"]).optional(),
1357
- right: import_zod14.z.enum(["intact", "torn"]).optional(),
1358
- bottom: import_zod14.z.enum(["intact", "torn"]).optional(),
1359
- left: import_zod14.z.enum(["intact", "torn"]).optional()
1540
+ holeRadius: import_zod15.z.number().min(2e-3).max(0.1).default(0.016),
1541
+ spacing: import_zod15.z.number().min(0.01).max(0.5).default(0.055),
1542
+ state: import_zod15.z.object({
1543
+ top: import_zod15.z.enum(["intact", "torn"]).optional(),
1544
+ right: import_zod15.z.enum(["intact", "torn"]).optional(),
1545
+ bottom: import_zod15.z.enum(["intact", "torn"]).optional(),
1546
+ left: import_zod15.z.enum(["intact", "torn"]).optional()
1360
1547
  }).default({})
1361
1548
  }).optional()
1362
1549
  });
1363
- var behaviorConfigSchema = import_zod14.z.discriminatedUnion("type", [
1364
- peelOptionsSchema.extend({ type: import_zod14.z.literal("peel") }),
1365
- unrollOptionsSchema.extend({ type: import_zod14.z.literal("unroll") }),
1366
- flipOptionsSchema.extend({ type: import_zod14.z.literal("flip") }),
1367
- letterFoldOptionsSchema.extend({ type: import_zod14.z.literal("letter-fold") }),
1368
- hangOptionsSchema.extend({ type: import_zod14.z.literal("hang") }),
1369
- flyOptionsSchema.extend({ type: import_zod14.z.literal("fly") }),
1370
- fallOptionsSchema.extend({ type: import_zod14.z.literal("fall") }),
1371
- carryOptionsSchema.extend({ type: import_zod14.z.literal("carry") }),
1372
- flightOptionsSchema.extend({ type: import_zod14.z.literal("flight") }),
1373
- crumpleBehaviorOptionsSchema.extend({ type: import_zod14.z.literal("crumple") }),
1374
- settleOptionsSchema.extend({ type: import_zod14.z.literal("settle") }),
1375
- ribbonOptionsSchema.extend({ type: import_zod14.z.literal("ribbon") })
1550
+ var creaseSchema = import_zod15.z.object({
1551
+ angle: import_zod15.z.number().min(-360).max(360).default(90),
1552
+ offset: import_zod15.z.number().min(-20).max(20).default(0),
1553
+ /**
1554
+ * The residual fold angle in degrees, signed — how far open the crease
1555
+ * still sits once nothing is holding the paper. This is the whole of what
1556
+ * a crease IS: geometry and shading both read it, and it is what makes the
1557
+ * field authorable by hand (a dog-ear is a crease with a big `depth` near
1558
+ * a corner) rather than only recordable.
1559
+ */
1560
+ depth: import_zod15.z.number().min(-180).max(180).default(12)
1561
+ });
1562
+ var memorySchema = import_zod15.z.object({
1563
+ /**
1564
+ * How much of a fold this paper keeps, 0..1, over the stock's own
1565
+ * {@link Stock.takesSet}. Kraft holds a crease hard; vellum springs most
1566
+ * of the way back.
1567
+ */
1568
+ set: import_zod15.z.number().min(0).max(1).optional(),
1569
+ /**
1570
+ * The creases themselves. Recorded by folding the paper (see
1571
+ * `onCrease`), or written by hand — a preset can ship already creased.
1572
+ *
1573
+ * Capped at four because that is what the crease shader carries, and a
1574
+ * cap the schema states is better than one the renderer applies silently.
1575
+ */
1576
+ creases: import_zod15.z.array(creaseSchema).max(4).default([])
1577
+ });
1578
+ var behaviorConfigSchema = import_zod15.z.discriminatedUnion("type", [
1579
+ peelOptionsSchema.extend({ type: import_zod15.z.literal("peel") }),
1580
+ unrollOptionsSchema.extend({ type: import_zod15.z.literal("unroll") }),
1581
+ flipOptionsSchema.extend({ type: import_zod15.z.literal("flip") }),
1582
+ letterFoldOptionsSchema.extend({ type: import_zod15.z.literal("letter-fold") }),
1583
+ hangOptionsSchema.extend({ type: import_zod15.z.literal("hang") }),
1584
+ flyOptionsSchema.extend({ type: import_zod15.z.literal("fly") }),
1585
+ fallOptionsSchema.extend({ type: import_zod15.z.literal("fall") }),
1586
+ carryOptionsSchema.extend({ type: import_zod15.z.literal("carry") }),
1587
+ flightOptionsSchema.extend({ type: import_zod15.z.literal("flight") }),
1588
+ crumpleBehaviorOptionsSchema.extend({ type: import_zod15.z.literal("crumple") }),
1589
+ settleOptionsSchema.extend({ type: import_zod15.z.literal("settle") }),
1590
+ ribbonOptionsSchema.extend({ type: import_zod15.z.literal("ribbon") })
1376
1591
  ]);
1377
- var deformerInstanceSchema = import_zod14.z.object({
1378
- type: import_zod14.z.string(),
1379
- options: import_zod14.z.record(import_zod14.z.unknown()).default({}),
1380
- enabled: import_zod14.z.boolean().default(true)
1592
+ var deformerInstanceSchema = import_zod15.z.object({
1593
+ type: import_zod15.z.string(),
1594
+ options: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.unknown()).default({}),
1595
+ enabled: import_zod15.z.boolean().default(true)
1381
1596
  });
1382
1597
  var physicsNames = ["none", "float", "tumble", "dangle", "taped", "breeze"];
1383
- var clothConfigSchema = import_zod14.z.object({
1384
- type: import_zod14.z.literal("cloth"),
1385
- pins: import_zod14.z.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1386
- wind: import_zod14.z.number().min(0).max(1).default(0.3),
1598
+ var clothConfigSchema = import_zod15.z.object({
1599
+ type: import_zod15.z.literal("cloth"),
1600
+ pins: import_zod15.z.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
1601
+ wind: import_zod15.z.number().min(0).max(1).default(0.3),
1387
1602
  /** Bend stiffness: 1 = crisp paper, 0 = silk. */
1388
- stiffness: import_zod14.z.number().min(0).max(1).default(0.8),
1389
- gravity: import_zod14.z.number().min(0).max(2).default(1),
1603
+ stiffness: import_zod15.z.number().min(0).max(1).default(0.8),
1604
+ gravity: import_zod15.z.number().min(0).max(2).default(1),
1390
1605
  /** Local-space ground plane the sheet settles onto. */
1391
- floor: import_zod14.z.number().min(-5).max(0).default(-1.4)
1606
+ floor: import_zod15.z.number().min(-5).max(0).default(-1.4)
1607
+ });
1608
+ var stripConfigSchema = import_zod15.z.object({
1609
+ type: import_zod15.z.literal("strip"),
1610
+ /** How far the page has scrolled, in world units of paper asked for. */
1611
+ scroll: import_zod15.z.number().min(-1e3).max(1e3).default(0),
1612
+ /** Thin layers and many turns, or few and fat. */
1613
+ tightness: import_zod15.z.number().min(0).max(1).default(0.6),
1614
+ /** Radius of the cardboard tube — the roll never pays out past it. */
1615
+ core: import_zod15.z.number().min(0.01).max(0.5).default(0.09),
1616
+ /** Paper already hanging before the first scroll. A roll always has a leaf out. */
1617
+ tail: import_zod15.z.number().min(0).max(20).default(1.1),
1618
+ /** Spacing of the perforations: one sheet's worth of strip, in world units. */
1619
+ perforation: import_zod15.z.number().min(0.05).max(5).default(1),
1620
+ /**
1621
+ * How much a perforation remembers being folded. 0 = a fresh roll, 1 = one
1622
+ * that has been used.
1623
+ *
1624
+ * The default is high on purpose: below about 0.6 the landed paper flops
1625
+ * over in flat panels and spreads across the floor, and at 0.7 it holds its
1626
+ * folds and stacks into an accordion. The pile is the point.
1627
+ */
1628
+ crease: import_zod15.z.number().min(0).max(1).default(0.7),
1629
+ /** Bend stiffness between perforations. 1 = card, 0 = cloth. */
1630
+ stiffness: import_zod15.z.number().min(0).max(1).default(0.55),
1631
+ /** Broadside air drag — what makes paper float down rather than drop. */
1632
+ drag: import_zod15.z.number().min(0).max(1).default(0.55),
1633
+ gravity: import_zod15.z.number().min(0).max(2).default(1),
1634
+ /**
1635
+ * How far below the roll the paper lands. A DISTANCE below the roll's axis,
1636
+ * matching `unroll.floor`, not a signed y like `cloth.floor` — the roll
1637
+ * family measures drops, and the composition is centred on the origin so
1638
+ * an absolute y would not survive the offset anyway.
1639
+ */
1640
+ floor: import_zod15.z.number().min(0.1).max(30).default(1.2),
1641
+ /** How long the roll coasts after the scroll stops. */
1642
+ inertia: import_zod15.z.number().min(0).max(1).default(0.45)
1392
1643
  });
1393
- var physicsSchema = import_zod14.z.union([
1394
- import_zod14.z.enum(physicsNames),
1395
- import_zod14.z.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1396
- clothConfigSchema
1644
+ var physicsSchema = import_zod15.z.union([
1645
+ import_zod15.z.enum(physicsNames),
1646
+ import_zod15.z.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
1647
+ import_zod15.z.literal("strip").transform(() => stripConfigSchema.parse({ type: "strip" })),
1648
+ clothConfigSchema,
1649
+ stripConfigSchema
1397
1650
  ]);
1398
1651
  var lightingNames = [
1399
1652
  "studio",
@@ -1406,9 +1659,9 @@ var lightingNames = [
1406
1659
  "lightbox"
1407
1660
  ];
1408
1661
  var filmNames = ["agx", "neutral", "filmic"];
1409
- var lightSchema = import_zod14.z.object({
1662
+ var lightSchema = import_zod15.z.object({
1410
1663
  /** Tone-mapping exposure — the stop the whole picture is printed at. */
1411
- exposure: import_zod14.z.number().min(0.1).max(4).optional(),
1664
+ exposure: import_zod15.z.number().min(0.1).max(4).optional(),
1412
1665
  /**
1413
1666
  * The tone curve — the film, where `exposure` is the stop.
1414
1667
  *
@@ -1417,33 +1670,33 @@ var lightSchema = import_zod14.z.object({
1417
1670
  * the wrong film: it desaturates and drags bright neutrals toward
1418
1671
  * yellow-green, which is the sepia cast a lit sheet used to pick up.
1419
1672
  */
1420
- film: import_zod14.z.enum(filmNames).optional(),
1673
+ film: import_zod15.z.enum(filmNames).optional(),
1421
1674
  /** Key light strength. */
1422
- key: import_zod14.z.number().min(0).max(12).optional(),
1675
+ key: import_zod15.z.number().min(0).max(12).optional(),
1423
1676
  /** Key light colour. */
1424
- color: import_zod14.z.string().optional().describe("color"),
1677
+ color: import_zod15.z.string().optional().describe("color"),
1425
1678
  /**
1426
1679
  * Where the key stands, degrees around the vertical. 0° is straight in
1427
1680
  * front of the paper (+Z, beside the camera), 90° is off to the right,
1428
1681
  * and ±180° is directly behind it — which is where `nave` puts it, and
1429
1682
  * why that preset is carried by light coming THROUGH the paper.
1430
1683
  */
1431
- direction: import_zod14.z.number().min(-180).max(180).optional(),
1684
+ direction: import_zod15.z.number().min(-180).max(180).optional(),
1432
1685
  /** How high the key stands, degrees above the horizon. */
1433
- height: import_zod14.z.number().min(-30).max(89).optional(),
1686
+ height: import_zod15.z.number().min(-30).max(89).optional(),
1434
1687
  /** Flat fill from every direction at once. Cheap, and it kills form — reach for `studio` first. */
1435
- ambient: import_zod14.z.number().min(0).max(2).optional(),
1688
+ ambient: import_zod15.z.number().min(0).max(2).optional(),
1436
1689
  /** The room's own light: an environment map built from `sky`. Directional fill, and the only thing paper's sheen has to reflect. */
1437
- studio: import_zod14.z.number().min(0).max(3).optional(),
1690
+ studio: import_zod15.z.number().min(0).max(3).optional(),
1438
1691
  /** Distance haze, as a multiple of the preset's. 0 clears the air entirely; 2 halves the distance you can see. */
1439
- haze: import_zod14.z.number().min(0).max(3).optional()
1692
+ haze: import_zod15.z.number().min(0).max(3).optional()
1440
1693
  });
1441
- var backdropSchema = import_zod14.z.object({
1694
+ var backdropSchema = import_zod15.z.object({
1442
1695
  /** Behind everything, and behind the picture where it does not reach. */
1443
- color: import_zod14.z.string().default("#171717").describe("color"),
1696
+ color: import_zod15.z.string().default("#171717").describe("color"),
1444
1697
  /** A URL, or an uploaded picture. Empty is the colour on its own. */
1445
- image: import_zod14.z.string().default(""),
1446
- fit: import_zod14.z.enum(["cover", "contain"]).default("cover"),
1698
+ image: import_zod15.z.string().default(""),
1699
+ fit: import_zod15.z.enum(["cover", "contain"]).default("cover"),
1447
1700
  /**
1448
1701
  * Toward the colour, so the paper stays the subject.
1449
1702
  *
@@ -1452,14 +1705,35 @@ var backdropSchema = import_zod14.z.object({
1452
1705
  * of the light, and what this solves by mixing it back toward the ground
1453
1706
  * it sits on.
1454
1707
  */
1455
- fade: import_zod14.z.number().min(0).max(1).default(0.25),
1708
+ fade: import_zod15.z.number().min(0).max(1).default(0.25),
1456
1709
  /** Out of focus, for the same reason. */
1457
- blur: import_zod14.z.number().min(0).max(1).default(0.2)
1710
+ blur: import_zod15.z.number().min(0).max(1).default(0.2)
1458
1711
  });
1459
- var sceneSchema = import_zod14.z.object({
1460
- lighting: import_zod14.z.enum(lightingNames).default("studio"),
1712
+ var sceneSchema = import_zod15.z.object({
1713
+ lighting: import_zod15.z.enum(lightingNames).default("studio"),
1461
1714
  /** What is behind the sheet. Unset leaves the canvas alone. */
1462
1715
  backdrop: backdropSchema.optional(),
1716
+ /**
1717
+ * Degrees the whole composition is turned about its vertical axis, so a
1718
+ * preset can choose the angle it is READ from.
1719
+ *
1720
+ * Every camera in the library is fixed and head-on — `<Paper>` sits at
1721
+ * `(0, 0.35, 2.4)` looking down -Z, and neither it nor the editor fits a
1722
+ * camera to its content. That is the right default for a sheet, which is
1723
+ * flat and faces you. It is the wrong one for anything whose shape lives
1724
+ * in DEPTH: the `strip` sim folds in z by construction, so head-on its
1725
+ * roll and the whole accordion of its pile are edge-on and the preset
1726
+ * renders as a blank white column.
1727
+ *
1728
+ * A camera field would have been the other way to fix it, and is worse: it
1729
+ * is meaningless inside `<PaperField>` and `<PaperMesh>`, where the caller
1730
+ * owns the camera and there may be a dozen papers sharing it. Turning the
1731
+ * paper works everywhere, because it is a property of the paper.
1732
+ *
1733
+ * Additive with the `rotation` prop rather than overriding it — the prop
1734
+ * is the caller's, and a preset does not get to overrule it.
1735
+ */
1736
+ turn: import_zod15.z.number().min(-180).max(180).default(0),
1463
1737
  /**
1464
1738
  * Overrides on the named preset — the same authorable half stage mode has
1465
1739
  * always had, and which a lone sheet had no way to reach.
@@ -1473,56 +1747,65 @@ var sceneSchema = import_zod14.z.object({
1473
1747
  });
1474
1748
  var coreStateNames = ["rest", "hover", "pressed", "picked", "placed"];
1475
1749
  var isStateName = (s) => coreStateNames.includes(s) || s.startsWith("custom:");
1476
- var stateNameSchema = import_zod14.z.string().refine(isStateName, {
1750
+ var stateNameSchema = import_zod15.z.string().refine(isStateName, {
1477
1751
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1478
1752
  });
1479
- var stateTransitionSchema = import_zod14.z.object({
1480
- duration: import_zod14.z.number().min(0).max(5).default(0.35),
1753
+ var stateTransitionSchema = import_zod15.z.object({
1754
+ duration: import_zod15.z.number().min(0).max(5).default(0.35),
1481
1755
  /** GSAP ease name. */
1482
- ease: import_zod14.z.string().default("power2.out")
1756
+ ease: import_zod15.z.string().default("power2.out")
1483
1757
  });
1484
- var stateDefSchema = import_zod14.z.object({
1758
+ var stateDefSchema = import_zod15.z.object({
1485
1759
  /** Deep-partial override of the paper schema (behavior params, surface, …). */
1486
- overrides: import_zod14.z.record(import_zod14.z.unknown()).default({}),
1760
+ overrides: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.unknown()).default({}),
1487
1761
  /** Transition INTO this state. */
1488
- transition: stateTransitionSchema.default({}),
1762
+ transition: stateTransitionSchema.prefault({}),
1489
1763
  /** Chained actions after arriving. v1: 'emit:<event>' only. */
1490
- onEnter: import_zod14.z.array(import_zod14.z.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1764
+ onEnter: import_zod15.z.array(import_zod15.z.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
1491
1765
  });
1492
- var paperStatesSchema = import_zod14.z.object({
1766
+ var paperStatesSchema = import_zod15.z.object({
1493
1767
  initial: stateNameSchema.default("rest"),
1494
- states: import_zod14.z.record(import_zod14.z.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1768
+ states: import_zod15.z.record(import_zod15.z.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
1495
1769
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
1496
1770
  }),
1497
1771
  /** World-units drag distance that flips pressed → picked (pick-enabled behaviors only). */
1498
- pickThreshold: import_zod14.z.number().min(5e-3).max(1).default(0.1)
1772
+ pickThreshold: import_zod15.z.number().min(5e-3).max(1).default(0.1)
1499
1773
  });
1500
- var metaSchema = import_zod14.z.object({
1501
- name: import_zod14.z.string().default("untitled"),
1502
- author: import_zod14.z.string().optional(),
1503
- version: import_zod14.z.string().default("0"),
1504
- tags: import_zod14.z.array(import_zod14.z.string()).default([])
1774
+ var metaSchema = import_zod15.z.object({
1775
+ name: import_zod15.z.string().default("untitled"),
1776
+ author: import_zod15.z.string().optional(),
1777
+ version: import_zod15.z.string().default("0"),
1778
+ tags: import_zod15.z.array(import_zod15.z.string()).default([])
1505
1779
  });
1506
- var paperConfigSchema = import_zod14.z.object({
1507
- meta: metaSchema.default({}),
1508
- sheet: sheetSchema.default({}),
1780
+ var paperConfigSchema = import_zod15.z.object({
1781
+ meta: metaSchema.prefault({}),
1782
+ sheet: sheetSchema.prefault({}),
1509
1783
  stock: stockSchema.default("printer"),
1510
1784
  content: contentSchema.default({ type: "blank" }),
1511
1785
  /** A behavior OR a raw deformer stack — if both are present, `deformers` wins (it's the fork). */
1512
1786
  behavior: behaviorConfigSchema.optional(),
1513
- deformers: import_zod14.z.array(deformerInstanceSchema).optional(),
1787
+ deformers: import_zod15.z.array(deformerInstanceSchema).optional(),
1514
1788
  surface: surfaceSchema.default({}),
1789
+ /**
1790
+ * What the sheet remembers being folded — creases outlive the fold.
1791
+ *
1792
+ * Present by default, and on: paper that forgets is the bug this exists
1793
+ * to fix, so remembering is not something a preset should have to ask
1794
+ * for. `memory: { set: 0 }` is the opt-out, and it is what every sheet in
1795
+ * the library did before this shipped.
1796
+ */
1797
+ memory: memorySchema.prefault({}),
1515
1798
  physics: physicsSchema.default("none"),
1516
- scene: sceneSchema.default({}),
1517
- onTwos: import_zod14.z.boolean().default(false),
1799
+ scene: sceneSchema.prefault({}),
1800
+ onTwos: import_zod15.z.boolean().default(false),
1518
1801
  /** Interaction state machine — overrides-on-base diffs. */
1519
1802
  states: paperStatesSchema.optional()
1520
1803
  }).superRefine((config, ctx) => {
1521
- if (typeof config.physics === "object" && (config.behavior || config.deformers)) {
1804
+ if (typeof config.physics === "object" && config.physics.type === "strip" && (config.behavior || config.deformers)) {
1522
1805
  ctx.addIssue({
1523
- code: import_zod14.z.ZodIssueCode.custom,
1806
+ code: import_zod15.z.ZodIssueCode.custom,
1524
1807
  path: ["physics"],
1525
- message: "cloth physics and behavior/deformers are exclusive \u2014 cloth owns the vertices (pick Shape OR Simulation)"
1808
+ 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)"
1526
1809
  });
1527
1810
  }
1528
1811
  if (config.states) {
@@ -1531,7 +1814,7 @@ var paperConfigSchema = import_zod14.z.object({
1531
1814
  if (!def) continue;
1532
1815
  if (def.overrides.states !== void 0) {
1533
1816
  ctx.addIssue({
1534
- code: import_zod14.z.ZodIssueCode.custom,
1817
+ code: import_zod15.z.ZodIssueCode.custom,
1535
1818
  path: ["states", "states", name, "overrides"],
1536
1819
  message: "state overrides cannot override `states` (no nested state machines)"
1537
1820
  });
@@ -1542,7 +1825,7 @@ var paperConfigSchema = import_zod14.z.object({
1542
1825
  if (!result.success) {
1543
1826
  const first = result.error.issues[0];
1544
1827
  ctx.addIssue({
1545
- code: import_zod14.z.ZodIssueCode.custom,
1828
+ code: import_zod15.z.ZodIssueCode.custom,
1546
1829
  path: ["states", "states", name, "overrides"],
1547
1830
  message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")} \u2014 ${first.message}` : "invalid"}`
1548
1831
  });
@@ -1721,21 +2004,21 @@ var lightingPresets = {
1721
2004
  function getLightingPreset(name) {
1722
2005
  return lightingPresets[name];
1723
2006
  }
1724
- var DEG2 = 180 / Math.PI;
2007
+ var DEG3 = 180 / Math.PI;
1725
2008
  function lightAngles(position) {
1726
2009
  const [x, y, z30] = position;
1727
2010
  const distance = Math.hypot(x, y, z30);
1728
2011
  if (distance < 1e-9) return { azimuth: 0, elevation: 90, distance: 0 };
1729
2012
  const ground = Math.hypot(x, z30);
1730
2013
  return {
1731
- azimuth: ground < 1e-9 ? 0 : Math.atan2(x, z30) * DEG2,
1732
- elevation: Math.atan2(y, ground) * DEG2,
2014
+ azimuth: ground < 1e-9 ? 0 : Math.atan2(x, z30) * DEG3,
2015
+ elevation: Math.atan2(y, ground) * DEG3,
1733
2016
  distance
1734
2017
  };
1735
2018
  }
1736
2019
  function lightPosition(angles) {
1737
- const azimuth = angles.azimuth / DEG2;
1738
- const elevation = angles.elevation / DEG2;
2020
+ const azimuth = angles.azimuth / DEG3;
2021
+ const elevation = angles.elevation / DEG3;
1739
2022
  const ground = Math.cos(elevation) * angles.distance;
1740
2023
  return [Math.sin(azimuth) * ground, Math.sin(elevation) * angles.distance, Math.cos(azimuth) * ground];
1741
2024
  }
@@ -2344,6 +2627,183 @@ var builtins = {
2344
2627
  behavior: { type: "unroll", progress: 0.55, tightness: 0.55, sway: 0.3 },
2345
2628
  surface: { deckle: { edges: ["bottom"], roughness: 0.6 } }
2346
2629
  },
2630
+ /**
2631
+ * A roll on a holder with a leaf already out, meant to be driven by scroll:
2632
+ * bind `behavior.progress` to how far down the page you are and the paper
2633
+ * pays out while the roll runs down toward its tube.
2634
+ *
2635
+ * The three parts that make it read as a real roll rather than a curled
2636
+ * sheet: `fixed` keeps the roll on its holder and moves the paper instead,
2637
+ * `tail` means there is always a leaf to take hold of, and `floor` gives
2638
+ * the drop somewhere to land — paper that reaches the ground creases and
2639
+ * lies down rather than hanging into the void forever. A `core` a third of
2640
+ * the full radius is a real cardboard tube, so the roll still looks like a
2641
+ * roll after it has been used down.
2642
+ */
2643
+ "paper-roll": {
2644
+ meta: { name: "Paper roll", tags: ["roll", "unroll", "scroll", "hero"] },
2645
+ sheet: { width: 1, height: 5 },
2646
+ stock: "newsprint",
2647
+ content: { type: "blank" },
2648
+ behavior: {
2649
+ type: "unroll",
2650
+ progress: 0.25,
2651
+ tightness: 0.8,
2652
+ sway: 0.15,
2653
+ from: "top",
2654
+ fixed: true,
2655
+ core: 0.12,
2656
+ tail: 0.5,
2657
+ floor: 2.4
2658
+ },
2659
+ surface: { deckle: { edges: ["bottom"], roughness: 0.5 } }
2660
+ },
2661
+ /**
2662
+ * The one that is a SIMULATION rather than a shape.
2663
+ *
2664
+ * `paper-roll` above draws this same object with a deformer stack, and for
2665
+ * a roll paying out against a wall that is the cheaper and better answer.
2666
+ * This preset exists for the half that geometry cannot reach: what happens
2667
+ * once the paper hits the ground. A deformer can bend a sheet along a curve
2668
+ * you have already chosen; it cannot discover that a strip under
2669
+ * compression buckles at its weakest hinge, and it cannot let one fold land
2670
+ * on the one beneath it. Both of those are what a pile IS.
2671
+ *
2672
+ * Bind `physics.scroll` to the page and the roll turns:
2673
+ *
2674
+ * ```tsx
2675
+ * const [scroll, setScroll] = useState(0)
2676
+ * useEffect(() => {
2677
+ * const onScroll = () => setScroll(window.scrollY / 120)
2678
+ * window.addEventListener('scroll', onScroll, { passive: true })
2679
+ * return () => window.removeEventListener('scroll', onScroll)
2680
+ * }, [])
2681
+ * <Paper preset="toilet-roll" physics={{ type: 'strip', scroll }} />
2682
+ * ```
2683
+ *
2684
+ * It is a MONOTONIC world-unit number, not a 0..1 progress — the sim
2685
+ * differentiates it, so scrolling back up rewinds the roll and drags the
2686
+ * pile taut before it lifts.
2687
+ *
2688
+ * The proportions are the real object's: a panel as wide as it is long, so
2689
+ * `perforation` equals the sheet width and the strip tears into squares.
2690
+ */
2691
+ "toilet-roll": {
2692
+ meta: { name: "Toilet roll", tags: ["roll", "scroll", "simulation", "hero"] },
2693
+ // The proportions are a real roll's, at the scale the library is viewed
2694
+ // at: `<Paper>` and the editor both look at the origin through about two
2695
+ // world units and neither fits a camera to its content, so the whole
2696
+ // composition — roll, drop and pile — has to live inside that. A panel is
2697
+ // as wide as it is long and the roll is about a panel across, which is
2698
+ // what a toilet roll is.
2699
+ //
2700
+ // Twenty-three panels of paper. Not a real roll's several hundred, but
2701
+ // enough that a full page of scrolling does not empty it: one scroll unit
2702
+ // is about one unit of paper on a fresh roll, so this is a couple of
2703
+ // screens' worth before the tube shows.
2704
+ sheet: { width: 0.6, height: 14 },
2705
+ stock: "printer",
2706
+ content: { type: "blank" },
2707
+ physics: {
2708
+ type: "strip",
2709
+ scroll: 0,
2710
+ // Wound tightly enough to hold this much paper at a believable size: the
2711
+ // outer radius lands at 0.61 of the panel width against a real roll's
2712
+ // 0.57, over about nine visible turns.
2713
+ //
2714
+ // `tightness` is doing double duty and the trade is worth knowing about.
2715
+ // A layer gap IS the paper's thickness, so it also sets how far apart
2716
+ // self-collision holds two folds — wind tighter for a neater roll and
2717
+ // the pile on the floor gets flatter, looser for a fatter pile and the
2718
+ // roll coarsens. This is the middle of that.
2719
+ //
2720
+ // It is ALSO the roll's rim, and that is the reason not to raise it on
2721
+ // looks alone. A layer gap is a real space between two wound turns, so
2722
+ // the roll's end face is concentric rings with nothing between them,
2723
+ // and off head-on you see between them — a fine sawtooth around the
2724
+ // rim that winding tighter genuinely reduces. It was tried. Tightening
2725
+ // to 0.78 also moves the roll's proportion to 0.553 of a panel width,
2726
+ // nearer a real roll's 0.57 than this is, so it looked like a free win
2727
+ // — and it throws the pile 1.33 units out in x against the 0.874 a
2728
+ // square parent can see, spreading 5.9 panel-widths. Framing beats the
2729
+ // rim. The sawtooth is what a roll wound from ONE zero-thickness
2730
+ // ribbon costs; it is not tuned away, and a caller who wants it gone
2731
+ // wants a thicker `stock` or fewer, fatter turns.
2732
+ tightness: 0.65,
2733
+ // A real cardboard tube, and a floor on how tight the spiral ever winds.
2734
+ // The innermost wrap is the coarsest thing in the roll — the same
2735
+ // arc-length step spans a bigger angle the smaller the radius — so the
2736
+ // core is what stops a nearly-empty roll turning back into a polygon.
2737
+ core: 0.12,
2738
+ // A panel and a half already hanging. A roll on a holder always has a
2739
+ // leaf out; starting from a bare cylinder reads as one still wrapped.
2740
+ tail: 0.9,
2741
+ perforation: 0.6,
2742
+ // The four numbers below were chosen TOGETHER, and by worst case rather
2743
+ // than by a good-looking run. A pile is chaotic: change `crease` by
2744
+ // 0.05 and one 14-second scroll can spread 2.0 panel-widths of floor or
2745
+ // 4.2, so a single trajectory is a sample and not a measurement. These
2746
+ // are scored over nine — three scroll depths crossed with three feed
2747
+ // rates — on how far the composition ever gets from the origin.
2748
+ //
2749
+ // What that fixed: the shipped set spread 2.9 panel-widths on average
2750
+ // and 4.0 at worst, and threw paper 1.60 units out in z. It ran off the
2751
+ // side of the frame and kept going. These hold 2.2 average, 2.8 worst,
2752
+ // and 1.09 out — while keeping the pile's height (about ten layers)
2753
+ // intact, which is the thing all of this exists to show.
2754
+ //
2755
+ // The most load-bearing of them. Below about 0.6 the landed paper flops
2756
+ // over in flat panels and runs away across the floor instead of folding
2757
+ // back; high is what makes the perforations hold and the pile
2758
+ // accordion. A used roll remembering its creases is the whole effect.
2759
+ crease: 0.9,
2760
+ // Low enough that a panel buckles rather than steering the pile: at 0.5
2761
+ // the sheet was stiff enough to push the folds already down along the
2762
+ // floor ahead of it, which is what "spreads across four panels" was.
2763
+ stiffness: 0.4,
2764
+ // High: paper is light and broad, and this is what separates it from a
2765
+ // rope hanging off a drum. It also damps the sideways travel that
2766
+ // carried the pile out of frame.
2767
+ drag: 0.85,
2768
+ gravity: 1,
2769
+ // The drop, measured from the roll's axis. Together with the roll's own
2770
+ // radius this IS the height of the composition, which is centred on the
2771
+ // origin — so keep their sum under about 1.7 or the roll and the pile
2772
+ // fall outside the ~1.75 units `<Paper>`'s fixed camera can see.
2773
+ //
2774
+ // Shortened from 1.2, and it is the single most effective number here:
2775
+ // a longer fall is more airtime for the strip to pick a direction and
2776
+ // glide, so it landed still travelling and slid. Worst-case spread goes
2777
+ // 4.2 panel-widths to 3.1 and the pile keeps its full depth. It buys
2778
+ // vertical room as well, which is what lets the roll sit further up.
2779
+ floor: 0.85,
2780
+ inertia: 0.5
2781
+ },
2782
+ surface: { grain: 0.25 },
2783
+ // The preset is unreadable without this, and that is not a figure of
2784
+ // speech: the strip sim folds in DEPTH, and every camera in the library
2785
+ // is fixed and head-on, so `<Paper preset="toilet-roll" />` framed the
2786
+ // roll end-on and the entire accordion edge-on and rendered a blank white
2787
+ // column. This is the three-quarter view the pile actually reads from —
2788
+ // enough to see along the folds and around the roll's rim, not so much
2789
+ // that the strip's face turns away.
2790
+ //
2791
+ // The ceiling is framing, not taste. Turning swaps the pile's DEPTH for
2792
+ // WIDTH, and depth is free — the camera has plenty — while width is not.
2793
+ // Measured over fifteen scroll trajectories the pile reaches 1.27 units
2794
+ // of depth at worst, and `halfWidth·cos θ + 1.27·sin θ` crosses the 0.874
2795
+ // half-view a square parent gets at 28°. Twenty-five leaves real margin
2796
+ // (0.809) and gives up almost nothing of the angle.
2797
+ //
2798
+ // Do not raise it without re-measuring that depth: the two numbers are
2799
+ // coupled, and the pile's depth is not stable under small changes to the
2800
+ // physics — see the note on `floor`.
2801
+ //
2802
+ // A parent narrower than square still crops the pile's far edge. That is
2803
+ // the honest limit of a fixed camera, and the caller's answer is their
2804
+ // own `rotation` prop, which this composes with rather than overrides.
2805
+ scene: { turn: 25 }
2806
+ },
2347
2807
  "letter-fold": {
2348
2808
  meta: { name: "Letter fold", tags: ["fold", "text"] },
2349
2809
  sheet: { width: 1, height: 1.4 },
@@ -2353,7 +2813,24 @@ var builtins = {
2353
2813
  text: "Dear you,\n\nSome things are worth folding carefully.\n\nYours,\nN."
2354
2814
  },
2355
2815
  behavior: { type: "letter-fold", progress: 0.4, crease: 0.3 },
2356
- surface: { creaseLines: { angle: 0, positions: [1 / 3, 2 / 3], strength: 0.5 } }
2816
+ // A letter that has been folded before. These were painted-on
2817
+ // `creaseLines` until the paper could carry real ones — two marks in the
2818
+ // shader at exactly the two places the tri-fold bends, which is a good
2819
+ // impression of a crease right up until you unfold the letter and it
2820
+ // comes back perfectly flat. As memory they bend the sheet as well as
2821
+ // marking it, and folding the letter deepens the creases it already has
2822
+ // rather than drawing a second pair on top of them.
2823
+ //
2824
+ // The lines are `letterFold`'s own: travel down and up, each a sixth of
2825
+ // the sheet from the middle. Shallower than a fold leaves (printer keeps
2826
+ // about 20°) because this letter has been folded once and put away, not
2827
+ // folded and reopened in front of you.
2828
+ memory: {
2829
+ creases: [
2830
+ { angle: 270, offset: 1.4 / 6, depth: 13 },
2831
+ { angle: 90, offset: 1.4 / 6, depth: 11 }
2832
+ ]
2833
+ }
2357
2834
  },
2358
2835
  /**
2359
2836
  * The wash, shown rather than described.
@@ -2648,9 +3125,9 @@ var NOTE_TRACKING = 0.06;
2648
3125
  function paintCard(ctx, w, h, content, stock, dpr) {
2649
3126
  const ink = content.color === "#2b2620" ? stock.inkColor : content.color;
2650
3127
  const size = content.size * dpr;
2651
- const pad = content.padding * Math.min(w, h);
2652
- const maxWidth = w - pad * 2;
2653
- const x = content.align === "center" ? w / 2 : pad;
3128
+ const pad2 = content.padding * Math.min(w, h);
3129
+ const maxWidth = w - pad2 * 2;
3130
+ const x = content.align === "center" ? w / 2 : pad2;
2654
3131
  ctx.textAlign = content.align === "center" ? "center" : "left";
2655
3132
  ctx.textBaseline = "alphabetic";
2656
3133
  const titleSize = size * TITLE_RATIO;
@@ -2662,7 +3139,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2662
3139
  const noteBlock = content.note ? noteSize * 2.4 : 0;
2663
3140
  const bodyBlock = bodyLines.length * bodyStep;
2664
3141
  const total = titleBlock + ruleBlock + bodyBlock + noteBlock;
2665
- let y = Math.max(pad, (h - total) / 2) + size * 0.9;
3142
+ let y = Math.max(pad2, (h - total) / 2) + size * 0.9;
2666
3143
  if (content.title) {
2667
3144
  ctx.font = `${titleSize}px ${content.font}`;
2668
3145
  ctx.letterSpacing = `${TITLE_TRACKING}em`;
@@ -2678,8 +3155,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2678
3155
  ctx.globalAlpha = 0.28;
2679
3156
  ctx.lineWidth = Math.max(1, dpr * 0.75);
2680
3157
  ctx.beginPath();
2681
- ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad, y - titleSize * 0.5);
2682
- ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad + maxWidth, y - titleSize * 0.5);
3158
+ ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad2, y - titleSize * 0.5);
3159
+ ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad2 + maxWidth, y - titleSize * 0.5);
2683
3160
  ctx.stroke();
2684
3161
  ctx.restore();
2685
3162
  y += ruleBlock;
@@ -2693,8 +3170,8 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2693
3170
  for (let i = 0; i < bodyLines.length; i++) {
2694
3171
  const lineY = y + i * bodyStep + size * 0.28;
2695
3172
  ctx.beginPath();
2696
- ctx.moveTo(pad, lineY);
2697
- ctx.lineTo(pad + maxWidth, lineY);
3173
+ ctx.moveTo(pad2, lineY);
3174
+ ctx.lineTo(pad2 + maxWidth, lineY);
2698
3175
  ctx.stroke();
2699
3176
  }
2700
3177
  ctx.restore();
@@ -2702,7 +3179,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2702
3179
  ctx.font = `${size}px ${content.font}`;
2703
3180
  ctx.fillStyle = ink;
2704
3181
  for (const line of bodyLines) {
2705
- if (y > h - pad) break;
3182
+ if (y > h - pad2) break;
2706
3183
  ctx.fillText(line, x, y);
2707
3184
  y += bodyStep;
2708
3185
  }
@@ -2710,7 +3187,7 @@ function paintCard(ctx, w, h, content, stock, dpr) {
2710
3187
  ctx.font = `${noteSize}px ${content.font}`;
2711
3188
  ctx.letterSpacing = `${NOTE_TRACKING}em`;
2712
3189
  ctx.globalAlpha = 0.6;
2713
- ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad));
3190
+ ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad2));
2714
3191
  ctx.globalAlpha = 1;
2715
3192
  ctx.letterSpacing = "0em";
2716
3193
  }
@@ -2840,23 +3317,23 @@ function paintImage(ctx, w, h, img, fit) {
2840
3317
  }
2841
3318
  function paintText(ctx, w, h, content, stock) {
2842
3319
  const size = content.size * DPR;
2843
- const pad = content.padding * Math.min(w, h);
3320
+ const pad2 = content.padding * Math.min(w, h);
2844
3321
  const font = `${content.weight} ${size}px ${content.font}`;
2845
3322
  ctx.font = font;
2846
3323
  ctx.fillStyle = content.color === "#2b2620" ? stock.inkColor : content.color;
2847
3324
  ctx.textBaseline = "top";
2848
3325
  ctx.textAlign = content.align;
2849
3326
  ctx.letterSpacing = `${content.tracking}em`;
2850
- const maxWidth = w - pad * 2;
2851
- const x = content.align === "left" ? pad : content.align === "right" ? w - pad : w / 2;
3327
+ const maxWidth = w - pad2 * 2;
3328
+ const x = content.align === "left" ? pad2 : content.align === "right" ? w - pad2 : w / 2;
2852
3329
  const lineStep = size * content.lineHeight;
2853
3330
  const lines = wrapLines(ctx, content.text, maxWidth, font);
2854
3331
  ctx.font = font;
2855
3332
  ctx.letterSpacing = `${content.tracking}em`;
2856
3333
  const block = lines.length * lineStep;
2857
- let y = content.valign === "center" ? Math.max(pad, (h - block) / 2) : pad;
3334
+ let y = content.valign === "center" ? Math.max(pad2, (h - block) / 2) : pad2;
2858
3335
  for (const line of lines) {
2859
- if (y > h - pad) break;
3336
+ if (y > h - pad2) break;
2860
3337
  ctx.fillText(line, x, y);
2861
3338
  y += lineStep;
2862
3339
  }
@@ -2927,77 +3404,6 @@ var THREE7 = __toESM(require("three"), 1);
2927
3404
  // src/deformers/registry.ts
2928
3405
  var import_zod21 = require("zod");
2929
3406
 
2930
- // src/deformers/roll.ts
2931
- var import_zod15 = require("zod");
2932
- var rollOptionsSchema = import_zod15.z.object({
2933
- /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
2934
- angle: import_zod15.z.number().min(-360).max(360).default(90),
2935
- /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
2936
- boundary: import_zod15.z.number().min(-20).max(20).default(0),
2937
- /** Cylinder radius — sharpness of the roll. */
2938
- radius: import_zod15.z.number().min(0.01).max(2).default(0.12),
2939
- /** Radius growth per radian so multi-turn rolls spiral instead of z-fighting. */
2940
- spiral: import_zod15.z.number().min(0).max(0.2).default(0.015)
2941
- });
2942
- var DEG3 = Math.PI / 180;
2943
- var roll = {
2944
- id: "roll",
2945
- label: "Roll",
2946
- defaults: rollOptionsSchema.parse({}),
2947
- optionsSchema: rollOptionsSchema,
2948
- geometry: {
2949
- minSegments: 48,
2950
- // The winding radius is the tightest curvature on the sheet — `spiral`
2951
- // only grows it as the roll winds outward, so the first turn is the one
2952
- // that sets the density.
2953
- autoSegments: (o, sheet2) => segmentsForArc(spanAlong(sheet2, o.angle), o.radius),
2954
- axis: (o) => o.angle
2955
- },
2956
- displace(out, _uv, o) {
2957
- const dirX = Math.cos(o.angle * DEG3);
2958
- const dirY = Math.sin(o.angle * DEG3);
2959
- const d = out.x * dirX + out.y * dirY;
2960
- const s = d - o.boundary;
2961
- if (s <= 0) return;
2962
- const theta = s / o.radius;
2963
- const r = o.radius + o.spiral * theta;
2964
- const sin = Math.sin(theta);
2965
- const cos = Math.cos(theta);
2966
- const newD = o.boundary + (r - out.z) * sin;
2967
- const newZ = r * (1 - cos) + out.z * cos;
2968
- out.x += dirX * (newD - d);
2969
- out.y += dirY * (newD - d);
2970
- out.z = newZ;
2971
- },
2972
- glsl: {
2973
- chunk: (
2974
- /* glsl */
2975
- `
2976
- void FN(inout vec3 p, vec2 uv, float t) {
2977
- vec2 dir = vec2(cos(U_angle), sin(U_angle));
2978
- float d = dot(p.xy, dir);
2979
- float s = d - U_boundary;
2980
- if (s <= 0.0) return;
2981
- float theta = s / U_radius;
2982
- float r = U_radius + U_spiral * theta;
2983
- float sn = sin(theta);
2984
- float cs = cos(theta);
2985
- float newD = U_boundary + (r - p.z) * sn;
2986
- float newZ = r * (1.0 - cs) + p.z * cs;
2987
- p.xy += dir * (newD - d);
2988
- p.z = newZ;
2989
- }
2990
- `
2991
- ),
2992
- uniforms: (o) => ({
2993
- angle: o.angle * DEG3,
2994
- boundary: o.boundary,
2995
- radius: o.radius,
2996
- spiral: o.spiral
2997
- })
2998
- }
2999
- };
3000
-
3001
3407
  // src/deformers/bend.ts
3002
3408
  var import_zod16 = require("zod");
3003
3409
  var bendOptionsSchema = import_zod16.z.object({
@@ -3179,7 +3585,7 @@ var waveOptionsSchema = import_zod18.z.object({
3179
3585
  pinnedEdge: import_zod18.z.enum(["none", "top", "bottom", "left", "right"]).default("none")
3180
3586
  });
3181
3587
  var DEG6 = Math.PI / 180;
3182
- var TAU = Math.PI * 2;
3588
+ var TAU2 = Math.PI * 2;
3183
3589
  var wave = {
3184
3590
  id: "wave",
3185
3591
  label: "Wave",
@@ -3206,7 +3612,7 @@ var wave = {
3206
3612
  const dirX = Math.cos(o.angle * DEG6);
3207
3613
  const dirY = Math.sin(o.angle * DEG6);
3208
3614
  const d = out.x * dirX + out.y * dirY;
3209
- const phase = (d / o.wavelength - o.speed * ctx.t) * TAU;
3615
+ const phase = (d / o.wavelength - o.speed * ctx.t) * TAU2;
3210
3616
  let env = 1;
3211
3617
  if (o.pinnedEdge === "top") env = 1 - uv.y;
3212
3618
  else if (o.pinnedEdge === "bottom") env = uv.y;
@@ -3262,7 +3668,7 @@ var drapeOptionsSchema = import_zod19.z.object({
3262
3668
  gather: import_zod19.z.number().min(0).max(1).default(0.5),
3263
3669
  pinnedEdge: import_zod19.z.enum(["top", "bottom"]).default("top")
3264
3670
  });
3265
- var TAU2 = Math.PI * 2;
3671
+ var TAU3 = Math.PI * 2;
3266
3672
  var drape = {
3267
3673
  id: "drape",
3268
3674
  label: "Drape",
@@ -3290,7 +3696,7 @@ var drape = {
3290
3696
  if (o.amplitude === 0) return;
3291
3697
  const drop = o.pinnedEdge === "top" ? 1 - uv.y : uv.y;
3292
3698
  const depth = drop ** o.falloff;
3293
- const u = uv.x * TAU2 * o.folds;
3699
+ const u = uv.x * TAU3 * o.folds;
3294
3700
  const fold2 = Math.sin(u) + o.irregular * 0.6 * Math.sin(u * 1.7 + 2.1);
3295
3701
  out.z += o.amplitude * depth * fold2;
3296
3702
  const pinch = o.gather * depth * Math.min(o.amplitude * o.folds * 0.8, 0.6);
@@ -3564,6 +3970,211 @@ function take(out, deformer, options, sheet2, demand) {
3564
3970
  if (y > out[1]) out[1] = y;
3565
3971
  }
3566
3972
 
3973
+ // src/deformers/memory.ts
3974
+ var MAX_SET = 0.2;
3975
+ var CREASE_RADIUS = 0.03;
3976
+ var CREASE_MIN_GROWTH = 45;
3977
+ var CREASE_DRIFT = 0.02;
3978
+ var CREASE_DRIFT_ANGLE = 2;
3979
+ var MIN_DEPTH = 1;
3980
+ var MAX_CREASES = 4;
3981
+ function canonicalLine(angle, offset) {
3982
+ let a = (angle % 360 + 360) % 360;
3983
+ let o = offset;
3984
+ if (a >= 180) {
3985
+ a -= 180;
3986
+ o = -o;
3987
+ }
3988
+ return [a, o];
3989
+ }
3990
+ function sameLine(a, b) {
3991
+ const [aa, ao] = canonicalLine(a.angle, a.offset);
3992
+ const [ba, bo] = canonicalLine(b.angle, b.offset);
3993
+ const d = Math.abs(aa - ba);
3994
+ const angleClose = Math.min(d, 180 - d) <= CREASE_DRIFT_ANGLE;
3995
+ return angleClose && Math.abs(ao - bo) <= CREASE_DRIFT;
3996
+ }
3997
+ function applyMemory(stack, creases) {
3998
+ if (creases.length === 0) return stack;
3999
+ let out = stack;
4000
+ const loose = [];
4001
+ for (const crease of creases) {
4002
+ if (Math.abs(crease.depth) < MIN_DEPTH) continue;
4003
+ const index = out.findIndex(
4004
+ (i) => i.type === "fold" && i.enabled !== false && sameLine(i.options, crease)
4005
+ );
4006
+ if (index === -1) {
4007
+ loose.push(crease);
4008
+ continue;
4009
+ }
4010
+ const live = out[index];
4011
+ const options = live.options;
4012
+ if (Math.abs(options.foldAngle) >= Math.abs(crease.depth)) continue;
4013
+ if (out === stack) out = [...stack];
4014
+ out[index] = { ...live, options: { ...options, foldAngle: crease.depth } };
4015
+ }
4016
+ if (loose.length === 0) return out;
4017
+ return [...loose.map(toFold), ...out];
4018
+ }
4019
+ function toFold(crease) {
4020
+ return {
4021
+ type: "fold",
4022
+ options: {
4023
+ angle: crease.angle,
4024
+ offset: crease.offset,
4025
+ foldAngle: crease.depth,
4026
+ radius: CREASE_RADIUS
4027
+ }
4028
+ };
4029
+ }
4030
+ var CreaseTracker = class {
4031
+ slots = /* @__PURE__ */ new Map();
4032
+ recorded = [];
4033
+ authored = [];
4034
+ /** What `observe` last handed out, so an echo can be told from an edit. */
4035
+ lastReported = [];
4036
+ constructor(authored = []) {
4037
+ this.authored = authored;
4038
+ }
4039
+ /**
4040
+ * Forget how the paper got here without forgetting the creases.
4041
+ *
4042
+ * Called when the stack is replaced wholesale (a new behavior, a new
4043
+ * sheet): the slots describe folds that no longer exist, but a crease is a
4044
+ * property of the paper and survives being put down and picked up.
4045
+ */
4046
+ reset(authored = this.creases) {
4047
+ this.slots.clear();
4048
+ this.recorded = [];
4049
+ this.authored = authored;
4050
+ }
4051
+ /**
4052
+ * Take on a crease set that came from outside, and work out which kind of
4053
+ * outside it was — because the two kinds want opposite things.
4054
+ *
4055
+ * It is USUALLY this tracker's own recording coming back, a frame or two
4056
+ * after `onCrease` handed it to the host. Then the slots must survive: the
4057
+ * fold that made the crease is very likely still closing, and resetting
4058
+ * its peak on the host's echo would stall the crease halfway into the
4059
+ * fold that was making it.
4060
+ *
4061
+ * But it can also be somebody EDITING the paper — a depth dragged down in
4062
+ * a panel, a shared link opened, a state's overrides settling. Then the
4063
+ * slots are the wrong story to keep. `merge` takes the deeper of two
4064
+ * creases on a line, so a recording of 20° would quietly outvote a human
4065
+ * asking for 5° and the slider would appear not to work; and it would go
4066
+ * on outvoting it, because the fold that recorded the 20 is still sitting
4067
+ * in a slot. An edit means the paper is what it is now, and the next
4068
+ * crease has to be earned by folding it again.
4069
+ *
4070
+ * The two are told apart by what we last reported. Anything else is an
4071
+ * edit, which is the safe way round: mistaking an echo for an edit costs
4072
+ * a crease that gets re-recorded on the next fold, while mistaking an edit
4073
+ * for an echo costs a control that does not work.
4074
+ */
4075
+ adopt(creases) {
4076
+ if (!same(creases, this.lastReported)) this.slots.clear();
4077
+ this.authored = creases;
4078
+ this.recorded = [];
4079
+ }
4080
+ /** Everything the sheet currently carries, authored and recorded merged. */
4081
+ get creases() {
4082
+ return merge(this.authored, this.recorded);
4083
+ }
4084
+ /**
4085
+ * Take one frame's reading. Returns true when the crease set changed by
4086
+ * enough to be worth telling anyone about.
4087
+ */
4088
+ observe(stack, set) {
4089
+ if (set <= 0) return false;
4090
+ for (let i = 0; i < stack.length; i++) {
4091
+ const instance = stack[i];
4092
+ if (instance.type !== "fold" || instance.enabled === false) continue;
4093
+ const o = instance.options;
4094
+ const magnitude = Math.abs(o.foldAngle);
4095
+ const slot = this.slots.get(i);
4096
+ if (!slot || Math.abs(slot.angle - o.angle) > CREASE_DRIFT_ANGLE || Math.abs(slot.offset - o.offset) > CREASE_DRIFT) {
4097
+ this.slots.set(i, {
4098
+ angle: o.angle,
4099
+ offset: o.offset,
4100
+ trough: magnitude,
4101
+ bestGrowth: 0,
4102
+ bestPeak: magnitude,
4103
+ sign: o.foldAngle < 0 ? -1 : 1
4104
+ });
4105
+ continue;
4106
+ }
4107
+ if (magnitude < slot.trough) slot.trough = magnitude;
4108
+ const growth = magnitude - slot.trough;
4109
+ if (growth > slot.bestGrowth) {
4110
+ slot.bestGrowth = growth;
4111
+ slot.bestPeak = magnitude;
4112
+ slot.sign = o.foldAngle < 0 ? -1 : 1;
4113
+ }
4114
+ }
4115
+ const next = [];
4116
+ for (const slot of this.slots.values()) {
4117
+ if (slot.bestGrowth < CREASE_MIN_GROWTH) continue;
4118
+ const depth = slot.sign * slot.bestPeak * set * MAX_SET;
4119
+ if (Math.abs(depth) < MIN_DEPTH) continue;
4120
+ next.push({ angle: slot.angle, offset: slot.offset, depth });
4121
+ }
4122
+ if (same(next, this.recorded)) return false;
4123
+ this.recorded = next;
4124
+ this.lastReported = this.creases;
4125
+ return true;
4126
+ }
4127
+ };
4128
+ function merge(authored, recorded) {
4129
+ const out = [];
4130
+ for (const crease of [...recorded, ...authored]) {
4131
+ const existing = out.find((c) => sameLine(c, crease));
4132
+ if (!existing) {
4133
+ out.push({ ...crease });
4134
+ continue;
4135
+ }
4136
+ if (Math.abs(crease.depth) > Math.abs(existing.depth)) existing.depth = crease.depth;
4137
+ }
4138
+ if (out.length > MAX_CREASES) {
4139
+ out.sort((a, b) => Math.abs(b.depth) - Math.abs(a.depth));
4140
+ out.length = MAX_CREASES;
4141
+ }
4142
+ return out;
4143
+ }
4144
+ function same(a, b) {
4145
+ if (a.length !== b.length) return false;
4146
+ return a.every((crease, i) => {
4147
+ const other = b[i];
4148
+ return sameLine(crease, other) && Math.abs(crease.depth - other.depth) < 0.5;
4149
+ });
4150
+ }
4151
+
4152
+ // src/surface/creases.ts
4153
+ var MAX_SHADED = 4;
4154
+ function resolveCreases(surface, creases, sheet2) {
4155
+ const out = [];
4156
+ const lines = surface.creaseLines;
4157
+ if (lines) {
4158
+ const angle = lines.angle + 90;
4159
+ const span = spanAlong(sheet2, angle);
4160
+ for (const position of lines.positions) {
4161
+ out.push({ angle, offset: (position - 0.5) * span, strength: lines.strength });
4162
+ }
4163
+ }
4164
+ for (const crease of creases) {
4165
+ out.push(creaseShading(crease));
4166
+ }
4167
+ if (out.length > MAX_SHADED) out.length = MAX_SHADED;
4168
+ return out;
4169
+ }
4170
+ function creaseShading(crease) {
4171
+ return {
4172
+ angle: crease.angle,
4173
+ offset: crease.offset,
4174
+ strength: Math.max(-1, Math.min(1, -crease.depth / (90 * MAX_SET)))
4175
+ };
4176
+ }
4177
+
3567
4178
  // src/behaviors/registry.ts
3568
4179
  var registry2 = /* @__PURE__ */ new Map();
3569
4180
  function registerBehavior(behavior) {
@@ -3653,12 +4264,22 @@ function getIdlePreset(name) {
3653
4264
  // src/physics/cloth.ts
3654
4265
  var FIXED_DT = 1 / 120;
3655
4266
  var SOLVER_ITERATIONS = 5;
4267
+ var AERO_TURBULENCE = 0.15;
4268
+ var GRAB_RADIUS = 0.05;
3656
4269
  var SLEEP_EPSILON = 1e-6;
3657
4270
  var SLEEP_FRAMES = 45;
4271
+ function grabFalloff(distance, radius) {
4272
+ const t = Math.min(1, Math.max(0, distance / radius));
4273
+ const s = 1 - t;
4274
+ return s * s * (3 - 2 * s);
4275
+ }
3658
4276
  var ClothSim = class {
3659
4277
  cols;
3660
4278
  rows;
3661
4279
  count;
4280
+ /** The sheet this grid was laid out on. Read by {@link adopt}. */
4281
+ width;
4282
+ height;
3662
4283
  positions;
3663
4284
  prev;
3664
4285
  pinned;
@@ -3669,17 +4290,47 @@ var ClothSim = class {
3669
4290
  accumulator = 0;
3670
4291
  stillFrames = 0;
3671
4292
  grabbedIndex = -1;
4293
+ /**
4294
+ * A normal per particle, refreshed once a frame.
4295
+ *
4296
+ * The sim needs these for one thing only — how much wind each part of the
4297
+ * sheet is actually catching — and it needs its OWN rather than the mesh's,
4298
+ * because the mesh's are computed after the fact by the adapter and a
4299
+ * deformer may be running over the top of them by then. Once a frame rather
4300
+ * than once a substep: paper does not turn far in eight milliseconds, and
4301
+ * this is the only part of the step that is not a constraint solve.
4302
+ */
4303
+ normals;
4304
+ /**
4305
+ * How hard each particle is held, 0..1. Zero for all but the handful under
4306
+ * a hand, so it doubles as the inverse mass the constraint solver wants:
4307
+ * a particle is as immovable as it is held.
4308
+ */
4309
+ grabWeights;
4310
+ /** Just the held ones, so nothing iterates the whole sheet to find six. */
4311
+ grabbed = [];
4312
+ /** Each held particle's offset from the one under the cursor, at grab time. */
4313
+ grabOffsets = new Float32Array(0);
4314
+ /** Where the hand was at the start of the last step, and how fast it moved. */
4315
+ grabAt = new Float32Array(3);
4316
+ grabWas = new Float32Array(3);
4317
+ grabVelocity = new Float32Array(3);
3672
4318
  /** True when the sim has settled and steps are skipped. */
3673
4319
  asleep = false;
3674
4320
  constructor(cols, rows, width, height, pins, params) {
3675
4321
  this.cols = cols;
3676
4322
  this.rows = rows;
3677
4323
  this.count = cols * rows;
4324
+ this.width = width;
4325
+ this.height = height;
3678
4326
  this.params = { ...params };
3679
4327
  this.positions = new Float32Array(this.count * 3);
3680
4328
  this.prev = new Float32Array(this.count * 3);
3681
4329
  this.pinned = new Uint8Array(this.count);
3682
4330
  this.pinTargets = new Float32Array(this.count * 3);
4331
+ this.grabWeights = new Float32Array(this.count);
4332
+ this.normals = new Float32Array(this.count * 3);
4333
+ for (let i = 0; i < this.count; i++) this.normals[i * 3 + 2] = 1;
3683
4334
  for (let r = 0; r < rows; r++) {
3684
4335
  for (let c = 0; c < cols; c++) {
3685
4336
  const i3 = (r * cols + c) * 3;
@@ -3719,6 +4370,54 @@ var ClothSim = class {
3719
4370
  }
3720
4371
  if (pins === "corner") pin(0, 0);
3721
4372
  }
4373
+ /**
4374
+ * Carry a previous sim's drape across a rebuild.
4375
+ *
4376
+ * Sheet dimensions are a GEOMETRY dependency, so changing them builds a new
4377
+ * mesh and a new sim, and a new sim starts flat — which means a draped sheet
4378
+ * snaps rigid the instant it is resized. Nothing about the physics requires
4379
+ * that; it is only that nobody had carried the state over.
4380
+ *
4381
+ * What carries is the FREE particles, scaled by how much the sheet grew:
4382
+ * the constraints' rest lengths are laid out afresh at the new size, so
4383
+ * scaling the drape by the same ratio leaves every constraint exactly as
4384
+ * violated as it was and the sim simply continues. Pinned particles keep
4385
+ * the new layout's own rest positions instead — a pin holds a CORNER, and
4386
+ * the corner is where the resized sheet says it is.
4387
+ *
4388
+ * Refused in one case only: a different grid, because there is no
4389
+ * correspondence between the two sets of particles and the nearest thing to
4390
+ * one would be a guess. Everything else carries — a resize, a change of
4391
+ * pins, a deformer appearing on top. The sheet's state belongs to the sheet,
4392
+ * and none of those is a reason to have never fallen.
4393
+ *
4394
+ * That matters most for the one that is not a resize at all: a shape
4395
+ * arriving over a simulation rebuilds the mesh (the stack has its own
4396
+ * opinion about tessellation) without touching a single thing the physics
4397
+ * knows about. Resetting there would mean the sheet snapped flat the instant
4398
+ * you tried to fold the sheet you were holding, which is the whole point of
4399
+ * being able to.
4400
+ *
4401
+ * Returns whether it took.
4402
+ */
4403
+ adopt(previous) {
4404
+ if (!previous || previous.cols !== this.cols || previous.rows !== this.rows) return false;
4405
+ const sx = previous.width > 0 ? this.width / previous.width : 1;
4406
+ const sy = previous.height > 0 ? this.height / previous.height : 1;
4407
+ const sz = (sx + sy) / 2;
4408
+ for (let i = 0; i < this.count; i++) {
4409
+ if (this.pinned[i]) continue;
4410
+ const i3 = i * 3;
4411
+ this.positions[i3] = previous.positions[i3] * sx;
4412
+ this.positions[i3 + 1] = previous.positions[i3 + 1] * sy;
4413
+ this.positions[i3 + 2] = previous.positions[i3 + 2] * sz;
4414
+ this.prev[i3] = previous.prev[i3] * sx;
4415
+ this.prev[i3 + 1] = previous.prev[i3 + 1] * sy;
4416
+ this.prev[i3 + 2] = previous.prev[i3 + 2] * sz;
4417
+ }
4418
+ this.wake();
4419
+ return true;
4420
+ }
3722
4421
  setParams(params) {
3723
4422
  let changed = false;
3724
4423
  for (const key of ["stiffness", "gravity", "wind", "floor"]) {
@@ -3734,6 +4433,69 @@ var ClothSim = class {
3734
4433
  this.asleep = false;
3735
4434
  this.stillFrames = 0;
3736
4435
  }
4436
+ /**
4437
+ * Take hold of the sheet at one particle.
4438
+ *
4439
+ * Separate from {@link grabNearest} because the particle a hand grabbed is
4440
+ * not always the particle nearest the point it touched: with a deformer
4441
+ * running over the simulation, what the pointer hit was a RENDERED vertex,
4442
+ * and the vertex it hit is the particle of the same index — the stack maps
4443
+ * a point to a point and never reorders them.
4444
+ *
4445
+ * What is taken hold of is a PATCH around that particle, not the particle
4446
+ * alone — see {@link GRAB_RADIUS}. The patch is measured across the GRID
4447
+ * rather than through space, because a hand holds a piece of the sheet and
4448
+ * keeps holding the same piece: measured through space, a fold that brought
4449
+ * a far corner near the fingers would silently add it to the grip.
4450
+ */
4451
+ grab(index) {
4452
+ this.grabbedIndex = index >= 0 && index < this.count ? index : -1;
4453
+ this.grabWeights.fill(0);
4454
+ this.grabbed = [];
4455
+ if (this.grabbedIndex < 0) {
4456
+ this.grabOffsets = new Float32Array(0);
4457
+ this.wake();
4458
+ return -1;
4459
+ }
4460
+ const cellX = this.cols > 1 ? this.width / (this.cols - 1) : this.width;
4461
+ const cellY = this.rows > 1 ? this.height / (this.rows - 1) : this.height;
4462
+ const cell = Math.max(cellX, cellY, 1e-6);
4463
+ const radius = Math.min(
4464
+ Math.max(GRAB_RADIUS, cell * 1.5),
4465
+ Math.max(cell, Math.min(this.width, this.height) * 0.2)
4466
+ );
4467
+ const centreRow = Math.floor(this.grabbedIndex / this.cols);
4468
+ const centreCol = this.grabbedIndex % this.cols;
4469
+ const spanRows = Math.ceil(radius / cellY);
4470
+ const spanCols = Math.ceil(radius / cellX);
4471
+ const offsets = [];
4472
+ const anchor = this.grabbedIndex * 3;
4473
+ for (let r = centreRow - spanRows; r <= centreRow + spanRows; r++) {
4474
+ if (r < 0 || r >= this.rows) continue;
4475
+ for (let c = centreCol - spanCols; c <= centreCol + spanCols; c++) {
4476
+ if (c < 0 || c >= this.cols) continue;
4477
+ const i = r * this.cols + c;
4478
+ if (this.pinned[i]) continue;
4479
+ const distance = Math.hypot((c - centreCol) * cellX, (r - centreRow) * cellY);
4480
+ const weight = i === this.grabbedIndex ? 1 : grabFalloff(distance, radius);
4481
+ if (weight <= 0) continue;
4482
+ this.grabWeights[i] = weight;
4483
+ this.grabbed.push(i);
4484
+ const i3 = i * 3;
4485
+ offsets.push(
4486
+ this.positions[i3] - this.positions[anchor],
4487
+ this.positions[i3 + 1] - this.positions[anchor + 1],
4488
+ this.positions[i3 + 2] - this.positions[anchor + 2]
4489
+ );
4490
+ }
4491
+ }
4492
+ this.grabOffsets = Float32Array.from(offsets);
4493
+ this.grabAt.set(this.positions.subarray(anchor, anchor + 3));
4494
+ this.grabWas.set(this.grabAt);
4495
+ this.grabVelocity.fill(0);
4496
+ this.wake();
4497
+ return this.grabbedIndex;
4498
+ }
3737
4499
  /** Nearest particle to a local-space point — the grab interface. */
3738
4500
  grabNearest(x, y, z30) {
3739
4501
  let best = -1;
@@ -3748,32 +4510,110 @@ var ClothSim = class {
3748
4510
  best = i;
3749
4511
  }
3750
4512
  }
3751
- this.grabbedIndex = best;
3752
- this.wake();
3753
- return best;
4513
+ return this.grab(best);
4514
+ }
4515
+ /** How hard one particle is being held, 0..1 — for tests and for the adapter. */
4516
+ grabWeightAt(index) {
4517
+ return this.grabWeights[index] ?? 0;
3754
4518
  }
4519
+ /** Where the fingers are now. The patch follows, each particle by its weight. */
3755
4520
  moveGrab(x, y, z30) {
3756
4521
  if (this.grabbedIndex < 0) return;
3757
- const i3 = this.grabbedIndex * 3;
3758
- this.positions[i3] = x;
3759
- this.positions[i3 + 1] = y;
3760
- this.positions[i3 + 2] = z30;
3761
- this.prev[i3] = x;
3762
- this.prev[i3 + 1] = y;
3763
- this.prev[i3 + 2] = z30;
4522
+ this.grabAt[0] = x;
4523
+ this.grabAt[1] = y;
4524
+ this.grabAt[2] = z30;
3764
4525
  this.wake();
3765
4526
  }
4527
+ /**
4528
+ * Let go — and let go at SPEED.
4529
+ *
4530
+ * This used to drop the paper dead. Every held particle had its previous
4531
+ * position overwritten with its current one on the way past, and in a verlet
4532
+ * integrator the gap between those two IS the velocity, so a sheet whipped
4533
+ * across the frame and released came to a perfect standstill and then fell
4534
+ * straight down. Whatever you did with your hand, the paper had never heard
4535
+ * of it.
4536
+ *
4537
+ * The hand's velocity is measured per second (see {@link step}) and spent
4538
+ * here, converted into the one-substep gap the integrator reads it back out
4539
+ * of. Measured per second rather than per frame because a frame is not a
4540
+ * fixed length and a substep is: throwing the same sheet at the same speed
4541
+ * must not depend on what the frame rate happened to be.
4542
+ */
3766
4543
  release() {
4544
+ for (const i of this.grabbed) {
4545
+ const i3 = i * 3;
4546
+ const held = this.grabWeights[i];
4547
+ this.prev[i3] = this.positions[i3] - this.grabVelocity[0] * FIXED_DT * held;
4548
+ this.prev[i3 + 1] = this.positions[i3 + 1] - this.grabVelocity[1] * FIXED_DT * held;
4549
+ this.prev[i3 + 2] = this.positions[i3 + 2] - this.grabVelocity[2] * FIXED_DT * held;
4550
+ }
3767
4551
  this.grabbedIndex = -1;
4552
+ this.grabbed = [];
4553
+ this.grabWeights.fill(0);
4554
+ this.grabVelocity.fill(0);
4555
+ this.wake();
3768
4556
  }
3769
4557
  step(delta) {
3770
4558
  if (this.asleep) return;
4559
+ if (this.grabbedIndex >= 0 && delta > 0) {
4560
+ for (let axis = 0; axis < 3; axis++) {
4561
+ this.grabVelocity[axis] = (this.grabAt[axis] - this.grabWas[axis]) / delta;
4562
+ }
4563
+ this.grabWas.set(this.grabAt);
4564
+ }
4565
+ this.updateNormals();
3771
4566
  this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
3772
4567
  while (this.accumulator >= FIXED_DT) {
3773
4568
  this.substep(FIXED_DT);
3774
4569
  this.accumulator -= FIXED_DT;
3775
4570
  }
3776
4571
  }
4572
+ /**
4573
+ * Which way each part of the sheet is facing, by central difference across
4574
+ * the grid.
4575
+ *
4576
+ * Clamped at the edges rather than wrapped or skipped: an edge particle
4577
+ * takes the one-sided difference, which is the same normal its neighbour
4578
+ * has, and an edge with no normal is an edge the wind cannot push.
4579
+ *
4580
+ * Which SIDE the normal points at is deliberately not worried about. The
4581
+ * force below is `n (n·v)`, a quadratic form, and that is unchanged by
4582
+ * flipping n — which is exactly right for paper, a surface with no front as
4583
+ * far as the air is concerned.
4584
+ */
4585
+ updateNormals() {
4586
+ const p = this.positions;
4587
+ const n = this.normals;
4588
+ for (let r = 0; r < this.rows; r++) {
4589
+ for (let c = 0; c < this.cols; c++) {
4590
+ const left = (r * this.cols + (c > 0 ? c - 1 : c)) * 3;
4591
+ const right = (r * this.cols + (c < this.cols - 1 ? c + 1 : c)) * 3;
4592
+ const up = ((r > 0 ? r - 1 : r) * this.cols + c) * 3;
4593
+ const down = ((r < this.rows - 1 ? r + 1 : r) * this.cols + c) * 3;
4594
+ const ux = p[right] - p[left];
4595
+ const uy = p[right + 1] - p[left + 1];
4596
+ const uz = p[right + 2] - p[left + 2];
4597
+ const vx = p[down] - p[up];
4598
+ const vy = p[down + 1] - p[up + 1];
4599
+ const vz = p[down + 2] - p[up + 2];
4600
+ const nx = vy * uz - vz * uy;
4601
+ const ny = vz * ux - vx * uz;
4602
+ const nz = vx * uy - vy * ux;
4603
+ const length = Math.sqrt(nx * nx + ny * ny + nz * nz);
4604
+ const i3 = (r * this.cols + c) * 3;
4605
+ if (length > 1e-12) {
4606
+ n[i3] = nx / length;
4607
+ n[i3 + 1] = ny / length;
4608
+ n[i3 + 2] = nz / length;
4609
+ } else {
4610
+ n[i3] = 0;
4611
+ n[i3 + 1] = 0;
4612
+ n[i3 + 2] = 0;
4613
+ }
4614
+ }
4615
+ }
4616
+ }
3777
4617
  substep(dt) {
3778
4618
  const { gravity, wind, stiffness, floor } = this.params;
3779
4619
  const p = this.positions;
@@ -3783,12 +4623,10 @@ var ClothSim = class {
3783
4623
  let maxTravel = 0;
3784
4624
  for (let i = 0; i < this.count; i++) {
3785
4625
  const i3 = i * 3;
3786
- if (this.pinned[i] || i === this.grabbedIndex) {
3787
- if (this.pinned[i]) {
3788
- p[i3] = this.pinTargets[i3];
3789
- p[i3 + 1] = this.pinTargets[i3 + 1];
3790
- p[i3 + 2] = this.pinTargets[i3 + 2];
3791
- }
4626
+ if (this.pinned[i]) {
4627
+ p[i3] = this.pinTargets[i3];
4628
+ p[i3 + 1] = this.pinTargets[i3 + 1];
4629
+ p[i3 + 2] = this.pinTargets[i3 + 2];
3792
4630
  this.prev[i3] = p[i3];
3793
4631
  this.prev[i3 + 1] = p[i3 + 1];
3794
4632
  this.prev[i3 + 2] = p[i3 + 2];
@@ -3797,20 +4635,38 @@ var ClothSim = class {
3797
4635
  const x = p[i3];
3798
4636
  const y = p[i3 + 1];
3799
4637
  const z30 = p[i3 + 2];
3800
- const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
3801
- const ax = gust2 * 0.25;
3802
- const az = gust2;
3803
4638
  const vx = (x - this.prev[i3]) * damping;
3804
4639
  const vy = (y - this.prev[i3 + 1]) * damping;
3805
4640
  const vz = (z30 - this.prev[i3 + 2]) * damping;
4641
+ const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
4642
+ const relX = gust2 * 0.25 - vx / dt;
4643
+ const relY = -vy / dt;
4644
+ const relZ = gust2 - vz / dt;
4645
+ const nx = this.normals[i3];
4646
+ const ny = this.normals[i3 + 1];
4647
+ const nz = this.normals[i3 + 2];
4648
+ const facing = nx * relX + ny * relY + nz * relZ;
4649
+ const ax = nx * facing * (1 - AERO_TURBULENCE) + gust2 * 0.25 * AERO_TURBULENCE;
4650
+ const ay = ny * facing * (1 - AERO_TURBULENCE);
4651
+ const az = nz * facing * (1 - AERO_TURBULENCE) + gust2 * AERO_TURBULENCE;
3806
4652
  this.prev[i3] = x;
3807
4653
  this.prev[i3 + 1] = y;
3808
4654
  this.prev[i3 + 2] = z30;
3809
4655
  p[i3] = x + vx + ax * dt2;
3810
- p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
4656
+ p[i3 + 1] = y + vy + (ay - gravity * 3.2) * dt2;
3811
4657
  p[i3 + 2] = z30 + vz + az * dt2;
3812
4658
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
3813
4659
  }
4660
+ for (let k = 0; k < this.grabbed.length; k++) {
4661
+ const i = this.grabbed[k];
4662
+ const i3 = i * 3;
4663
+ const k3 = k * 3;
4664
+ const held = this.grabWeights[i];
4665
+ for (let axis = 0; axis < 3; axis++) {
4666
+ const target = this.grabAt[axis] + this.grabOffsets[k3 + axis];
4667
+ p[i3 + axis] = p[i3 + axis] + (target - p[i3 + axis]) * held;
4668
+ }
4669
+ }
3814
4670
  for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
3815
4671
  for (const c of this.constraints) {
3816
4672
  const k = c.kind === 2 ? 0.25 + stiffness * 0.7 : c.kind === 1 ? 0.85 : 1;
@@ -3821,12 +4677,13 @@ var ClothSim = class {
3821
4677
  const dz = p[b3 + 2] - p[a3 + 2];
3822
4678
  const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
3823
4679
  if (dist === 0) continue;
3824
- const diff = (dist - c.rest) / dist * 0.5 * k;
3825
- const aPinned = this.pinned[c.a] || c.a === this.grabbedIndex;
3826
- const bPinned = this.pinned[c.b] || c.b === this.grabbedIndex;
3827
- if (aPinned && bPinned) continue;
3828
- const aw = aPinned ? 0 : bPinned ? 2 : 1;
3829
- const bw = bPinned ? 0 : aPinned ? 2 : 1;
4680
+ const ma = this.pinned[c.a] ? 0 : 1 - this.grabWeights[c.a];
4681
+ const mb = this.pinned[c.b] ? 0 : 1 - this.grabWeights[c.b];
4682
+ const total = ma + mb;
4683
+ if (total <= 0) continue;
4684
+ const diff = (dist - c.rest) / dist / total * k;
4685
+ const aw = ma;
4686
+ const bw = mb;
3830
4687
  p[a3] = p[a3] + dx * diff * aw;
3831
4688
  p[a3 + 1] = p[a3 + 1] + dy * diff * aw;
3832
4689
  p[a3 + 2] = p[a3 + 2] + dz * diff * aw;
@@ -3853,6 +4710,720 @@ var ClothSim = class {
3853
4710
  }
3854
4711
  };
3855
4712
 
4713
+ // src/physics/strip.ts
4714
+ var FIXED_DT2 = 1 / 120;
4715
+ var SOLVER_ITERATIONS2 = 8;
4716
+ var SLEEP_EPSILON2 = 1e-7;
4717
+ var SLEEP_FRAMES2 = 45;
4718
+ var LOOSE2 = 0.055;
4719
+ var TIGHT2 = 0.012;
4720
+ var NODES_PER_PANEL = 16;
4721
+ var MAX_NODES = 440;
4722
+ var MIN_NODES = 8;
4723
+ var REFERENCE_SEGMENT = 0.0375;
4724
+ var TANGENT_SPAN = 0.09;
4725
+ var PULL_STIFFNESS = 0.35;
4726
+ var COLLIDING_ITERATIONS = 4;
4727
+ var COLLISION_RELAXATION = 0.35;
4728
+ function safeSpiralRadius(segment, thickness) {
4729
+ return thickness > 0 ? segment * segment / (4 * thickness) : 0;
4730
+ }
4731
+ function layerThickness2(tightness) {
4732
+ return LOOSE2 - Math.min(1, Math.max(0, tightness)) * (LOOSE2 - TIGHT2);
4733
+ }
4734
+ function stripNodeCount(length, perforation) {
4735
+ const seg = Math.max(perforation, 1e-3) / NODES_PER_PANEL;
4736
+ return Math.min(MAX_NODES, Math.max(MIN_NODES, Math.round(length / seg) + 1));
4737
+ }
4738
+ var StripSim = class {
4739
+ /** Chain length. Node 0 is the deepest wrap; node `count-1` is the free tip. */
4740
+ count;
4741
+ /** Rest spacing between neighbours, in world units. */
4742
+ segment;
4743
+ /** Node centreline, y/z interleaved. x is implicit: the strip never twists. */
4744
+ pos;
4745
+ prev;
4746
+ /** Arc distance from the tip, per node. */
4747
+ arc;
4748
+ /** 1 where a perforation hinges, and the sign of the crease it remembers. */
4749
+ perforated;
4750
+ /** Bend gain correction for this chain's node spacing. See {@link REFERENCE_SEGMENT}. */
4751
+ bendScale;
4752
+ /** Half-width, in nodes, of the window the drag tangent is measured over. */
4753
+ tangentNodes;
4754
+ halfWidth;
4755
+ totalLength;
4756
+ /**
4757
+ * Paper that can never be paid out, because its inner end is glued to the
4758
+ * tube — which is true of every roll you have ever used.
4759
+ *
4760
+ * Without it the roll pays out to nothing, `firstFreeIndex` reaches 0, and
4761
+ * every node the roll was made of becomes free paper and falls: the whole
4762
+ * roll drops off its holder and lands flat on the pile. One wrap held back
4763
+ * leaves a cylinder at the core radius on the holder, which is what an
4764
+ * empty roll looks like and the closest this library can get to drawing a
4765
+ * cardboard tube out of the one sheet it has.
4766
+ */
4767
+ tubeStub;
4768
+ params;
4769
+ /** Paid-out length: how much paper is off the roll. */
4770
+ paid;
4771
+ /** Angular velocity of the roll, rad/s. The thing that coasts. */
4772
+ omega = 0;
4773
+ /**
4774
+ * Whether anything has turned the roll yet — a scroll delta or a hand.
4775
+ *
4776
+ * `tail` is the opening pose, and editing it should re-pose the roll; but
4777
+ * once the host has scrolled, `paid` is the simulation's own state and
4778
+ * re-posing would yank the paper back out of the pile.
4779
+ */
4780
+ driven = false;
4781
+ lastScroll;
4782
+ primed = false;
4783
+ /** Node held by the pointer, or -1. Kinematic while held, like a wound node. */
4784
+ grabbed = -1;
4785
+ grabY = 0;
4786
+ grabZ = 0;
4787
+ stillFrames = 0;
4788
+ accumulator = 0;
4789
+ time = 0;
4790
+ asleep = false;
4791
+ // Spatial hash for self-collision. Sized once, refilled in place — the
4792
+ // per-substep collision pass allocates nothing.
4793
+ cellOf;
4794
+ bucketStart;
4795
+ bucketItems;
4796
+ bucketFill;
4797
+ tableSize;
4798
+ constructor(length, width, params) {
4799
+ this.params = { ...params };
4800
+ this.totalLength = length;
4801
+ this.halfWidth = width / 2;
4802
+ this.count = stripNodeCount(length, params.perforation);
4803
+ this.segment = length / (this.count - 1);
4804
+ this.bendScale = Math.min(1, this.segment / REFERENCE_SEGMENT);
4805
+ this.tangentNodes = Math.max(1, Math.round(TANGENT_SPAN / this.segment));
4806
+ this.pos = new Float64Array(this.count * 2);
4807
+ this.prev = new Float64Array(this.count * 2);
4808
+ this.arc = new Float64Array(this.count);
4809
+ this.perforated = new Int8Array(this.count);
4810
+ for (let i = 0; i < this.count; i++) {
4811
+ this.arc[i] = (this.count - 1 - i) * this.segment;
4812
+ }
4813
+ const spacing = Math.max(params.perforation, this.segment * 2);
4814
+ for (let i = 0; i < this.count - 1; i++) {
4815
+ const here = Math.floor(this.arc[i] / spacing);
4816
+ const next = Math.floor(this.arc[i + 1] / spacing);
4817
+ if (here !== next) this.perforated[i] = here % 2 === 0 ? 1 : -1;
4818
+ }
4819
+ this.tableSize = 1 << Math.ceil(Math.log2(Math.max(16, this.count * 2)));
4820
+ this.cellOf = new Int32Array(this.count);
4821
+ this.bucketStart = new Int32Array(this.tableSize + 1);
4822
+ this.bucketItems = new Int32Array(this.count);
4823
+ this.bucketFill = new Int32Array(this.tableSize);
4824
+ this.tubeStub = this.stubFor(params.core);
4825
+ this.paid = Math.min(params.tail, this.usableLength);
4826
+ this.lastScroll = params.scroll;
4827
+ this.layOut();
4828
+ }
4829
+ /**
4830
+ * Local-space y the pile builds on, after centring. A host placing a
4831
+ * shadow catcher or a contact shadow needs this; it cannot derive it,
4832
+ * because the composition is offset to sit on the origin.
4833
+ */
4834
+ get floorY() {
4835
+ return -this.params.floor + this.centreOffset;
4836
+ }
4837
+ /**
4838
+ * How far the whole composition is lifted so that it straddles the origin.
4839
+ *
4840
+ * The roll's axis is the sim's origin, which would park a long drop
4841
+ * entirely below the frame — `<Paper>` and the editor both look at the
4842
+ * origin through about two world units, and neither fits a camera to the
4843
+ * content. `unroll` learned the same lesson and left a note about it: a
4844
+ * composition anchored anywhere but the origin needs a bespoke camera at
4845
+ * every call site.
4846
+ *
4847
+ * Measured against the FULL radius rather than the current one, so it is a
4848
+ * constant for a given config. Deriving it from the live radius would drift
4849
+ * the entire pile upward as the roll ran down.
4850
+ */
4851
+ get centreOffset() {
4852
+ return (this.params.floor - this.outerRadius) / 2;
4853
+ }
4854
+ /**
4855
+ * The same centring, along the axis the pile actually grows in.
4856
+ *
4857
+ * Everything here is built around the DROP LINE — the z the paper leaves
4858
+ * the roll at — and that line is not the origin and does not stay put. It
4859
+ * starts at 0 on a full roll and travels back to `core - outerRadius` as
4860
+ * the roll runs down (see {@link tangentZ}), because the spiral's centre is
4861
+ * fixed and the tangent point walks in toward it. The pile builds around
4862
+ * wherever the line has been, so a composition that ignores this is offset
4863
+ * by half that travel before a single fold has landed, and z is DEPTH —
4864
+ * the axis a fixed head-on camera has the least room in.
4865
+ *
4866
+ * A constant for a given config, for the reason {@link centreOffset} is:
4867
+ * derived from the live radius it would slide the whole composition
4868
+ * backwards as the roll emptied, which reads far worse than sitting still
4869
+ * slightly off centre. Centring the line's travel splits the difference
4870
+ * between a full roll and an empty one.
4871
+ */
4872
+ get centreShift() {
4873
+ return (this.outerRadius - this.params.core) / 2;
4874
+ }
4875
+ /** Outer radius of what is still wound. Shrinks to `core` as the roll empties. */
4876
+ get radius() {
4877
+ return rollRadius(this.totalLength - this.paid, this.params.core, layerThickness2(this.params.tightness));
4878
+ }
4879
+ /** Radius of the full roll. Fixes the spiral's centre, which must not move. */
4880
+ get outerRadius() {
4881
+ return rollRadius(this.totalLength, this.params.core, layerThickness2(this.params.tightness));
4882
+ }
4883
+ /**
4884
+ * The wrap that never leaves: one full turn around the core, but never so
4885
+ * much of a short sheet that there is nothing left to unroll.
4886
+ *
4887
+ * A method rather than a constant because `core` is a live control. It was
4888
+ * a constructor-time `readonly`, which meant dragging `core` moved the
4889
+ * radius the spiral is drawn at while `usableLength` and the end stop kept
4890
+ * answering for the old tube — the roll would run past its own floor, or
4891
+ * stop short of it, depending on which way the slider went.
4892
+ */
4893
+ stubFor(core) {
4894
+ return Math.min(Math.PI * 2 * core, this.totalLength * 0.3);
4895
+ }
4896
+ /** Paper that can actually leave the roll — everything but the glued stub. */
4897
+ get usableLength() {
4898
+ return this.totalLength - this.tubeStub;
4899
+ }
4900
+ /** How much paper is left to give. `0` is down to the tube, `1` is untouched. */
4901
+ get remaining() {
4902
+ return 1 - this.paid / this.usableLength;
4903
+ }
4904
+ /**
4905
+ * Where the paper leaves the roll: the frontmost point of the current
4906
+ * outer wrap. The spiral's CENTRE is fixed (a lesson the `roll` deformer
4907
+ * paid for — see its notes), so as the roll runs down the tangent point
4908
+ * travels back toward the holder and the strip hangs closer to it. That
4909
+ * drift is real and worth keeping.
4910
+ */
4911
+ get tangentZ() {
4912
+ return this.radius - this.outerRadius;
4913
+ }
4914
+ /** Place every node: the wound ones on the spiral, the free ones straight down. */
4915
+ layOut() {
4916
+ const firstFree = this.firstFreeIndex();
4917
+ for (let i = 0; i < this.count; i++) {
4918
+ const p = i * 2;
4919
+ if (i < firstFree) {
4920
+ this.spiralPoint(this.arc[i] - this.paid, p, this.pos);
4921
+ } else {
4922
+ this.pos[p] = -(this.paid - this.arc[i]);
4923
+ this.pos[p + 1] = this.tangentZ;
4924
+ }
4925
+ }
4926
+ this.prev.set(this.pos);
4927
+ }
4928
+ /** First node that has come off the roll. Everything below it is kinematic. */
4929
+ firstFreeIndex() {
4930
+ const free = Math.ceil(this.count - 1 - this.paid / this.segment);
4931
+ return Math.min(this.count - 1, Math.max(0, free));
4932
+ }
4933
+ /**
4934
+ * Position of a point `wound` along the spiral from the tangent point,
4935
+ * written into `out` at offset `o`.
4936
+ *
4937
+ * Angle 0 is the tangent point at the front of the roll; winding runs up
4938
+ * over the top, which is the "over" hang. The radius falls by one layer
4939
+ * per turn, so the wraps are concentric and exactly a thickness apart.
4940
+ */
4941
+ spiralPoint(wound, o, out) {
4942
+ const r0 = this.radius;
4943
+ const thickness = layerThickness2(this.params.tightness);
4944
+ const k = thickness / (Math.PI * 2);
4945
+ const phi = windAngle(Math.max(0, wound), r0, k);
4946
+ const floor = Math.max(this.params.core, safeSpiralRadius(this.segment, thickness));
4947
+ const r = Math.max(floor, r0 - k * phi);
4948
+ out[o] = r * Math.sin(phi);
4949
+ out[o + 1] = -this.outerRadius + r * Math.cos(phi);
4950
+ }
4951
+ setParams(params) {
4952
+ let changed = false;
4953
+ const tailBefore = this.params.tail;
4954
+ for (const key of [
4955
+ "scroll",
4956
+ "tightness",
4957
+ "core",
4958
+ "tail",
4959
+ "crease",
4960
+ "stiffness",
4961
+ "drag",
4962
+ "gravity",
4963
+ "floor",
4964
+ "inertia"
4965
+ ]) {
4966
+ const value = params[key];
4967
+ if (value !== void 0 && value !== this.params[key]) {
4968
+ this.params[key] = value;
4969
+ changed = true;
4970
+ }
4971
+ }
4972
+ if (params.core !== void 0) {
4973
+ this.tubeStub = this.stubFor(params.core);
4974
+ this.paid = Math.min(this.paid, this.usableLength);
4975
+ }
4976
+ if (params.tail !== void 0 && tailBefore !== this.params.tail) {
4977
+ if (!this.driven) {
4978
+ this.paid = Math.min(this.params.tail, this.usableLength);
4979
+ this.layOut();
4980
+ }
4981
+ }
4982
+ if (changed) this.wake();
4983
+ }
4984
+ wake() {
4985
+ this.asleep = false;
4986
+ this.stillFrames = 0;
4987
+ }
4988
+ /**
4989
+ * Take hold of the paper at a point, in the same y–z the vertex buffer is
4990
+ * written in — mesh-local coordinates, the lift already applied. Returns the
4991
+ * node caught, or -1 when there is no free paper to catch.
4992
+ *
4993
+ * Speaking the buffer's coordinates rather than the solver's is the whole
4994
+ * contract here: a caller has a raycast hit on the mesh and nothing else,
4995
+ * and the centring lift is an internal detail it has no way to know.
4996
+ *
4997
+ * Only paper that has left the roll can be caught — a wound node belongs to
4998
+ * the spiral, and pinning one would be pinning the roll itself.
4999
+ */
5000
+ grabNearest(y, z30) {
5001
+ const firstFree = this.firstFreeIndex();
5002
+ const localY = y - this.centreOffset;
5003
+ const localZ = z30 - this.centreShift;
5004
+ let best = -1;
5005
+ let bestDist = Infinity;
5006
+ for (let i = firstFree; i < this.count; i++) {
5007
+ const dy = this.pos[i * 2] - localY;
5008
+ const dz = this.pos[i * 2 + 1] - localZ;
5009
+ const d = dy * dy + dz * dz;
5010
+ if (d < bestDist) {
5011
+ bestDist = d;
5012
+ best = i;
5013
+ }
5014
+ }
5015
+ this.grabbed = best;
5016
+ this.grabY = localY;
5017
+ this.grabZ = localZ;
5018
+ this.wake();
5019
+ return best;
5020
+ }
5021
+ moveGrab(y, z30) {
5022
+ if (this.grabbed < 0) return;
5023
+ this.grabY = y - this.centreOffset;
5024
+ this.grabZ = z30 - this.centreShift;
5025
+ this.wake();
5026
+ }
5027
+ release() {
5028
+ this.grabbed = -1;
5029
+ }
5030
+ /** Whether a pointer currently holds the paper. */
5031
+ get held() {
5032
+ return this.grabbed >= 0;
5033
+ }
5034
+ step(delta) {
5035
+ if (this.params.scroll !== this.lastScroll) this.wake();
5036
+ if (this.asleep) return;
5037
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT2 * 4);
5038
+ while (this.accumulator >= FIXED_DT2) {
5039
+ this.substep(FIXED_DT2);
5040
+ this.accumulator -= FIXED_DT2;
5041
+ }
5042
+ }
5043
+ substep(dt) {
5044
+ this.time += dt;
5045
+ this.driveRoll(dt);
5046
+ this.pullFeed(dt);
5047
+ this.integrate(dt);
5048
+ const firstFree = this.firstFreeIndex();
5049
+ this.pinWound(firstFree);
5050
+ for (let iter = 0; iter < SOLVER_ITERATIONS2; iter++) {
5051
+ this.solveDistance(firstFree);
5052
+ this.solveBend(firstFree);
5053
+ if (iter >= SOLVER_ITERATIONS2 - COLLIDING_ITERATIONS) {
5054
+ this.solveSelfCollision(firstFree);
5055
+ this.solveFloor(firstFree);
5056
+ }
5057
+ this.pinWound(firstFree);
5058
+ }
5059
+ this.solveFloor(firstFree);
5060
+ }
5061
+ /**
5062
+ * Turn the roll. Scroll enters as an angle the roll OWES, which a leaky
5063
+ * integrator spends into angular velocity — so a steady scroll gives a
5064
+ * steady spin and a flick spikes and coasts down.
5065
+ *
5066
+ * `ΔL = R·Δθ` with the CURRENT radius, which is why the radius has to be
5067
+ * read inside the loop: a nearly-empty roll spins fast and gives up very
5068
+ * little paper for the same scroll, and that is the whole tell that a roll
5069
+ * is running out.
5070
+ */
5071
+ driveRoll(dt) {
5072
+ const scroll = this.params.scroll;
5073
+ if (!this.primed) {
5074
+ this.lastScroll = scroll;
5075
+ this.primed = true;
5076
+ }
5077
+ const impulse = (scroll - this.lastScroll) / this.outerRadius;
5078
+ this.lastScroll = scroll;
5079
+ const tau = 0.012 + this.params.inertia * 0.55;
5080
+ this.omega = this.omega * Math.exp(-dt / tau) + impulse / tau;
5081
+ if (Math.abs(this.omega) < 1e-6) this.omega = 0;
5082
+ const next = this.paid + this.radius * this.omega * dt;
5083
+ if (next !== this.paid) this.driven = true;
5084
+ this.paid = Math.min(this.usableLength, Math.max(0, next));
5085
+ if (this.paid === 0 || this.paid === this.usableLength) this.omega = 0;
5086
+ }
5087
+ /**
5088
+ * Take hold of the paper and pull, and the roll turns — the interaction the
5089
+ * real object is famous for.
5090
+ *
5091
+ * It is driven by TENSION rather than by mapping hand travel to an angle.
5092
+ * Paper does not stretch, so if the hand is further from the roll than there
5093
+ * is paper to reach it, the only way the constraint can be satisfied is for
5094
+ * the roll to give up more. That one rule gets the whole behaviour for free:
5095
+ * a slow pull feeds smoothly, a fast yank spins the roll and it carries on
5096
+ * after release, and pushing the paper back toward the roll does nothing at
5097
+ * all — which is exactly right, because slack does not rewind a roll. Only
5098
+ * scrolling up does.
5099
+ */
5100
+ pullFeed(dt) {
5101
+ if (this.grabbed < 0 || this.grabbed < this.firstFreeIndex()) return;
5102
+ const reach = Math.hypot(this.grabY, this.grabZ - this.tangentZ);
5103
+ const available = this.paid - this.arc[this.grabbed];
5104
+ const over = reach - available;
5105
+ if (over <= 1e-6) return;
5106
+ const feed = Math.min(over * PULL_STIFFNESS, this.usableLength - this.paid);
5107
+ if (feed <= 0) return;
5108
+ this.paid += feed;
5109
+ this.omega = feed / (this.radius * dt);
5110
+ }
5111
+ /**
5112
+ * Verlet with ANISOTROPIC drag. A strip of paper barely notices the air
5113
+ * when it moves along its own length and is stopped almost dead when it
5114
+ * moves broadside, which is the difference between paper that floats down
5115
+ * and a rope that drops. Damping the velocity uniformly — the obvious
5116
+ * thing, and what cloth does — gets neither.
5117
+ */
5118
+ integrate(dt) {
5119
+ const { gravity, drag } = this.params;
5120
+ const p = this.pos;
5121
+ const dt2 = dt * dt;
5122
+ const firstFree = this.firstFreeIndex();
5123
+ const across = Math.exp(-(0.6 + drag * 11) * dt);
5124
+ const along = Math.exp(-(0.4 + drag * 0.8) * dt);
5125
+ let travel = 0;
5126
+ for (let i = firstFree; i < this.count; i++) {
5127
+ const o = i * 2;
5128
+ if (i === this.grabbed) {
5129
+ p[o] = this.grabY;
5130
+ p[o + 1] = this.grabZ;
5131
+ continue;
5132
+ }
5133
+ const y = p[o];
5134
+ const z30 = p[o + 1];
5135
+ let vy = y - this.prev[o];
5136
+ let vz = z30 - this.prev[o + 1];
5137
+ const a = Math.max(firstFree, i - this.tangentNodes) * 2;
5138
+ const b = Math.min(this.count - 1, i + this.tangentNodes) * 2;
5139
+ let ty = p[b] - p[a];
5140
+ let tz = p[b + 1] - p[a + 1];
5141
+ const len = Math.hypot(ty, tz);
5142
+ if (len > 1e-9) {
5143
+ ty /= len;
5144
+ tz /= len;
5145
+ const vt = vy * ty + vz * tz;
5146
+ const ny = vy - vt * ty;
5147
+ const nz = vz - vt * tz;
5148
+ vy = vt * ty * along + ny * across;
5149
+ vz = vt * tz * along + nz * across;
5150
+ }
5151
+ this.prev[o] = y;
5152
+ this.prev[o + 1] = z30;
5153
+ const noise = Math.sin(this.time * 2.3 + i * 1.7) * 6e-5;
5154
+ p[o] = y + vy - gravity * 3.2 * dt2;
5155
+ p[o + 1] = z30 + vz + noise;
5156
+ travel = Math.max(travel, vy * vy + vz * vz);
5157
+ }
5158
+ if (this.omega === 0 && this.grabbed < 0) {
5159
+ if (travel < SLEEP_EPSILON2) {
5160
+ if (++this.stillFrames > SLEEP_FRAMES2) this.asleep = true;
5161
+ } else this.stillFrames = 0;
5162
+ } else this.stillFrames = 0;
5163
+ }
5164
+ /** Wound nodes are the roll's, not the solver's: rewrite them every pass. */
5165
+ pinWound(firstFree) {
5166
+ for (let i = 0; i < firstFree; i++) {
5167
+ this.spiralPoint(this.arc[i] - this.paid, i * 2, this.pos);
5168
+ }
5169
+ const tz = this.tangentZ;
5170
+ for (let i = firstFree; i < Math.min(this.count, firstFree + 2); i++) {
5171
+ if (i === this.grabbed) continue;
5172
+ const o = i * 2;
5173
+ this.pos[o + 1] = this.pos[o + 1] + (tz - this.pos[o + 1]) * 0.5;
5174
+ }
5175
+ if (this.grabbed >= firstFree) {
5176
+ this.pos[this.grabbed * 2] = this.grabY;
5177
+ this.pos[this.grabbed * 2 + 1] = this.grabZ;
5178
+ }
5179
+ }
5180
+ /** Paper does not stretch. Distance constraints run at full strength. */
5181
+ solveDistance(firstFree) {
5182
+ const p = this.pos;
5183
+ const rest = this.segment;
5184
+ for (let i = Math.max(0, firstFree - 1); i < this.count - 1; i++) {
5185
+ const a = i * 2;
5186
+ const b = (i + 1) * 2;
5187
+ const dy = p[b] - p[a];
5188
+ const dz = p[b + 1] - p[a + 1];
5189
+ const dist = Math.hypot(dy, dz);
5190
+ if (dist < 1e-9) continue;
5191
+ const diff = (dist - rest) / dist * 0.5;
5192
+ const aFixed = i < firstFree || i === this.grabbed;
5193
+ const bFixed = i + 1 === this.grabbed;
5194
+ if (aFixed && bFixed) continue;
5195
+ const aw = aFixed ? 0 : bFixed ? 2 : 1;
5196
+ const bw = bFixed ? 0 : aFixed ? 2 : 1;
5197
+ p[a] = p[a] + dy * diff * aw;
5198
+ p[a + 1] = p[a + 1] + dz * diff * aw;
5199
+ p[b] = p[b] - dy * diff * bw;
5200
+ p[b + 1] = p[b + 1] - dz * diff * bw;
5201
+ }
5202
+ }
5203
+ /**
5204
+ * The bend constraint, and the reason this is not a rope preset.
5205
+ *
5206
+ * Every joint wants to be straight. A joint AT A PERFORATION wants it far
5207
+ * less, and may want a slight fold instead — those are the hinges the pile
5208
+ * folds at, and the alternating sign is what turns a heap into an
5209
+ * accordion. Uniform stiffness gives a coil; no stiffness gives a rope.
5210
+ */
5211
+ solveBend(firstFree) {
5212
+ const p = this.pos;
5213
+ const { stiffness, crease } = this.params;
5214
+ const start = Math.max(firstFree + 1, 1);
5215
+ for (let i = start; i < this.count - 1; i++) {
5216
+ const hinge = this.perforated[i];
5217
+ const k = (hinge !== 0 ? stiffness * 0.06 : stiffness) * 0.45 * this.bendScale;
5218
+ if (k <= 0) continue;
5219
+ const a = (i - 1) * 2;
5220
+ const m = i * 2;
5221
+ const b = (i + 1) * 2;
5222
+ let ty = (p[a] + p[b]) * 0.5;
5223
+ let tz = (p[a + 1] + p[b + 1]) * 0.5;
5224
+ if (hinge !== 0 && crease > 0) {
5225
+ let cy = p[b] - p[a];
5226
+ let cz = p[b + 1] - p[a + 1];
5227
+ const len = Math.hypot(cy, cz);
5228
+ if (len > 1e-9) {
5229
+ cy /= len;
5230
+ cz /= len;
5231
+ const bow = crease * this.segment * 0.35 * hinge;
5232
+ ty += -cz * bow;
5233
+ tz += cy * bow;
5234
+ }
5235
+ }
5236
+ const dy = (ty - p[m]) * k;
5237
+ const dz = (tz - p[m + 1]) * k;
5238
+ if (i !== this.grabbed) {
5239
+ p[m] = p[m] + dy;
5240
+ p[m + 1] = p[m + 1] + dz;
5241
+ }
5242
+ if (i - 1 >= firstFree && i - 1 !== this.grabbed) {
5243
+ p[a] = p[a] - dy * 0.5;
5244
+ p[a + 1] = p[a + 1] - dz * 0.5;
5245
+ }
5246
+ if (i + 1 !== this.grabbed) {
5247
+ p[b] = p[b] - dy * 0.5;
5248
+ p[b + 1] = p[b + 1] - dz * 0.5;
5249
+ }
5250
+ }
5251
+ }
5252
+ /**
5253
+ * Folds stack on each other instead of passing through. Without this the
5254
+ * pile collapses into a single flat line on the floor and there is nothing
5255
+ * to look at, which is the entire reason the preset exists.
5256
+ *
5257
+ * **Segment against segment, not node against node.** The obvious version
5258
+ * puts a sphere on every node and pushes overlapping pairs apart, and it
5259
+ * leaks: paper is thinner than the chain is finely cut — the layer gap here
5260
+ * is 0.027 against a node spacing of 0.037 — so those spheres do not touch
5261
+ * each other, and the chain is a string of beads with gaps between them
5262
+ * rather than a continuous tube. Another fold threads straight through a
5263
+ * gap, and once it is through, the point test pushes it out the far side
5264
+ * instead of back. It shows up exactly as a sheet edge buried in a surface.
5265
+ *
5266
+ * Testing the SEGMENTS closes the gaps, because a segment is the whole
5267
+ * length of paper between two nodes and not just its ends. The cost is a
5268
+ * closest-approach solve per candidate pair instead of a subtraction, which
5269
+ * is why the hash cell is sized to `segment + d`: a pair that can possibly
5270
+ * touch has its midpoints inside that, so a 3×3 neighbourhood is a complete
5271
+ * search rather than a hopeful one.
5272
+ *
5273
+ * Spatial hash over preallocated arrays, refilled by counting sort — no
5274
+ * allocation, and it does not degrade when the whole strip lands in one
5275
+ * cell, which is exactly what a pile IS.
5276
+ */
5277
+ solveSelfCollision(firstFree) {
5278
+ this.freeFrom = firstFree;
5279
+ const p = this.pos;
5280
+ const d = Math.max(layerThickness2(this.params.tightness), 1e-4);
5281
+ const n = this.count;
5282
+ const last = n - 2;
5283
+ if (last < firstFree + 2) return;
5284
+ const cell = this.segment + d;
5285
+ const inv = 1 / cell;
5286
+ const table = this.tableSize;
5287
+ const mask = table - 1;
5288
+ const starts = this.bucketStart;
5289
+ const items = this.bucketItems;
5290
+ const placed = this.bucketFill;
5291
+ starts.fill(0);
5292
+ placed.fill(0);
5293
+ for (let i = firstFree; i <= last; i++) {
5294
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
5295
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
5296
+ const h = (Math.floor(my * inv) * 92837111 ^ Math.floor(mz * inv) * 689287499) >>> 0 & mask;
5297
+ this.cellOf[i] = h;
5298
+ starts[h + 1]++;
5299
+ }
5300
+ for (let c = 0; c < table; c++) starts[c + 1] += starts[c];
5301
+ for (let i = firstFree; i <= last; i++) {
5302
+ const h = this.cellOf[i];
5303
+ items[starts[h] + placed[h]++] = i;
5304
+ }
5305
+ for (let i = firstFree; i <= last; i++) {
5306
+ const my = (p[i * 2] + p[(i + 1) * 2]) * 0.5;
5307
+ const mz = (p[i * 2 + 1] + p[(i + 1) * 2 + 1]) * 0.5;
5308
+ const cy = Math.floor(my * inv);
5309
+ const cz = Math.floor(mz * inv);
5310
+ for (let oy = -1; oy <= 1; oy++) {
5311
+ for (let oz = -1; oz <= 1; oz++) {
5312
+ const h = ((cy + oy) * 92837111 ^ (cz + oz) * 689287499) >>> 0 & mask;
5313
+ const end = starts[h + 1];
5314
+ for (let e = starts[h]; e < end; e++) {
5315
+ const j = items[e];
5316
+ if (j >= i + 2) this.separate(i, j, d);
5317
+ }
5318
+ }
5319
+ }
5320
+ }
5321
+ }
5322
+ /**
5323
+ * Push segments `(i, i+1)` and `(j, j+1)` apart to `d`, if they are closer.
5324
+ *
5325
+ * Closest approach between two 2D segments, then the correction shared over
5326
+ * the four endpoints by how near each is to the touching point — so a fold
5327
+ * caught at its middle moves bodily, and one grazed at its tip barely
5328
+ * pivots. Wound and held nodes are the roll's and the hand's, so they take
5329
+ * none of it and their partner takes the lot.
5330
+ */
5331
+ separate(i, j, d) {
5332
+ const p = this.pos;
5333
+ const a = i * 2;
5334
+ const b = (i + 1) * 2;
5335
+ const c = j * 2;
5336
+ const e = (j + 1) * 2;
5337
+ const uy = p[b] - p[a];
5338
+ const uz = p[b + 1] - p[a + 1];
5339
+ const vy = p[e] - p[c];
5340
+ const vz = p[e + 1] - p[c + 1];
5341
+ const wy = p[a] - p[c];
5342
+ const wz = p[a + 1] - p[c + 1];
5343
+ const uu = uy * uy + uz * uz;
5344
+ const vv = vy * vy + vz * vz;
5345
+ if (uu < 1e-12 || vv < 1e-12) return;
5346
+ const uv = uy * vy + uz * vz;
5347
+ const uw = uy * wy + uz * wz;
5348
+ const vw = vy * wy + vz * wz;
5349
+ const denom = uu * vv - uv * uv;
5350
+ let s = denom > 1e-12 ? (uv * vw - vv * uw) / denom : 0;
5351
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
5352
+ let t = (uv * s + vw) / vv;
5353
+ if (t < 0) {
5354
+ t = 0;
5355
+ s = -uw / uu;
5356
+ } else if (t > 1) {
5357
+ t = 1;
5358
+ s = (uv - uw) / uu;
5359
+ }
5360
+ s = s < 0 ? 0 : s > 1 ? 1 : s;
5361
+ let ny = p[c] + vy * t - (p[a] + uy * s);
5362
+ let nz = p[c + 1] + vz * t - (p[a + 1] + uz * s);
5363
+ let dist = Math.hypot(ny, nz);
5364
+ if (dist >= d) return;
5365
+ if (dist < 1e-9) {
5366
+ ny = -uz;
5367
+ nz = uy;
5368
+ dist = Math.hypot(ny, nz);
5369
+ if (dist < 1e-12) return;
5370
+ ny = -ny;
5371
+ nz = -nz;
5372
+ }
5373
+ const push = (d - dist) / dist * COLLISION_RELAXATION;
5374
+ const gy = ny * push;
5375
+ const gz = nz * push;
5376
+ const ki = 0.5 / ((1 - s) * (1 - s) + s * s);
5377
+ const kj = 0.5 / ((1 - t) * (1 - t) + t * t);
5378
+ this.nudge(i, -gy * (1 - s) * ki, -gz * (1 - s) * ki);
5379
+ this.nudge(i + 1, -gy * s * ki, -gz * s * ki);
5380
+ this.nudge(j, gy * (1 - t) * kj, gz * (1 - t) * kj);
5381
+ this.nudge(j + 1, gy * t * kj, gz * t * kj);
5382
+ }
5383
+ /** First free node for the substep in flight — `nudge` runs in the innermost
5384
+ * collision loop and must not recompute it per call. */
5385
+ freeFrom = 0;
5386
+ /** Move a node, unless the roll or the hand owns it. */
5387
+ nudge(i, dy, dz) {
5388
+ if (i === this.grabbed || i < this.freeFrom) return;
5389
+ this.pos[i * 2] = this.pos[i * 2] + dy;
5390
+ this.pos[i * 2 + 1] = this.pos[i * 2 + 1] + dz;
5391
+ }
5392
+ /** Restitution 0, friction high. Paper lands and stays; it does not slide. */
5393
+ solveFloor(firstFree) {
5394
+ const p = this.pos;
5395
+ const floor = -this.params.floor;
5396
+ for (let i = firstFree; i < this.count; i++) {
5397
+ const o = i * 2;
5398
+ if (i === this.grabbed || p[o] >= floor) continue;
5399
+ p[o] = floor;
5400
+ this.prev[o] = floor;
5401
+ this.prev[o + 1] = this.prev[o + 1] + (p[o + 1] - this.prev[o + 1]) * 0.92;
5402
+ }
5403
+ }
5404
+ /**
5405
+ * Write the chain into a 2×N quad strip, in PlaneGeometry vertex order:
5406
+ * row-major, top row first, x left→right. Row `i` is node `i`, so the
5407
+ * content texture runs down the strip and folds with it.
5408
+ */
5409
+ writeInto(out) {
5410
+ const lift = this.centreOffset;
5411
+ const shift = this.centreShift;
5412
+ for (let i = 0; i < this.count; i++) {
5413
+ const o = i * 2;
5414
+ const y = this.pos[o] + lift;
5415
+ const z30 = this.pos[o + 1] + shift;
5416
+ const v = i * 6;
5417
+ out[v] = -this.halfWidth;
5418
+ out[v + 1] = y;
5419
+ out[v + 2] = z30;
5420
+ out[v + 3] = this.halfWidth;
5421
+ out[v + 4] = y;
5422
+ out[v + 5] = z30;
5423
+ }
5424
+ }
5425
+ };
5426
+
3856
5427
  // src/surface/PaperMaterial.tsx
3857
5428
  var THREE10 = __toESM(require("three"), 1);
3858
5429
  var import_react9 = require("react");
@@ -3934,6 +5505,7 @@ function translucencyUniforms(translucency, lighting) {
3934
5505
  }
3935
5506
 
3936
5507
  // src/surface/compose.ts
5508
+ var SHADED_CREASE = 0.35;
3937
5509
  var VERTEX = (
3938
5510
  /* glsl */
3939
5511
  `
@@ -3950,6 +5522,89 @@ var HELPERS = (
3950
5522
  `
3951
5523
  varying vec2 vPaperUv;
3952
5524
  uniform float uBackDarken;
5525
+ uniform vec2 uSheetSize;
5526
+
5527
+ /**
5528
+ * Where this fragment is on the sheet, in the sheet's OWN local space \u2014
5529
+ * the same coordinates the deformers displace, centred on the sheet.
5530
+ *
5531
+ * Every effect below measures in these rather than in UV, and the difference
5532
+ * is not cosmetic. UV divides the sheet's aspect out, so a 1.2 x 1.5 sheet is
5533
+ * a unit square as far as the shader is concerned: fibre drawn round comes out
5534
+ * stretched, a tear bites deeper into the short edge than the long one, and a
5535
+ * crease line scored at 45 degrees renders at 51. Worse, all three change when
5536
+ * the sheet is RESIZED, which makes the paper's own material a function of how
5537
+ * big the piece is. Grain is a property of the stock and a crease is a broken
5538
+ * fibre; neither knows how large a sheet it was cut from.
5539
+ */
5540
+ vec2 plLocal() {
5541
+ return (vPaperUv - 0.5) * uSheetSize;
5542
+ }
5543
+
5544
+ /**
5545
+ * The paper's relief, in world units above the sheet the mesh describes.
5546
+ *
5547
+ * Accumulated by whichever effects have a shape as well as a colour, and
5548
+ * spent once at the end of main by {@link plPerturb}. One shared field rather
5549
+ * than a perturbation per effect, because two effects that both tilt the
5550
+ * surface tilt it TOGETHER \u2014 a crease across a grained sheet is one surface,
5551
+ * not a crease lit on top of a grain lit on top of the paper.
5552
+ */
5553
+ float plHeight;
5554
+
5555
+ /**
5556
+ * The relief, turned into the normal the lighting actually runs on.
5557
+ *
5558
+ * This is the change that makes the surface effects respond to light at all.
5559
+ * They used to be painted: a crease multiplied a grey band into the albedo
5560
+ * and added a fixed white sheen beside it, so the mark looked identical from
5561
+ * every angle and under every rig, and turning the sheet under the key light
5562
+ * did nothing to it. Real creased paper is two facets meeting at a line \u2014
5563
+ * swing it and the crease flips from a dark line to a bright one. Only a
5564
+ * normal can do that, so now the effects describe a HEIGHT and the standard
5565
+ * material lights it.
5566
+ *
5567
+ * The maths is Mikkelsen's surface-gradient bump, which is what three's own
5568
+ * perturbNormalArb implements, with one deliberate difference: three
5569
+ * normalises the screen-space position derivatives, which makes a bump map
5570
+ * look the same at any scale and is the right call for a texture. Ours is a
5571
+ * real depth in world units \u2014 a crease is as deep as it is however close you
5572
+ * stand \u2014 so the raw derivatives stay, and the ratio between them and the
5573
+ * height's is a true surface slope.
5574
+ *
5575
+ * Analytic height plus screen derivatives also anti-aliases itself for free:
5576
+ * as a crease shrinks below a pixel the derivative flattens and the mark
5577
+ * fades, rather than crawling.
5578
+ */
5579
+ vec3 plPerturb(vec3 n, float height) {
5580
+ vec2 dH = vec2(dFdx(height), dFdy(height));
5581
+ if (dH.x == 0.0 && dH.y == 0.0) return n;
5582
+ // View-space position: the varying is its negation, by three's convention.
5583
+ vec3 sigmaX = dFdx(-vViewPosition);
5584
+ vec3 sigmaY = dFdy(-vViewPosition);
5585
+ vec3 r1 = cross(sigmaY, n);
5586
+ vec3 r2 = cross(n, sigmaX);
5587
+ float det = dot(sigmaX, r1) * (gl_FrontFacing ? 1.0 : -1.0);
5588
+ if (abs(det) < 1e-12) return n;
5589
+ vec3 grad = sign(det) * (dH.x * r1 + dH.y * r2);
5590
+ return normalize(abs(det) * n - grad);
5591
+ }
5592
+
5593
+ /**
5594
+ * A gaussian bell of unit width, pre-filtered against this fragment's own
5595
+ * footprint.
5596
+ *
5597
+ * Convolving a gaussian with the pixel broadens it and flattens it by the
5598
+ * same factor, which conserves the integral: a crease seen from across the
5599
+ * room dims instead of breaking into a dotted line. s is the distance
5600
+ * across the feature in units of its own width, so a caller only ever has to
5601
+ * decide how wide the thing is.
5602
+ */
5603
+ float plBell(float s) {
5604
+ float px = fwidth(s);
5605
+ float widen = sqrt(1.0 + px * px);
5606
+ return exp(-(s * s) / (widen * widen)) / widen;
5607
+ }
3953
5608
 
3954
5609
  float plHash(vec2 p) {
3955
5610
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
@@ -3990,12 +5645,54 @@ var GRAIN_CHUNK = (
3990
5645
  uniform float uGrainAmount;
3991
5646
  uniform float uGrainBanding;
3992
5647
 
5648
+ /**
5649
+ * Fibre density, per world unit.
5650
+ *
5651
+ * Per WORLD UNIT and not per UV, which is the whole fix: the fibre in a sheet
5652
+ * is the stock's, and it does not get coarser because someone cut a bigger
5653
+ * piece or stretch oval because the piece is taller than it is wide.
5654
+ *
5655
+ * The number is carried over from when it was a UV frequency, so a sheet one
5656
+ * world unit wide is unchanged across its width. Its height is not, and that
5657
+ * is the point: a 1.4-tall sheet used to fit the same 240 cycles into a
5658
+ * longer span and its fibre ran visibly coarser the other way.
5659
+ */
5660
+ const float PL_FIBRE = 240.0;
5661
+
5662
+ /**
5663
+ * The coarser structure underneath it \u2014 paper's tooth, the part that has a
5664
+ * SHAPE and not just a colour.
5665
+ *
5666
+ * Separate from the fibre, and much lower, for a reason worth writing down:
5667
+ * the relief is differentiated in screen space, and a field at the fibre's own
5668
+ * frequency is a few pixels per cycle at any sane viewing distance, so its
5669
+ * derivative is noise and the sheet would sparkle. The tooth is safely above
5670
+ * the sampling rate, and it is the scale at which paper actually catches a
5671
+ * raking light anyway.
5672
+ */
5673
+ const float PL_TOOTH = 70.0;
5674
+
5675
+ /**
5676
+ * How far the tooth stands proud, in world units, at full grain.
5677
+ *
5678
+ * Real paper's surface relief is tens of microns. Against a sheet whose width
5679
+ * is one world unit \u2014 call it A4 \u2014 0.00035 is about 70 microns, and at the
5680
+ * tooth's wavelength that is a surface tilting by four degrees or so. Enough
5681
+ * to break a specular highlight into paper, not enough to look pebbled.
5682
+ */
5683
+ const float PL_TOOTH_RELIEF = 0.00035;
5684
+
3993
5685
  void plGrain(inout vec4 color, inout float rough) {
3994
- float fiber = plFbm(vPaperUv * 240.0);
3995
- float fleck = plNoise(vPaperUv * 900.0);
5686
+ vec2 local = plLocal();
5687
+ float fiber = plFbm(local * PL_FIBRE);
5688
+ float fleck = plNoise(local * (PL_FIBRE * 3.75));
3996
5689
  float g = mix(0.5, fiber * 0.75 + fleck * 0.25, uGrainAmount);
3997
5690
  color.rgb *= 0.92 + g * 0.16;
3998
5691
  rough = clamp(rough + (g - 0.5) * uGrainAmount * 0.35, 0.0, 1.0);
5692
+ // The tooth, handed to the lighting rather than drawn. A single octave: the
5693
+ // relief only needs the scale the eye reads as texture, and the fbm above
5694
+ // is already carrying everything finer as colour.
5695
+ plHeight += (plNoise(local * PL_TOOTH) - 0.5) * PL_TOOTH_RELIEF * uGrainAmount;
3999
5696
  // Thermal-printer banding: faint horizontal density stripes.
4000
5697
  if (uGrainBanding > 0.0) {
4001
5698
  float band = sin(vPaperUv.y * 700.0) * 0.5 + 0.5;
@@ -4010,18 +5707,33 @@ var DECKLE_CHUNK = (
4010
5707
  uniform vec4 uDeckleEdges; // top, right, bottom, left
4011
5708
  uniform float uDeckleRoughness;
4012
5709
 
5710
+ /** Gnaw frequency along a torn edge, per world unit \u2014 see {@link plLocal}. */
5711
+ const float PL_DECKLE_GNAW = 26.0;
5712
+
4013
5713
  void plDeckle(inout vec4 color) {
4014
5714
  // Distance to each selected edge, gnawed by low-frequency noise.
4015
- float depth = 0.012 + uDeckleRoughness * 0.05;
5715
+ //
5716
+ // The depth is in world units, taken against the sheet's mean dimension.
5717
+ // Against the MEAN rather than each edge's own span, which is what UV
5718
+ // amounted to: one roughness used to bite a third deeper into the short
5719
+ // edges of a 1 x 1.4 sheet than the long ones, for no reason anybody chose.
5720
+ //
5721
+ // Still proportional to the sheet rather than absolute, which is a decision
5722
+ // and not an oversight. A real deckle is a fibre length and would be the
5723
+ // same depth on any size of sheet; roughness is a 0..1 knob someone types,
5724
+ // and an absolute one would vanish on a poster and swallow a stamp.
5725
+ float depth = (0.012 + uDeckleRoughness * 0.05) * (uSheetSize.x + uSheetSize.y) * 0.5;
4016
5726
  float tear = 1.0;
4017
5727
  float fiberBand = 0.0;
4018
5728
  vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
4019
5729
  vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
5730
+ vec4 distScale = vec4(uSheetSize.y, uSheetSize.x, uSheetSize.y, uSheetSize.x);
5731
+ vec4 alongScale = vec4(uSheetSize.x, uSheetSize.y, uSheetSize.x, uSheetSize.y);
4020
5732
  for (int e = 0; e < 4; e++) {
4021
5733
  if (uDeckleEdges[e] < 0.5) continue;
4022
- float n = plFbm(vec2(alongs[e] * 26.0, float(e) * 7.31)) - 0.5;
5734
+ float n = plFbm(vec2(alongs[e] * alongScale[e] * PL_DECKLE_GNAW, float(e) * 7.31)) - 0.5;
4023
5735
  float boundary = depth * (0.55 + n * 1.6);
4024
- float d = dists[e] - boundary;
5736
+ float d = dists[e] * distScale[e] - boundary;
4025
5737
  tear = min(tear, step(0.0, d));
4026
5738
  // Lightened fiber band just inside the tear.
4027
5739
  fiberBand = max(fiberBand, smoothstep(depth * 1.4, 0.0, d) * step(0.0, d));
@@ -4034,23 +5746,55 @@ void plDeckle(inout vec4 color) {
4034
5746
  var CREASE_CHUNK = (
4035
5747
  /* glsl */
4036
5748
  `
4037
- uniform float uCreaseAngle;
4038
- uniform float uCreaseStrength;
4039
- uniform float uCreasePositions[4];
5749
+ uniform float uCreaseAngles[4];
5750
+ uniform float uCreaseStrengths[4];
5751
+ uniform float uCreaseOffsets[4];
5752
+ uniform float uCreaseWidth;
4040
5753
  uniform int uCreaseCount;
4041
5754
 
5755
+ /**
5756
+ * Peak tilt of a crease's own facets, as a slope.
5757
+ *
5758
+ * A gaussian groove of amplitude A and width w reaches a maximum slope of
5759
+ * about 0.86 A/w, so an amplitude of 0.55 w peaks near 25 degrees \u2014 steep
5760
+ * enough that turning the sheet visibly flips the line from dark to bright,
5761
+ * shallow enough that it never reads as a fold in its own right. Held as a
5762
+ * SLOPE rather than a depth because that is the quantity the lighting
5763
+ * responds to, and the only one that stays honest when the width changes.
5764
+ */
5765
+ const float PL_CREASE_TILT = 0.55;
5766
+
5767
+ /** How much grime a crease traps, at full strength. */
5768
+ const float PL_CREASE_SOIL = 0.1;
5769
+
4042
5770
  void plCrease(inout vec4 color, inout float rough) {
4043
- vec2 dir = vec2(cos(uCreaseAngle), sin(uCreaseAngle));
4044
- // Coordinate across the crease lines (0..1 over the sheet).
4045
- float t = dot(vPaperUv - 0.5, vec2(-dir.y, dir.x)) + 0.5;
5771
+ vec2 p = plLocal();
4046
5772
  for (int i = 0; i < 4; i++) {
4047
5773
  if (i >= uCreaseCount) break;
4048
- float d = abs(t - uCreasePositions[i]);
4049
- float shadow = smoothstep(0.014, 0.0, d);
4050
- float sheen = smoothstep(0.02, 0.006, d) - smoothstep(0.006, 0.0, d);
4051
- color.rgb *= 1.0 - shadow * uCreaseStrength * 0.28;
4052
- color.rgb += sheen * uCreaseStrength * 0.05;
4053
- rough = clamp(rough + shadow * uCreaseStrength * 0.2, 0.0, 1.0);
5774
+ vec2 dir = vec2(cos(uCreaseAngles[i]), sin(uCreaseAngles[i]));
5775
+ // The identical measurement the fold deformer displaces by: signed
5776
+ // distance across the line, in the sheet's own space. Shading and
5777
+ // geometry cannot place a crease differently when the number they place
5778
+ // it by is the same number.
5779
+ float s = (dot(p, dir) - uCreaseOffsets[i]) / uCreaseWidth;
5780
+ float strength = uCreaseStrengths[i];
5781
+ float bell = plBell(s);
5782
+
5783
+ // The relief. This is the fine burnished line where the fibres broke, and
5784
+ // it is deliberately narrower than the hinge the fold deformer bends
5785
+ // over: the mesh carries the wide bend, the shader carries the crease
5786
+ // inside it, and the two add up instead of competing. Signed, so a
5787
+ // mountain stands proud and a valley cuts in \u2014 the same crease read from
5788
+ // the other side of the sheet is the other one.
5789
+ plHeight += strength * uCreaseWidth * PL_CREASE_TILT * bell;
5790
+
5791
+ // What is left for the albedo once the lighting is doing the work: a
5792
+ // crease collects dirt and its broken fibres scatter wider. The grey band
5793
+ // and the painted-on sheen that used to live here were standing in for a
5794
+ // normal, and there is one now.
5795
+ float mark = bell * abs(strength);
5796
+ color.rgb *= 1.0 - mark * PL_CREASE_SOIL;
5797
+ rough = clamp(rough + mark * 0.3, 0.0, 1.0);
4054
5798
  }
4055
5799
  }
4056
5800
  `
@@ -4062,7 +5806,6 @@ uniform vec4 uPerfEdges; // top, right, bottom, left enabled
4062
5806
  uniform vec4 uPerfTorn; // 1 = ripped-through profile, 0 = clean punches
4063
5807
  uniform float uPerfRadius; // world units
4064
5808
  uniform float uPerfSpacing;
4065
- uniform vec2 uSheetSize;
4066
5809
 
4067
5810
  void plPerforation(inout vec4 color) {
4068
5811
  // Per-edge distance/along coordinates, converted from UV to world units so
@@ -4113,11 +5856,10 @@ void plAging(inout vec4 color) {
4113
5856
  }
4114
5857
  `
4115
5858
  );
4116
- function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
5859
+ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio", creases = resolveCreases(surface, [], sheet2)) {
4117
5860
  const grain = surface.grain ?? stock.defaultSurface.grain;
4118
5861
  const aging = surface.aging ?? stock.defaultSurface.aging;
4119
5862
  const deckle = surface.deckle;
4120
- const creases = surface.creaseLines;
4121
5863
  const perforation = surface.perforation;
4122
5864
  const banding = stock.banding;
4123
5865
  const showThrough = stock.adhesive ? 0 : surface.showThrough ?? stock.showThrough;
@@ -4130,6 +5872,9 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
4130
5872
  value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
4131
5873
  },
4132
5874
  uStockColor: { value: new THREE9.Color(stock.color) },
5875
+ // Always present, not just when something asks for it: every effect that
5876
+ // measures anything measures in the sheet's own space now — see plLocal.
5877
+ uSheetSize: { value: new THREE9.Vector2(sheet2.width, sheet2.height) },
4133
5878
  uOpacity: { value: stock.opacity },
4134
5879
  uShowThrough: { value: showThrough },
4135
5880
  // Always compiled in: the shader early-outs at zero translucency, which
@@ -4162,21 +5907,22 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
4162
5907
  };
4163
5908
  uniforms.uPerfRadius = { value: perforation.holeRadius };
4164
5909
  uniforms.uPerfSpacing = { value: perforation.spacing };
4165
- uniforms.uSheetSize = { value: new THREE9.Vector2(sheet2.width, sheet2.height) };
4166
5910
  }
4167
- if (creases) {
5911
+ if (creases.length > 0) {
4168
5912
  chunks.push(CREASE_CHUNK);
4169
5913
  calls.push("plCrease(csm_DiffuseColor, csm_Roughness);");
4170
- uniforms.uCreaseAngle = { value: creases.angle * Math.PI / 180 };
4171
- uniforms.uCreaseStrength = { value: creases.strength };
4172
- uniforms.uCreasePositions = { value: padPositions(creases.positions) };
4173
- uniforms.uCreaseCount = { value: Math.min(creases.positions.length, 4) };
5914
+ uniforms.uCreaseAngles = { value: pad(creases.map((c) => c.angle * Math.PI / 180)) };
5915
+ uniforms.uCreaseStrengths = { value: pad(creases.map((c) => c.strength)) };
5916
+ uniforms.uCreaseOffsets = { value: pad(creases.map((c) => c.offset)) };
5917
+ uniforms.uCreaseWidth = { value: CREASE_RADIUS * SHADED_CREASE };
5918
+ uniforms.uCreaseCount = { value: Math.min(creases.length, 4) };
4174
5919
  }
4175
5920
  if (aging !== void 0) {
4176
5921
  chunks.push(AGING_CHUNK);
4177
5922
  calls.push("plAging(csm_DiffuseColor);");
4178
5923
  uniforms.uAgingAmount = { value: aging };
4179
5924
  }
5925
+ const relief = grain !== void 0 || creases.length > 0;
4180
5926
  const frontExpr = maps.hasFrontMap ? "texture2D(uFrontMap, vPaperUv).rgb" : "uStockColor";
4181
5927
  const backBaseExpr = stock.adhesive ? "vec3(0.965, 0.96, 0.945)" : maps.hasBackMap ? "texture2D(uBackMap, vec2(1.0 - vPaperUv.x, vPaperUv.y)).rgb" : "uStockColor";
4182
5928
  const fragmentShader = (
@@ -4191,6 +5937,7 @@ ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
4191
5937
  ${TRANSLUCENCY_FRAGMENT}
4192
5938
  ${chunks.join("\n")}
4193
5939
  void main() {
5940
+ plHeight = 0.0;
4194
5941
  vec3 front = ${frontExpr};
4195
5942
  if (gl_FrontFacing) {
4196
5943
  csm_DiffuseColor = vec4(front, uOpacity);
@@ -4199,6 +5946,7 @@ void main() {
4199
5946
  csm_DiffuseColor = vec4(backBase * mix(vec3(1.0), front, uShowThrough), uOpacity);
4200
5947
  }
4201
5948
  ${calls.join("\n ")}
5949
+ ${relief ? " // The relief every effect above described, spent once \u2014 see plPerturb.\n csm_FragNormal = plPerturb(csm_FragNormal, plHeight);" : ""}
4202
5950
  if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
4203
5951
  ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
4204
5952
  // What the key light pushes through the sheet, filtered by the ink on it.
@@ -4210,7 +5958,7 @@ void main() {
4210
5958
  structureKey: `${[
4211
5959
  grain !== void 0 || banding > 0 ? "g" : "",
4212
5960
  deckle ? "d" : "",
4213
- creases ? "c" : "",
5961
+ creases.length > 0 ? "c" : "",
4214
5962
  aging !== void 0 ? "a" : "",
4215
5963
  perforation ? "p" : "",
4216
5964
  stock.adhesive ? "A" : ""
@@ -4221,9 +5969,9 @@ void main() {
4221
5969
  alphaTest: deckle || perforation ? 0.5 : 0
4222
5970
  };
4223
5971
  }
4224
- function padPositions(positions) {
4225
- const out = positions.slice(0, 4);
4226
- while (out.length < 4) out.push(-1);
5972
+ function pad(values, fill = 0) {
5973
+ const out = values.slice(0, 4);
5974
+ while (out.length < 4) out.push(fill);
4227
5975
  return out;
4228
5976
  }
4229
5977
 
@@ -4247,7 +5995,8 @@ function PaperMaterial({
4247
5995
  surface,
4248
5996
  thickness,
4249
5997
  sheet: sheet2,
4250
- lighting = "studio"
5998
+ lighting = "studio",
5999
+ creases
4251
6000
  }) {
4252
6001
  const rig = useLightRig(lighting);
4253
6002
  const composed = composeSurface(
@@ -4259,7 +6008,8 @@ function PaperMaterial({
4259
6008
  hasBackMap: Boolean(backTexture)
4260
6009
  },
4261
6010
  sheet2,
4262
- rig
6011
+ rig,
6012
+ creases ?? resolveCreases(surface, [], sheet2 ?? { width: 1, height: 1.4 })
4263
6013
  );
4264
6014
  const bound = (0, import_react9.useMemo)(() => composed.uniforms, [composed.structureKey]);
4265
6015
  (0, import_react9.useEffect)(() => {
@@ -4594,6 +6344,7 @@ function configInputs(props) {
4594
6344
  props.behavior ?? null,
4595
6345
  props.deformers ?? null,
4596
6346
  props.surface ?? null,
6347
+ props.memory ?? null,
4597
6348
  props.scene ?? null,
4598
6349
  props.physics ?? null,
4599
6350
  props.onTwos ?? null
@@ -4612,6 +6363,7 @@ function resolveConfig(props) {
4612
6363
  if (props.behavior) overrides.behavior = props.behavior;
4613
6364
  if (props.deformers) overrides.deformers = props.deformers;
4614
6365
  if (props.surface) overrides.surface = { ...base.surface, ...props.surface };
6366
+ if (props.memory) overrides.memory = { ...base.memory, ...props.memory };
4615
6367
  if (props.scene) overrides.scene = { ...base.scene, ...props.scene };
4616
6368
  if (props.physics) overrides.physics = props.physics;
4617
6369
  if (props.onTwos !== void 0) overrides.onTwos = props.onTwos;
@@ -4639,7 +6391,9 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4639
6391
  machineRef.current = machine;
4640
6392
  const resolvedRef = (0, import_react11.useRef)(resolved);
4641
6393
  resolvedRef.current = resolved;
4642
- const isCloth = !reduced && typeof config.physics === "object";
6394
+ const simKind = !reduced && typeof config.physics === "object" ? config.physics.type : null;
6395
+ const isCloth = simKind === "cloth";
6396
+ const isStrip = simKind === "strip";
4643
6397
  const idle = !reduced && typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4644
6398
  const meshRef = (0, import_react11.useRef)(null);
4645
6399
  const groupRef = (0, import_react11.useRef)(null);
@@ -4651,19 +6405,33 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4651
6405
  const draggingRef = (0, import_react11.useRef)(null);
4652
6406
  const configRef = (0, import_react11.useRef)(config);
4653
6407
  configRef.current = config;
6408
+ const baseRotation = (0, import_react11.useMemo)(() => {
6409
+ const [rx, ry, rz] = props.rotation ?? [0, 0, 0];
6410
+ return [rx, ry + config.scene.turn * Math.PI / 180, rz];
6411
+ }, [props.rotation, config.scene.turn]);
4654
6412
  const controls = (0, import_fiber4.useThree)((s) => s.controls);
4655
6413
  const camera = (0, import_fiber4.useThree)((s) => s.camera);
4656
6414
  const behaviorKey = JSON.stringify(config.behavior ?? null);
4657
6415
  const deformersKey = JSON.stringify(config.deformers ?? null);
4658
6416
  const sheetKey = JSON.stringify(config.sheet);
4659
- const physicsKey = JSON.stringify(config.physics);
6417
+ const physicsKey = typeof config.physics === "object" ? config.physics.type : config.physics;
6418
+ const memoryKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}`).join("|");
6419
+ const creaseKey = config.memory.creases.map((c) => `${c.angle}:${c.offset}:${c.depth}`).join("|");
6420
+ const creasesRef = (0, import_react11.useRef)(null);
6421
+ creasesRef.current ??= new CreaseTracker(config.memory.creases);
6422
+ const creases = creasesRef.current;
4660
6423
  (0, import_react11.useEffect)(() => {
4661
6424
  if (!draggingRef.current && !playingRef.current) overridesRef.current = {};
6425
+ creases.reset();
4662
6426
  dirtyRef.current = true;
4663
6427
  }, [behaviorKey, deformersKey, sheetKey, physicsKey]);
6428
+ (0, import_react11.useEffect)(() => {
6429
+ creases.adopt(configRef.current.memory.creases);
6430
+ dirtyRef.current = true;
6431
+ }, [creaseKey]);
4664
6432
  const { minSegments, autoSegments, animatedStack } = (0, import_react11.useMemo)(() => {
4665
6433
  const cfg = configRef.current;
4666
- const probe2 = buildStack(cfg, {});
6434
+ const probe2 = withMemory(buildStack(cfg, {}), cfg);
4667
6435
  if (!probe2) {
4668
6436
  return {
4669
6437
  minSegments: [2, 2],
@@ -4676,7 +6444,7 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4676
6444
  if (cfg.behavior && !cfg.deformers) {
4677
6445
  const param = getBehavior(cfg.behavior.type).progressParam;
4678
6446
  for (const p of PROGRESS_SAMPLES) {
4679
- const at = buildStack(cfg, { [param]: p });
6447
+ const at = withMemory(buildStack(cfg, { [param]: p }), cfg);
4680
6448
  if (!at) continue;
4681
6449
  const [x, y] = stackAutoSegments(at, cfg.sheet);
4682
6450
  if (x > want[0]) want[0] = x;
@@ -4689,30 +6457,65 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4689
6457
  autoSegments: want,
4690
6458
  animatedStack: animated
4691
6459
  };
4692
- }, [behaviorKey, deformersKey, physicsKey]);
6460
+ }, [behaviorKey, deformersKey, physicsKey, memoryKey]);
4693
6461
  const geometry = (0, import_react11.useMemo)(() => {
6462
+ if (isStrip) {
6463
+ const strip = configRef.current.physics;
6464
+ const nodes = stripNodeCount(config.sheet.height, strip.perforation);
6465
+ return new THREE11.PlaneGeometry(config.sheet.width, config.sheet.height, 1, nodes - 1);
6466
+ }
4694
6467
  if (!isCloth) return createSheetGeometry(config.sheet, minSegments, autoSegments);
4695
- const [sx, sy] = resolveSegments(config.sheet, 2);
6468
+ const [sx, sy] = resolveSegments(config.sheet, minSegments);
4696
6469
  const capped = Math.min(Math.max(sx, sy), CLOTH_MAX_SEGMENTS);
4697
6470
  return new THREE11.PlaneGeometry(config.sheet.width, config.sheet.height, capped, capped);
4698
- }, [sheetKey, minSegments, autoSegments, isCloth]);
6471
+ }, [
6472
+ sheetKey,
6473
+ minSegments,
6474
+ autoSegments,
6475
+ isCloth,
6476
+ isStrip,
6477
+ isStrip ? config.physics.perforation : 0
6478
+ ]);
4699
6479
  (0, import_react11.useEffect)(() => () => geometry.dispose(), [geometry]);
4700
6480
  const basePositions = (0, import_react11.useMemo)(
4701
6481
  () => Float32Array.from(geometry.attributes.position.array),
4702
6482
  [geometry]
4703
6483
  );
6484
+ const stripSim = (0, import_react11.useMemo)(() => {
6485
+ if (!isStrip) return null;
6486
+ const strip = configRef.current.physics;
6487
+ return new StripSim(config.sheet.height, config.sheet.width, {
6488
+ scroll: strip.scroll,
6489
+ tightness: strip.tightness,
6490
+ core: strip.core,
6491
+ tail: strip.tail,
6492
+ perforation: strip.perforation,
6493
+ crease: strip.crease,
6494
+ stiffness: strip.stiffness,
6495
+ drag: strip.drag,
6496
+ gravity: strip.gravity,
6497
+ floor: strip.floor,
6498
+ inertia: strip.inertia
6499
+ });
6500
+ }, [geometry, isStrip]);
6501
+ const lastSimRef = (0, import_react11.useRef)(null);
4704
6502
  const sim = (0, import_react11.useMemo)(() => {
4705
6503
  if (!isCloth) return null;
4706
6504
  const cloth = configRef.current.physics;
4707
6505
  const cols = geometry.parameters.widthSegments + 1;
4708
6506
  const rows = geometry.parameters.heightSegments + 1;
4709
- return new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
6507
+ const next = new ClothSim(cols, rows, config.sheet.width, config.sheet.height, cloth.pins, {
4710
6508
  stiffness: cloth.stiffness,
4711
6509
  gravity: cloth.gravity,
4712
6510
  wind: cloth.wind,
4713
6511
  floor: cloth.floor
4714
6512
  });
6513
+ next.adopt(lastSimRef.current);
6514
+ return next;
4715
6515
  }, [geometry, isCloth, isCloth ? config.physics.pins : ""]);
6516
+ (0, import_react11.useEffect)(() => {
6517
+ lastSimRef.current = sim;
6518
+ }, [sim]);
4716
6519
  const stock = getStock(config.stock);
4717
6520
  const texture = useContentTexture(config.content, config.sheet, stock);
4718
6521
  const backTexture = useContentTexture(config.content.back, config.sheet, stock);
@@ -4807,6 +6610,10 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4807
6610
  placeProgrammatic: () => machineRef.current?.placeProgrammatic() ?? false,
4808
6611
  returnProgrammatic: () => machineRef.current?.returnProgrammatic() ?? false
4809
6612
  }));
6613
+ const shadedCreases = (0, import_react11.useMemo)(
6614
+ () => resolveCreases(config.surface, config.memory.creases, config.sheet),
6615
+ [config.surface, config.memory.creases, config.sheet]
6616
+ );
4810
6617
  const idlePose = (0, import_react11.useRef)({ position: [0, 0, 0], rotation: [0, 0, 0] });
4811
6618
  (0, import_fiber4.useFrame)(({ clock }, delta) => {
4812
6619
  const cfg = liveConfig();
@@ -4819,10 +6626,10 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4819
6626
  idle?.transform?.(now, pose);
4820
6627
  if (hasBehaviorTransform) {
4821
6628
  const o = effectiveOptions(now);
4822
- if (o) behavior.transform(o, now, pose);
6629
+ if (o) behavior.transform(o, now, pose, cfg.sheet);
4823
6630
  }
4824
6631
  const base = props.position ?? [0, 0, 0];
4825
- const baseRot = props.rotation ?? [0, 0, 0];
6632
+ const baseRot = baseRotation;
4826
6633
  groupRef.current.position.set(
4827
6634
  base[0] + pose.position[0],
4828
6635
  base[1] + pose.position[1],
@@ -4834,6 +6641,56 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4834
6641
  baseRot[2] + pose.rotation[2]
4835
6642
  );
4836
6643
  }
6644
+ if (isStrip && stripSim) {
6645
+ const strip = cfg.physics;
6646
+ stripSim.setParams({
6647
+ scroll: strip.scroll,
6648
+ tightness: strip.tightness,
6649
+ core: strip.core,
6650
+ tail: strip.tail,
6651
+ crease: strip.crease,
6652
+ stiffness: strip.stiffness,
6653
+ drag: strip.drag,
6654
+ gravity: strip.gravity,
6655
+ floor: strip.floor,
6656
+ inertia: strip.inertia
6657
+ });
6658
+ stripSim.step(delta);
6659
+ if (!stripSim.asleep) {
6660
+ const position = geometry.attributes.position;
6661
+ stripSim.writeInto(position.array);
6662
+ position.needsUpdate = true;
6663
+ computeSheetNormals(geometry);
6664
+ geometry.computeBoundingSphere();
6665
+ }
6666
+ return;
6667
+ }
6668
+ const applyShape = (base, force) => {
6669
+ const animated = !reduced && animatedStack;
6670
+ const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
6671
+ const machineAnimating = Boolean(machineRef.current?.transitioning);
6672
+ if (!force && !dirtyRef.current && !hasLoop && !animated && !machineAnimating) return false;
6673
+ const raw = buildStack(cfg, overridesRef.current, behavior, now);
6674
+ const setAmount = cfg.memory.set ?? stock.takesSet;
6675
+ if (raw && creases.observe(raw, setAmount)) props.onCrease?.(creases.creases);
6676
+ const stack = withMemory(raw, cfg, creases.creases);
6677
+ if (!stack) return false;
6678
+ dirtyRef.current = false;
6679
+ const ctx = { t: now, sheet: cfg.sheet };
6680
+ applyDeformerStack(geometry, base, stack, ctx);
6681
+ if (props.interactive && behavior?.handles) {
6682
+ const o = effectiveOptions(now);
6683
+ behavior.handles.forEach((h, i) => {
6684
+ const mesh = handleRefs.current[i];
6685
+ if (!mesh || !o) return;
6686
+ const [u, v] = h.anchor(o, cfg.sheet);
6687
+ anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
6688
+ displacePoint(anchorScratch, u, v, stack, ctx);
6689
+ mesh.position.copy(anchorScratch);
6690
+ });
6691
+ }
6692
+ return true;
6693
+ };
4837
6694
  if (isCloth && sim) {
4838
6695
  const cloth = cfg.physics;
4839
6696
  sim.setParams({
@@ -4843,7 +6700,8 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4843
6700
  floor: cloth.floor
4844
6701
  });
4845
6702
  sim.step(delta);
4846
- if (!sim.asleep) {
6703
+ const moved = !sim.asleep;
6704
+ if (!applyShape(sim.positions, moved) && moved) {
4847
6705
  const position = geometry.attributes.position;
4848
6706
  position.array.set(sim.positions);
4849
6707
  position.needsUpdate = true;
@@ -4851,26 +6709,7 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4851
6709
  }
4852
6710
  return;
4853
6711
  }
4854
- const animated = !reduced && animatedStack;
4855
- const hasLoop = !reduced && Boolean(cfg.behavior && behavior?.loop);
4856
- const machineAnimating = Boolean(machineRef.current?.transitioning);
4857
- if (!dirtyRef.current && !hasLoop && !animated && !machineAnimating) return;
4858
- const stack = buildStack(cfg, overridesRef.current, behavior, now);
4859
- if (!stack) return;
4860
- dirtyRef.current = false;
4861
- const ctx = { t: now, sheet: cfg.sheet };
4862
- applyDeformerStack(geometry, basePositions, stack, ctx);
4863
- if (props.interactive && behavior?.handles) {
4864
- const o = effectiveOptions(now);
4865
- behavior.handles.forEach((h, i) => {
4866
- const mesh = handleRefs.current[i];
4867
- if (!mesh || !o) return;
4868
- const [u, v] = h.anchor(o, cfg.sheet);
4869
- anchorScratch.set((u - 0.5) * cfg.sheet.width, (v - 0.5) * cfg.sheet.height, 0);
4870
- displacePoint(anchorScratch, u, v, stack, ctx);
4871
- mesh.position.copy(anchorScratch);
4872
- });
4873
- }
6712
+ applyShape(basePositions, false);
4874
6713
  });
4875
6714
  const localDragPoint = (e) => {
4876
6715
  const group = groupRef.current;
@@ -4894,13 +6733,22 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4894
6733
  if (typeof p === "number") props.onProgress?.(p);
4895
6734
  };
4896
6735
  const grabAnchor = (0, import_react11.useRef)(new THREE11.Vector3());
6736
+ const grabOffset = (0, import_react11.useRef)(new THREE11.Vector3());
4897
6737
  const clothDown = (e) => {
4898
6738
  if (!isCloth || !sim || !props.interactive || !groupRef.current) return;
4899
6739
  e.stopPropagation();
4900
6740
  if (controls) controls.enabled = false;
4901
6741
  grabAnchor.current.copy(e.point);
4902
6742
  const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
4903
- sim.grabNearest(local.x, local.y, local.z);
6743
+ const drawn = geometry.attributes.position.array;
6744
+ const index = nearestVertex(drawn, sim.count, local.x, local.y, local.z);
6745
+ sim.grab(index);
6746
+ const i3 = index * 3;
6747
+ grabOffset.current.set(
6748
+ drawn[i3] - sim.positions[i3],
6749
+ drawn[i3 + 1] - sim.positions[i3 + 1],
6750
+ drawn[i3 + 2] - sim.positions[i3 + 2]
6751
+ );
4904
6752
  draggingRef.current = "cloth";
4905
6753
  e.target.setPointerCapture(e.pointerId);
4906
6754
  };
@@ -4911,7 +6759,8 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4911
6759
  const hit = e.ray.intersectPlane(dragPlane, dragPoint);
4912
6760
  if (!hit) return;
4913
6761
  groupRef.current.worldToLocal(hit);
4914
- sim.moveGrab(hit.x, hit.y, hit.z);
6762
+ const offset = grabOffset.current;
6763
+ sim.moveGrab(hit.x - offset.x, hit.y - offset.y, hit.z - offset.z);
4915
6764
  };
4916
6765
  const clothUp = (e) => {
4917
6766
  if (draggingRef.current !== "cloth" || !sim) return;
@@ -4920,8 +6769,34 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4920
6769
  sim.release();
4921
6770
  e.target.releasePointerCapture(e.pointerId);
4922
6771
  };
6772
+ const stripDown = (e) => {
6773
+ if (!isStrip || !stripSim || !props.interactive || !groupRef.current) return;
6774
+ e.stopPropagation();
6775
+ const local = groupRef.current.worldToLocal(worldScratch.copy(e.point));
6776
+ if (stripSim.grabNearest(local.y, local.z) < 0) return;
6777
+ if (controls) controls.enabled = false;
6778
+ grabAnchor.current.copy(e.point);
6779
+ draggingRef.current = "strip";
6780
+ e.target.setPointerCapture(e.pointerId);
6781
+ };
6782
+ const stripMove = (e) => {
6783
+ if (draggingRef.current !== "strip" || !stripSim || !groupRef.current) return;
6784
+ camera.getWorldDirection(planeNormal);
6785
+ dragPlane.setFromNormalAndCoplanarPoint(planeNormal, grabAnchor.current);
6786
+ const hit = e.ray.intersectPlane(dragPlane, dragPoint);
6787
+ if (!hit) return;
6788
+ groupRef.current.worldToLocal(hit);
6789
+ stripSim.moveGrab(hit.y, hit.z);
6790
+ };
6791
+ const stripUp = (e) => {
6792
+ if (draggingRef.current !== "strip" || !stripSim) return;
6793
+ draggingRef.current = null;
6794
+ if (controls) controls.enabled = true;
6795
+ stripSim.release();
6796
+ e.target.releasePointerCapture(e.pointerId);
6797
+ };
4923
6798
  const sendState = (event) => machineRef.current?.send(event);
4924
- return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("group", { ref: groupRef, position: props.position, rotation: props.rotation, children: [
6799
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("group", { ref: groupRef, position: props.position, rotation: baseRotation, children: [
4925
6800
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
4926
6801
  "mesh",
4927
6802
  {
@@ -4932,13 +6807,15 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4932
6807
  frustumCulled: false,
4933
6808
  onPointerOver: statesLive ? () => sendState("enter") : void 0,
4934
6809
  onPointerOut: statesLive ? () => sendState("leave") : void 0,
4935
- onPointerDown: isCloth || statesLive ? (e) => {
6810
+ onPointerDown: isCloth || isStrip || statesLive ? (e) => {
4936
6811
  if (isCloth) clothDown(e);
6812
+ if (isStrip) stripDown(e);
4937
6813
  if (statesLive) sendState("down");
4938
6814
  } : void 0,
4939
- onPointerMove: isCloth ? clothMove : void 0,
4940
- onPointerUp: isCloth || statesLive ? (e) => {
6815
+ onPointerMove: isCloth ? clothMove : isStrip ? stripMove : void 0,
6816
+ onPointerUp: isCloth || isStrip || statesLive ? (e) => {
4941
6817
  if (isCloth) clothUp(e);
6818
+ if (isStrip) stripUp(e);
4942
6819
  if (statesLive) sendState("up");
4943
6820
  } : void 0,
4944
6821
  children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
@@ -4950,12 +6827,17 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4950
6827
  surface: config.surface,
4951
6828
  thickness: config.sheet.thickness,
4952
6829
  sheet: config.sheet,
4953
- lighting: config.scene.lighting
6830
+ lighting: config.scene.lighting,
6831
+ creases: shadedCreases
4954
6832
  }
4955
6833
  )
4956
6834
  }
4957
6835
  ),
4958
- props.interactive && !isCloth && behavior?.handles?.map((h, i) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
6836
+ props.interactive && // Any simulation, not cloth alone: a sim owns the vertices, so there
6837
+ // is no deformer stack for a handle to drive. Harmless as `!isCloth`
6838
+ // only because the schema makes a sim and a behavior exclusive — the
6839
+ // intent is what is written here.
6840
+ !simKind && behavior?.handles?.map((h, i) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
4959
6841
  "mesh",
4960
6842
  {
4961
6843
  userData: { paperlabChrome: true },
@@ -4986,8 +6868,32 @@ var PaperMesh = (0, import_react11.forwardRef)(function PaperMesh2(props, ref) {
4986
6868
  ))
4987
6869
  ] });
4988
6870
  });
6871
+ function withMemory(stack, config, creases = config.memory.creases) {
6872
+ if (isStripConfig(config.physics)) return null;
6873
+ const out = applyMemory(stack ?? [], creases);
6874
+ return out.length > 0 ? out : null;
6875
+ }
6876
+ function nearestVertex(array, count, x, y, z30) {
6877
+ let best = -1;
6878
+ let bestDist = Infinity;
6879
+ for (let i = 0; i < count; i++) {
6880
+ const i3 = i * 3;
6881
+ const dx = array[i3] - x;
6882
+ const dy = array[i3 + 1] - y;
6883
+ const dz = array[i3 + 2] - z30;
6884
+ const d = dx * dx + dy * dy + dz * dz;
6885
+ if (d < bestDist) {
6886
+ bestDist = d;
6887
+ best = i;
6888
+ }
6889
+ }
6890
+ return best;
6891
+ }
6892
+ function isStripConfig(physics) {
6893
+ return typeof physics === "object" && physics.type === "strip";
6894
+ }
4989
6895
  function buildStack(config, overrides, behavior, t = 0) {
4990
- if (typeof config.physics === "object") return null;
6896
+ if (isStripConfig(config.physics)) return null;
4991
6897
  const idle = typeof config.physics === "string" && config.physics !== "none" ? getIdlePreset(config.physics) : null;
4992
6898
  const idleStack = idle?.stack?.() ?? [];
4993
6899
  let shapeStack = [];
@@ -5430,7 +7336,7 @@ function getWalkPath(options) {
5430
7336
 
5431
7337
  // src/field/layouts/index.ts
5432
7338
  var DEFAULT_SHEET = { width: 1, height: 1.4 };
5433
- var TAU3 = Math.PI * 2;
7339
+ var TAU4 = Math.PI * 2;
5434
7340
  var DEG7 = Math.PI / 180;
5435
7341
  function jitter2(seed, i) {
5436
7342
  let h = Math.imul(seed * 1e3 + i + 1 ^ 2654435769, 2654435761);
@@ -5450,7 +7356,7 @@ var ring = {
5450
7356
  defaults: ringSchema.parse({}),
5451
7357
  optionsSchema: ringSchema,
5452
7358
  pose(i, n, o, phase) {
5453
- const theta = (i / n + phase) * TAU3;
7359
+ const theta = (i / n + phase) * TAU4;
5454
7360
  return {
5455
7361
  position: [Math.sin(theta) * o.radius, 0, Math.cos(theta) * o.radius],
5456
7362
  // Face radially OUTWARD so the papers nearest the camera show their
@@ -5611,7 +7517,7 @@ var sweepSchema = import_zod24.z.object({
5611
7517
  from: import_zod24.z.number().min(0).max(1).default(0),
5612
7518
  to: import_zod24.z.number().min(0).max(1).default(1)
5613
7519
  });
5614
- var sweep = {
7520
+ var sweep2 = {
5615
7521
  id: "sweep",
5616
7522
  label: "Sweep",
5617
7523
  defaults: sweepSchema.parse({}),
@@ -5726,7 +7632,7 @@ var rack = {
5726
7632
  };
5727
7633
  var colonnadeSchema = import_zod24.z.object({
5728
7634
  /** The walk the colonnade is built along — see `stage/path`. */
5729
- path: walkPathSchema.default({}),
7635
+ path: walkPathSchema.prefault({}),
5730
7636
  /** Half-width of the clear aisle: how far each banner stands off the walk line. */
5731
7637
  aisle: import_zod24.z.number().min(0.2).max(20).default(2.4),
5732
7638
  /** How much that gap opens and closes along the walk. Nothing hung by hand is a corridor. */
@@ -5817,7 +7723,7 @@ registerLayout(spread);
5817
7723
  registerLayout(pile);
5818
7724
  registerLayout(wall);
5819
7725
  registerLayout(spill);
5820
- registerLayout(sweep);
7726
+ registerLayout(sweep2);
5821
7727
  registerLayout(book);
5822
7728
  registerLayout(accordion);
5823
7729
  registerLayout(rack);
@@ -6020,7 +7926,8 @@ function FieldGroup({
6020
7926
  behaviorTransform.transform(
6021
7927
  { ...config.behavior, [behaviorTransform.progressParam]: progressRef.current },
6022
7928
  t,
6023
- pose2
7929
+ pose2,
7930
+ shared.sheet
6024
7931
  );
6025
7932
  scratchObj.position.x += pose2.position[0];
6026
7933
  scratchObj.position.y += pose2.position[1];
@@ -7072,7 +8979,7 @@ var RUN_STRIDE = 1.7;
7072
8979
  var LEAN_FULL_SPEED = 1.2;
7073
8980
  var GRAVITY = 9.81;
7074
8981
  var FROUDE_RUN = 0.5;
7075
- var TAU4 = Math.PI * 2;
8982
+ var TAU5 = Math.PI * 2;
7076
8983
  function isRunning(o) {
7077
8984
  if (o.gait !== "auto") return o.gait === "run";
7078
8985
  const legLength = PROPORTIONS.hip * o.height;
@@ -7088,7 +8995,7 @@ function figureGait(distance, o) {
7088
8995
  const a = running ? RUN : WALK;
7089
8996
  const cycle = cycleLength(o);
7090
8997
  const phase = cycle > 0 ? (distance / cycle % 1 + 1) % 1 : 0;
7091
- const w = phase * TAU4;
8998
+ const w = phase * TAU5;
7092
8999
  const leftThigh = a.thigh * Math.sin(w);
7093
9000
  const rightThigh = a.thigh * Math.sin(w + Math.PI);
7094
9001
  const flex = (at) => -a.knee * Math.max(0, Math.cos(w - at)) ** 1.5;
@@ -7814,9 +9721,9 @@ var stageRoomSchema = import_zod27.z.object({
7814
9721
  height: import_zod27.z.number().min(1).max(6).default(2.2),
7815
9722
  color: import_zod27.z.string().default("#171310").describe("color"),
7816
9723
  /** Columns flanking the walk — see `stageColumnsSchema`. */
7817
- columns: stageColumnsSchema.default({}),
9724
+ columns: stageColumnsSchema.prefault({}),
7818
9725
  /** A wall at the end of the walk with the source in it. */
7819
- doorway: stageDoorwaySchema.default({})
9726
+ doorway: stageDoorwaySchema.prefault({})
7820
9727
  });
7821
9728
  var stageGradeSchema = import_zod27.z.object({
7822
9729
  /**
@@ -7870,9 +9777,9 @@ var stageGradeSchema = import_zod27.z.object({
7870
9777
  grain: import_zod27.z.number().min(0).max(0.5).default(0.022)
7871
9778
  });
7872
9779
  var stageSchema = import_zod27.z.object({
7873
- path: walkPathSchema.default({}),
7874
- shot: shotSchema.default({}),
7875
- figure: figureSchema.default({}),
9780
+ path: walkPathSchema.prefault({}),
9781
+ shot: shotSchema.prefault({}),
9782
+ figure: figureSchema.prefault({}),
7876
9783
  /** Stage mode is built for `nave`; the others are all front-lit. */
7877
9784
  lighting: import_zod27.z.enum(lightingNames).default("nave"),
7878
9785
  /**
@@ -7899,12 +9806,12 @@ var stageSchema = import_zod27.z.object({
7899
9806
  * Still one flag away for anyone who wants it.
7900
9807
  */
7901
9808
  showFigure: import_zod27.z.boolean().default(false),
7902
- source: stageSourceSchema.default({}),
7903
- ground: stageGroundSchema.default({}),
9809
+ source: stageSourceSchema.prefault({}),
9810
+ ground: stageGroundSchema.prefault({}),
7904
9811
  /** Ceiling and the architecture around the walk — see `stageRoomSchema`. */
7905
- room: stageRoomSchema.default({}),
9812
+ room: stageRoomSchema.prefault({}),
7906
9813
  /** Thread and clips — see `stageSuspensionSchema`. */
7907
- suspension: stageSuspensionSchema.default({}),
9814
+ suspension: stageSuspensionSchema.prefault({}),
7908
9815
  /**
7909
9816
  * The print — bloom, vignette, grain.
7910
9817
  *
@@ -7916,7 +9823,7 @@ var stageSchema = import_zod27.z.object({
7916
9823
  * behaviour: a stage silently losing its grade would be worse than a
7917
9824
  * missing-module error that names the package.
7918
9825
  */
7919
- grade: stageGradeSchema.default({})
9826
+ grade: stageGradeSchema.prefault({})
7920
9827
  });
7921
9828
 
7922
9829
  // src/stage/navigate.ts
@@ -8884,6 +10791,8 @@ function diffConfig(config) {
8884
10791
  }
8885
10792
  if (config.deformers) out.deformers = config.deformers;
8886
10793
  if (Object.keys(config.surface).length > 0) out.surface = config.surface;
10794
+ const memory = diffAgainst(config.memory, memorySchema.parse({}));
10795
+ if (Object.keys(memory).length > 0) out.memory = memory;
8887
10796
  if (typeof config.physics === "object") {
8888
10797
  const defaults = clothConfigSchema.parse({ type: "cloth" });
8889
10798
  out.physics = { type: "cloth", ...diffAgainst(config.physics, defaults) };
@@ -8932,7 +10841,7 @@ function diffStage(stage) {
8932
10841
  return diff;
8933
10842
  }
8934
10843
  function stringifyStage(value, indent = 0) {
8935
- const pad = " ".repeat(indent);
10844
+ const pad2 = " ".repeat(indent);
8936
10845
  const inner = " ".repeat(indent + 1);
8937
10846
  if (Array.isArray(value)) {
8938
10847
  if (value.length === 0) return "[]";
@@ -8940,7 +10849,7 @@ function stringifyStage(value, indent = 0) {
8940
10849
  const items = value.map((v) => `${inner}${stringifyStage(v, indent + 1)}`);
8941
10850
  return `[
8942
10851
  ${items.join(",\n")}
8943
- ${pad}]`;
10852
+ ${pad2}]`;
8944
10853
  }
8945
10854
  if (value && typeof value === "object") {
8946
10855
  const entries = Object.entries(value);
@@ -8948,7 +10857,7 @@ ${pad}]`;
8948
10857
  const items = entries.map(([k, v]) => `${inner}${JSON.stringify(k)}: ${stringifyStage(v, indent + 1)}`);
8949
10858
  return `{
8950
10859
  ${items.join(",\n")}
8951
- ${pad}}`;
10860
+ ${pad2}}`;
8952
10861
  }
8953
10862
  return JSON.stringify(value);
8954
10863
  }