paperlab 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,13 +3,13 @@ import { Canvas } from "@react-three/fiber";
3
3
  import { forwardRef as forwardRef2, useMemo as useMemo5 } from "react";
4
4
 
5
5
  // src/PaperMesh.tsx
6
- import * as THREE6 from "three";
6
+ import * as THREE7 from "three";
7
7
  import { gsap as gsap2 } from "gsap";
8
8
  import { useFrame, useThree } from "@react-three/fiber";
9
9
  import { forwardRef, useEffect as useEffect5, useImperativeHandle, useMemo as useMemo3, useRef as useRef2 } from "react";
10
10
 
11
11
  // src/config/schema.ts
12
- import { z as z11 } from "zod";
12
+ import { z as z12 } from "zod";
13
13
 
14
14
  // src/config/merge.ts
15
15
  function mergeConfig(base, override) {
@@ -121,6 +121,7 @@ void FN(inout vec3 p, vec2 uv, float t) {
121
121
  }
122
122
  `
123
123
  ),
124
+ strength: "amount",
124
125
  uniforms: (o) => {
125
126
  const [sx, sy] = CORNER_SIGNS[o.corner];
126
127
  return {
@@ -582,16 +583,62 @@ var flight = {
582
583
  }
583
584
  };
584
585
 
586
+ // src/behaviors/crumple.ts
587
+ import { z as z11 } from "zod";
588
+ var crumpleBehaviorOptionsSchema = z11.object({
589
+ /** 0 = flat sheet, 1 = crushed. */
590
+ progress: z11.number().min(0).max(1).default(0.55),
591
+ /** Few big facets at 0, many small ones at 1. */
592
+ coarseness: z11.number().min(0).max(1).default(0.35),
593
+ /** How far the sheet curls in on itself as it crushes. */
594
+ ball: z11.number().min(0).max(1).default(0.5),
595
+ /** A different crush of the same paper. */
596
+ seed: z11.number().int().min(0).max(7).default(0)
597
+ });
598
+ var crumpleBehavior = {
599
+ id: "crumple",
600
+ label: "Crumple",
601
+ defaults: crumpleBehaviorOptionsSchema.parse({}),
602
+ optionsSchema: crumpleBehaviorOptionsSchema,
603
+ progressParam: "progress",
604
+ duration: 2.6,
605
+ loopMode: "yoyo",
606
+ stack(o) {
607
+ return [
608
+ {
609
+ type: "crumple",
610
+ options: {
611
+ amount: o.progress,
612
+ scale: 1.5 + o.coarseness * 4.5,
613
+ pull: 0.5,
614
+ seed: o.seed
615
+ }
616
+ },
617
+ // The sheet closing in on itself. Paper does not crush flat, and
618
+ // without this the result reads as texture rather than as a ball.
619
+ {
620
+ type: "bend",
621
+ options: { curvature: o.progress * o.ball * 0.9, angle: 35 }
622
+ }
623
+ ];
624
+ }
625
+ };
626
+
585
627
  // src/config/schema.ts
586
- var sheetSchema = z11.object({
628
+ var sheetSchema = z12.object({
587
629
  /** World units. A letter sheet is ~1 × 1.4, a receipt ~1 × 2.6. */
588
- width: z11.number().positive().max(20).default(1),
589
- height: z11.number().positive().max(20).default(1.4),
630
+ width: z12.number().positive().max(20).default(1),
631
+ height: z12.number().positive().max(20).default(1.4),
590
632
  /** Visual thickness in mm-ish units; drives edge/shadow treatment, not geometry (yet). */
591
- thickness: z11.number().min(0).max(2).default(0.2),
592
- /** 'auto' sizes the grid from the active deformers' needs. */
593
- segments: z11.union([z11.literal("auto"), z11.number().int().min(2).max(256)]).default("auto"),
594
- cornerRadius: z11.number().min(0).max(0.5).default(0)
633
+ thickness: z12.number().min(0).max(2).default(0.2),
634
+ /**
635
+ * `'auto'` gives the LONG side 72 segments, whatever is on the sheet — a
636
+ * deformer's `minSegments` can only raise that floor, never lower it, so in
637
+ * practice a blank sheet is tessellated exactly as finely as a crumpled
638
+ * one. Set a number to take the decision yourself.
639
+ */
640
+ segments: z12.union([z12.literal("auto"), z12.number().int().min(2).max(256)]).default("auto"),
641
+ cornerRadius: z12.number().min(0).max(0.5).default(0)
595
642
  });
596
643
  var stockNames = [
597
644
  "printer",
@@ -602,46 +649,46 @@ var stockNames = [
602
649
  "photo-gloss",
603
650
  "sticker"
604
651
  ];
605
- var stockSchema = z11.enum(stockNames);
606
- var blankContentBase = z11.object({
607
- type: z11.literal("blank")
652
+ var stockSchema = z12.enum(stockNames);
653
+ var blankContentBase = z12.object({
654
+ type: z12.literal("blank")
608
655
  });
609
- var imageContentBase = z11.object({
610
- type: z11.literal("image"),
611
- src: z11.string(),
612
- fit: z11.enum(["cover", "contain"]).default("cover"),
656
+ var imageContentBase = z12.object({
657
+ type: z12.literal("image"),
658
+ src: z12.string(),
659
+ fit: z12.enum(["cover", "contain"]).default("cover"),
613
660
  /** Read by the hidden DOM mirror and the no-WebGL fallback. */
614
- alt: z11.string().optional()
661
+ alt: z12.string().optional()
615
662
  });
616
- var textContentBase = z11.object({
617
- type: z11.literal("text"),
618
- text: z11.string().default("Dear reader,"),
619
- font: z11.string().default('Georgia, "Times New Roman", serif'),
663
+ var textContentBase = z12.object({
664
+ type: z12.literal("text"),
665
+ text: z12.string().default("Dear reader,"),
666
+ font: z12.string().default('Georgia, "Times New Roman", serif'),
620
667
  /** px at texture resolution (long edge = 1024 logical px before DPR). */
621
- size: z11.number().min(8).max(256).default(44),
622
- weight: z11.number().min(100).max(900).default(400),
623
- color: z11.string().default("#2b2620"),
624
- align: z11.enum(["left", "center", "right"]).default("left"),
668
+ size: z12.number().min(8).max(256).default(44),
669
+ weight: z12.number().min(100).max(900).default(400),
670
+ color: z12.string().default("#2b2620"),
671
+ align: z12.enum(["left", "center", "right"]).default("left"),
625
672
  /** Fraction of the short edge. */
626
- padding: z11.number().min(0).max(0.4).default(0.09),
627
- lineHeight: z11.number().min(0.8).max(3).default(1.45)
673
+ padding: z12.number().min(0).max(0.4).default(0.09),
674
+ lineHeight: z12.number().min(0.8).max(3).default(1.45)
628
675
  });
629
- var receiptContentBase = z11.object({
630
- type: z11.literal("receipt"),
631
- store: z11.string().default("PAPERLAB"),
632
- address: z11.string().default("124 PAPER ST"),
633
- items: z11.array(z11.object({ name: z11.string(), price: z11.number() })).default([
676
+ var receiptContentBase = z12.object({
677
+ type: z12.literal("receipt"),
678
+ store: z12.string().default("PAPERLAB"),
679
+ address: z12.string().default("124 PAPER ST"),
680
+ items: z12.array(z12.object({ name: z12.string(), price: z12.number() })).default([
634
681
  { name: "CURL, TRUE", price: 12 },
635
682
  { name: "ROLL, TIGHT", price: 8.5 },
636
683
  { name: "SHEET, ONE", price: 0.99 }
637
684
  ]),
638
- taxRate: z11.number().min(0).max(1).default(0.08),
639
- barcode: z11.boolean().default(true),
685
+ taxRate: z12.number().min(0).max(1).default(0.08),
686
+ barcode: z12.boolean().default(true),
640
687
  /** Fixed so presets render deterministically; omit for "now". */
641
- timestamp: z11.string().optional(),
642
- footer: z11.string().default("KEEP FOR YOUR RECORDS")
688
+ timestamp: z12.string().optional(),
689
+ footer: z12.string().default("KEEP FOR YOUR RECORDS")
643
690
  });
644
- var backContentSchema = z11.discriminatedUnion("type", [
691
+ var backContentSchema = z12.discriminatedUnion("type", [
645
692
  blankContentBase,
646
693
  imageContentBase,
647
694
  textContentBase,
@@ -652,137 +699,140 @@ var blankContentSchema = blankContentBase.extend(withBack);
652
699
  var imageContentSchema = imageContentBase.extend(withBack);
653
700
  var textContentSchema = textContentBase.extend(withBack);
654
701
  var receiptContentSchema = receiptContentBase.extend(withBack);
655
- var contentSchema = z11.discriminatedUnion("type", [
702
+ var contentSchema = z12.discriminatedUnion("type", [
656
703
  blankContentSchema,
657
704
  imageContentSchema,
658
705
  textContentSchema,
659
706
  receiptContentSchema
660
707
  ]);
661
708
  var paperEdges = ["top", "right", "bottom", "left"];
662
- var surfaceSchema = z11.object({
709
+ var surfaceSchema = z12.object({
663
710
  /** Paper fiber noise, 0..1. */
664
- grain: z11.number().min(0).max(1).optional(),
711
+ grain: z12.number().min(0).max(1).optional(),
712
+ /** Light passing through the sheet from behind, 0..1. Stock defaults apply. */
713
+ translucency: z12.number().min(0).max(1).optional(),
665
714
  /** Torn-edge alpha with a lightened fiber band. */
666
- deckle: z11.object({
667
- edges: z11.array(z11.enum(paperEdges)).default(["bottom"]),
668
- roughness: z11.number().min(0).max(1).default(0.5)
715
+ deckle: z12.object({
716
+ edges: z12.array(z12.enum(paperEdges)).default(["bottom"]),
717
+ roughness: z12.number().min(0).max(1).default(0.5)
669
718
  }).optional(),
670
719
  /** Visual AO/highlight companion to the fold deformer. */
671
- creaseLines: z11.object({
720
+ creaseLines: z12.object({
672
721
  /** Crease line direction, degrees (0 = horizontal lines). */
673
- angle: z11.number().min(-360).max(360).default(0),
722
+ angle: z12.number().min(-360).max(360).default(0),
674
723
  /** Positions across the sheet, 0..1 fractions. */
675
- positions: z11.array(z11.number().min(0).max(1)).default([1 / 3, 2 / 3]),
676
- strength: z11.number().min(0).max(1).default(0.5)
724
+ positions: z12.array(z12.number().min(0).max(1)).default([1 / 3, 2 / 3]),
725
+ strength: z12.number().min(0).max(1).default(0.5)
677
726
  }).optional(),
678
727
  /** Yellowing + foxing spots, 0..1. */
679
- aging: z11.number().min(0).max(1).optional(),
728
+ aging: z12.number().min(0).max(1).optional(),
680
729
  /** Reversed front-content ghost on the backside, 0..1. Stock defaults apply. */
681
- showThrough: z11.number().min(0).max(1).optional(),
730
+ showThrough: z12.number().min(0).max(1).optional(),
682
731
  /**
683
732
  * Postage-stamp perforation: alpha-punched semicircular holes along chosen
684
733
  * edges. `state` flips an edge to a ripped-through profile (torn) — set
685
734
  * automatically when a paper detaches from a `sheet` field, manual wins.
686
735
  */
687
- perforation: z11.object({
688
- edges: z11.union([z11.array(z11.enum(paperEdges)), z11.literal("all")]).default("all"),
736
+ perforation: z12.object({
737
+ edges: z12.union([z12.array(z12.enum(paperEdges)), z12.literal("all")]).default("all"),
689
738
  /** World units — default tuned to stamp scale. */
690
- holeRadius: z11.number().min(2e-3).max(0.1).default(0.016),
691
- spacing: z11.number().min(0.01).max(0.5).default(0.055),
692
- state: z11.object({
693
- top: z11.enum(["intact", "torn"]).optional(),
694
- right: z11.enum(["intact", "torn"]).optional(),
695
- bottom: z11.enum(["intact", "torn"]).optional(),
696
- left: z11.enum(["intact", "torn"]).optional()
739
+ holeRadius: z12.number().min(2e-3).max(0.1).default(0.016),
740
+ spacing: z12.number().min(0.01).max(0.5).default(0.055),
741
+ state: z12.object({
742
+ top: z12.enum(["intact", "torn"]).optional(),
743
+ right: z12.enum(["intact", "torn"]).optional(),
744
+ bottom: z12.enum(["intact", "torn"]).optional(),
745
+ left: z12.enum(["intact", "torn"]).optional()
697
746
  }).default({})
698
747
  }).optional()
699
748
  });
700
- var behaviorConfigSchema = z11.discriminatedUnion("type", [
701
- peelOptionsSchema.extend({ type: z11.literal("peel") }),
702
- unrollOptionsSchema.extend({ type: z11.literal("unroll") }),
703
- flipOptionsSchema.extend({ type: z11.literal("flip") }),
704
- letterFoldOptionsSchema.extend({ type: z11.literal("letter-fold") }),
705
- hangOptionsSchema.extend({ type: z11.literal("hang") }),
706
- flyOptionsSchema.extend({ type: z11.literal("fly") }),
707
- fallOptionsSchema.extend({ type: z11.literal("fall") }),
708
- carryOptionsSchema.extend({ type: z11.literal("carry") }),
709
- flightOptionsSchema.extend({ type: z11.literal("flight") })
749
+ var behaviorConfigSchema = z12.discriminatedUnion("type", [
750
+ peelOptionsSchema.extend({ type: z12.literal("peel") }),
751
+ unrollOptionsSchema.extend({ type: z12.literal("unroll") }),
752
+ flipOptionsSchema.extend({ type: z12.literal("flip") }),
753
+ letterFoldOptionsSchema.extend({ type: z12.literal("letter-fold") }),
754
+ hangOptionsSchema.extend({ type: z12.literal("hang") }),
755
+ flyOptionsSchema.extend({ type: z12.literal("fly") }),
756
+ fallOptionsSchema.extend({ type: z12.literal("fall") }),
757
+ carryOptionsSchema.extend({ type: z12.literal("carry") }),
758
+ flightOptionsSchema.extend({ type: z12.literal("flight") }),
759
+ crumpleBehaviorOptionsSchema.extend({ type: z12.literal("crumple") })
710
760
  ]);
711
- var deformerInstanceSchema = z11.object({
712
- type: z11.string(),
713
- options: z11.record(z11.unknown()).default({}),
714
- enabled: z11.boolean().default(true)
761
+ var deformerInstanceSchema = z12.object({
762
+ type: z12.string(),
763
+ options: z12.record(z12.unknown()).default({}),
764
+ enabled: z12.boolean().default(true)
715
765
  });
716
766
  var physicsNames = ["none", "float", "tumble", "dangle", "taped", "breeze"];
717
- var clothConfigSchema = z11.object({
718
- type: z11.literal("cloth"),
719
- pins: z11.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
720
- wind: z11.number().min(0).max(1).default(0.3),
767
+ var clothConfigSchema = z12.object({
768
+ type: z12.literal("cloth"),
769
+ pins: z12.enum(["top-edge", "top-corners", "corner", "none"]).default("top-edge"),
770
+ wind: z12.number().min(0).max(1).default(0.3),
721
771
  /** Bend stiffness: 1 = crisp paper, 0 = silk. */
722
- stiffness: z11.number().min(0).max(1).default(0.8),
723
- gravity: z11.number().min(0).max(2).default(1),
772
+ stiffness: z12.number().min(0).max(1).default(0.8),
773
+ gravity: z12.number().min(0).max(2).default(1),
724
774
  /** Local-space ground plane the sheet settles onto. */
725
- floor: z11.number().min(-5).max(0).default(-1.4)
775
+ floor: z12.number().min(-5).max(0).default(-1.4)
726
776
  });
727
- var physicsSchema = z11.union([
728
- z11.enum(physicsNames),
729
- z11.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
777
+ var physicsSchema = z12.union([
778
+ z12.enum(physicsNames),
779
+ z12.literal("cloth").transform(() => clothConfigSchema.parse({ type: "cloth" })),
730
780
  clothConfigSchema
731
781
  ]);
732
- var lightingNames = ["studio", "window", "leaves", "goldenhour", "noir"];
733
- var sceneSchema = z11.object({
734
- lighting: z11.enum(lightingNames).default("studio")
782
+ var lightingNames = ["studio", "window", "leaves", "goldenhour", "noir", "nave"];
783
+ var sceneSchema = z12.object({
784
+ lighting: z12.enum(lightingNames).default("studio")
735
785
  });
736
786
  var coreStateNames = ["rest", "hover", "pressed", "picked", "placed"];
737
787
  var isStateName = (s) => coreStateNames.includes(s) || s.startsWith("custom:");
738
- var stateNameSchema = z11.string().refine(isStateName, {
788
+ var stateNameSchema = z12.string().refine(isStateName, {
739
789
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
740
790
  });
741
- var stateTransitionSchema = z11.object({
742
- duration: z11.number().min(0).max(5).default(0.35),
791
+ var stateTransitionSchema = z12.object({
792
+ duration: z12.number().min(0).max(5).default(0.35),
743
793
  /** GSAP ease name. */
744
- ease: z11.string().default("power2.out")
794
+ ease: z12.string().default("power2.out")
745
795
  });
746
- var stateDefSchema = z11.object({
796
+ var stateDefSchema = z12.object({
747
797
  /** Deep-partial override of the paper schema (behavior params, surface, …). */
748
- overrides: z11.record(z11.unknown()).default({}),
798
+ overrides: z12.record(z12.unknown()).default({}),
749
799
  /** Transition INTO this state. */
750
800
  transition: stateTransitionSchema.default({}),
751
801
  /** Chained actions after arriving. v1: 'emit:<event>' only. */
752
- onEnter: z11.array(z11.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
802
+ onEnter: z12.array(z12.string().regex(/^emit:[\w-]+$/, 'v1 actions are "emit:<event>"')).default([])
753
803
  });
754
- var paperStatesSchema = z11.object({
804
+ var paperStatesSchema = z12.object({
755
805
  initial: stateNameSchema.default("rest"),
756
- states: z11.record(z11.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
806
+ states: z12.record(z12.string(), stateDefSchema).default({}).refine((rec) => Object.keys(rec).every(isStateName), {
757
807
  message: `state names are ${coreStateNames.join(", ")} or "custom:<name>"`
758
808
  }),
759
809
  /** World-units drag distance that flips pressed → picked (pick-enabled behaviors only). */
760
- pickThreshold: z11.number().min(5e-3).max(1).default(0.1)
810
+ pickThreshold: z12.number().min(5e-3).max(1).default(0.1)
761
811
  });
762
- var metaSchema = z11.object({
763
- name: z11.string().default("untitled"),
764
- author: z11.string().optional(),
765
- version: z11.string().default("0"),
766
- tags: z11.array(z11.string()).default([])
812
+ var metaSchema = z12.object({
813
+ name: z12.string().default("untitled"),
814
+ author: z12.string().optional(),
815
+ version: z12.string().default("0"),
816
+ tags: z12.array(z12.string()).default([])
767
817
  });
768
- var paperConfigSchema = z11.object({
818
+ var paperConfigSchema = z12.object({
769
819
  meta: metaSchema.default({}),
770
820
  sheet: sheetSchema.default({}),
771
821
  stock: stockSchema.default("printer"),
772
822
  content: contentSchema.default({ type: "blank" }),
773
823
  /** A behavior OR a raw deformer stack — if both are present, `deformers` wins (it's the fork). */
774
824
  behavior: behaviorConfigSchema.optional(),
775
- deformers: z11.array(deformerInstanceSchema).optional(),
825
+ deformers: z12.array(deformerInstanceSchema).optional(),
776
826
  surface: surfaceSchema.default({}),
777
827
  physics: physicsSchema.default("none"),
778
828
  scene: sceneSchema.default({}),
779
- onTwos: z11.boolean().default(false),
829
+ onTwos: z12.boolean().default(false),
780
830
  /** Interaction state machine — overrides-on-base diffs (spec M6 §1). */
781
831
  states: paperStatesSchema.optional()
782
832
  }).superRefine((config, ctx) => {
783
833
  if (typeof config.physics === "object" && (config.behavior || config.deformers)) {
784
834
  ctx.addIssue({
785
- code: z11.ZodIssueCode.custom,
835
+ code: z12.ZodIssueCode.custom,
786
836
  path: ["physics"],
787
837
  message: "cloth physics and behavior/deformers are exclusive \u2014 cloth owns the vertices (pick Shape OR Simulation)"
788
838
  });
@@ -793,7 +843,7 @@ var paperConfigSchema = z11.object({
793
843
  if (!def) continue;
794
844
  if (def.overrides.states !== void 0) {
795
845
  ctx.addIssue({
796
- code: z11.ZodIssueCode.custom,
846
+ code: z12.ZodIssueCode.custom,
797
847
  path: ["states", "states", name, "overrides"],
798
848
  message: "state overrides cannot override `states` (no nested state machines)"
799
849
  });
@@ -804,7 +854,7 @@ var paperConfigSchema = z11.object({
804
854
  if (!result.success) {
805
855
  const first = result.error.issues[0];
806
856
  ctx.addIssue({
807
- code: z11.ZodIssueCode.custom,
857
+ code: z12.ZodIssueCode.custom,
808
858
  path: ["states", "states", name, "overrides"],
809
859
  message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")} \u2014 ${first.message}` : "invalid"}`
810
860
  });
@@ -848,6 +898,7 @@ var stocks = {
848
898
  color: "#fbfaf7",
849
899
  roughness: 0.88,
850
900
  opacity: 1,
901
+ translucency: 0.2,
851
902
  inkColor: "#222222",
852
903
  banding: 0,
853
904
  defaultSurface: { grain: 0.12 },
@@ -860,6 +911,7 @@ var stocks = {
860
911
  color: "#f6f3e9",
861
912
  roughness: 0.62,
862
913
  opacity: 1,
914
+ translucency: 0.34,
863
915
  inkColor: "#3a3a3a",
864
916
  banding: 0.35,
865
917
  defaultSurface: { aging: 0.1 },
@@ -872,6 +924,7 @@ var stocks = {
872
924
  color: "#c9a06c",
873
925
  roughness: 0.96,
874
926
  opacity: 1,
927
+ translucency: 0.08,
875
928
  inkColor: "#33261a",
876
929
  banding: 0,
877
930
  defaultSurface: { grain: 0.5 },
@@ -884,6 +937,7 @@ var stocks = {
884
937
  color: "#e9e4d6",
885
938
  roughness: 0.95,
886
939
  opacity: 1,
940
+ translucency: 0.38,
887
941
  inkColor: "#3d3a34",
888
942
  banding: 0,
889
943
  defaultSurface: { grain: 0.7, aging: 0.15 },
@@ -896,6 +950,7 @@ var stocks = {
896
950
  color: "#f4f2ec",
897
951
  roughness: 0.42,
898
952
  opacity: 0.62,
953
+ translucency: 0.86,
899
954
  inkColor: "#4a453d",
900
955
  banding: 0,
901
956
  defaultSurface: {},
@@ -908,6 +963,7 @@ var stocks = {
908
963
  color: "#ffffff",
909
964
  roughness: 0.22,
910
965
  opacity: 1,
966
+ translucency: 0.03,
911
967
  inkColor: "#111111",
912
968
  banding: 0,
913
969
  defaultSurface: {},
@@ -922,6 +978,7 @@ var stocks = {
922
978
  color: "#ffffff",
923
979
  roughness: 0.3,
924
980
  opacity: 1,
981
+ translucency: 0.06,
925
982
  inkColor: "#1a1a1a",
926
983
  banding: 0,
927
984
  defaultSurface: {},
@@ -1079,7 +1136,25 @@ var builtins = {
1079
1136
  type: "image",
1080
1137
  src: "https://images.unsplash.com/photo-1501854140801-50d01698950b?w=1200&q=80",
1081
1138
  fit: "cover"
1082
- }
1139
+ },
1140
+ // No print lies perfectly flat. A shade of bow is the whole difference
1141
+ // between a sheet of paper and a rectangle — and since this is the field
1142
+ // starter, it is what a layout's per-sheet bias has to scale.
1143
+ deformers: [{ type: "bend", options: { curvature: 0.35, angle: 0 } }]
1144
+ },
1145
+ "crumpled-note": {
1146
+ meta: { name: "Crumpled note", tags: ["crumple", "text", "handled"] },
1147
+ sheet: { width: 1.1, height: 1.4 },
1148
+ stock: "printer",
1149
+ content: {
1150
+ type: "text",
1151
+ text: "I wrote it out three times\nand threw all three away.",
1152
+ size: 42
1153
+ },
1154
+ behavior: { type: "crumple", progress: 0.62, coarseness: 0.4, ball: 0.55 },
1155
+ // Handled paper is dirty paper: the grain is what stops the facets
1156
+ // reading as folded plastic.
1157
+ surface: { grain: 0.5, aging: 0.18 }
1083
1158
  },
1084
1159
  "typed-note": {
1085
1160
  meta: { name: "Typed note", tags: ["text", "starter"] },
@@ -1315,17 +1390,20 @@ function useContentTexture(content, sheet2, stock) {
1315
1390
  // src/deformers/compose.ts
1316
1391
  import * as THREE3 from "three";
1317
1392
 
1393
+ // src/deformers/registry.ts
1394
+ import { z as z19 } from "zod";
1395
+
1318
1396
  // src/deformers/roll.ts
1319
- import { z as z12 } from "zod";
1320
- var rollOptionsSchema = z12.object({
1397
+ import { z as z13 } from "zod";
1398
+ var rollOptionsSchema = z13.object({
1321
1399
  /** Direction of rolling in the sheet plane, degrees. 0 = +x, 90 = +y. */
1322
- angle: z12.number().min(-360).max(360).default(90),
1400
+ angle: z13.number().min(-360).max(360).default(90),
1323
1401
  /** Signed distance (along the roll direction, from sheet center) where the roll begins. */
1324
- boundary: z12.number().min(-20).max(20).default(0),
1402
+ boundary: z13.number().min(-20).max(20).default(0),
1325
1403
  /** Cylinder radius — sharpness of the roll. */
1326
- radius: z12.number().min(0.01).max(2).default(0.12),
1404
+ radius: z13.number().min(0.01).max(2).default(0.12),
1327
1405
  /** Radius growth per radian so multi-turn rolls spiral instead of z-fighting. */
1328
- spiral: z12.number().min(0).max(0.2).default(0.015)
1406
+ spiral: z13.number().min(0).max(0.2).default(0.015)
1329
1407
  });
1330
1408
  var DEG2 = Math.PI / 180;
1331
1409
  var roll = {
@@ -1380,15 +1458,20 @@ void FN(inout vec3 p, vec2 uv, float t) {
1380
1458
  };
1381
1459
 
1382
1460
  // src/deformers/bend.ts
1383
- import { z as z13 } from "zod";
1384
- var bendOptionsSchema = z13.object({
1461
+ import { z as z14 } from "zod";
1462
+ var bendOptionsSchema = z14.object({
1385
1463
  /** 1/radius in world units; sign flips the arc direction. 0 = flat. */
1386
- curvature: z13.number().min(-4).max(4).default(0.6),
1464
+ curvature: z14.number().min(-4).max(4).default(0.6),
1387
1465
  /** Bend axis direction in the sheet plane, degrees. 0 bends across x. */
1388
- angle: z13.number().min(-360).max(360).default(0)
1466
+ angle: z14.number().min(-360).max(360).default(0)
1389
1467
  });
1390
1468
  var DEG3 = Math.PI / 180;
1391
1469
  var EPS = 1e-5;
1470
+ function sinMinusX(x) {
1471
+ if (Math.abs(x) > 1) return Math.sin(x) - x;
1472
+ const x2 = x * x;
1473
+ return -x * x2 / 6 * (1 - x2 / 20 * (1 - x2 / 42 * (1 - x2 / 72)));
1474
+ }
1392
1475
  var bend = {
1393
1476
  id: "bend",
1394
1477
  label: "Bend",
@@ -1401,49 +1484,56 @@ var bend = {
1401
1484
  const dirY = Math.sin(o.angle * DEG3);
1402
1485
  const d = out.x * dirX + out.y * dirY;
1403
1486
  const r = 1 / o.curvature;
1404
- const theta = d / r;
1487
+ const theta = d * o.curvature;
1405
1488
  const sin = Math.sin(theta);
1406
- const cos = Math.cos(theta);
1407
- const newD = (r - out.z) * sin;
1408
- const newZ = r * (1 - cos) + out.z * cos;
1409
- out.x += dirX * (newD - d);
1410
- out.y += dirY * (newD - d);
1411
- out.z = newZ;
1489
+ const halfSin = Math.sin(theta * 0.5);
1490
+ const z0 = out.z;
1491
+ const shift = r * sinMinusX(theta) - z0 * sin;
1492
+ out.x += dirX * shift;
1493
+ out.y += dirY * shift;
1494
+ out.z = 2 * r * halfSin * halfSin + z0 * Math.cos(theta);
1412
1495
  },
1413
1496
  glsl: {
1414
1497
  chunk: (
1415
1498
  /* glsl */
1416
1499
  `
1500
+ float FN_sinm(float x) {
1501
+ if (abs(x) > 1.0) return sin(x) - x;
1502
+ float x2 = x * x;
1503
+ return (-x * x2 / 6.0) * (1.0 - (x2 / 20.0) * (1.0 - (x2 / 42.0) * (1.0 - x2 / 72.0)));
1504
+ }
1505
+
1417
1506
  void FN(inout vec3 p, vec2 uv, float t) {
1418
1507
  if (abs(U_curvature) < 1e-5) return;
1419
1508
  vec2 dir = vec2(cos(U_angle), sin(U_angle));
1420
1509
  float d = dot(p.xy, dir);
1421
1510
  float r = 1.0 / U_curvature;
1422
- float theta = d / r;
1511
+ float theta = d * U_curvature;
1423
1512
  float sn = sin(theta);
1424
- float cs = cos(theta);
1425
- float newD = (r - p.z) * sn;
1426
- float newZ = r * (1.0 - cs) + p.z * cs;
1427
- p.xy += dir * (newD - d);
1428
- p.z = newZ;
1513
+ float hs = sin(theta * 0.5);
1514
+ float z0 = p.z;
1515
+ float shift = r * FN_sinm(theta) - z0 * sn;
1516
+ p.xy += dir * shift;
1517
+ p.z = 2.0 * r * hs * hs + z0 * cos(theta);
1429
1518
  }
1430
1519
  `
1431
1520
  ),
1521
+ strength: "curvature",
1432
1522
  uniforms: (o) => ({ curvature: o.curvature, angle: o.angle * DEG3 })
1433
1523
  }
1434
1524
  };
1435
1525
 
1436
1526
  // src/deformers/fold.ts
1437
- import { z as z14 } from "zod";
1438
- var foldOptionsSchema = z14.object({
1527
+ import { z as z15 } from "zod";
1528
+ var foldOptionsSchema = z15.object({
1439
1529
  /** Direction of the fold travel in the sheet plane, degrees (the crease line runs perpendicular). */
1440
- angle: z14.number().min(-360).max(360).default(90),
1530
+ angle: z15.number().min(-360).max(360).default(90),
1441
1531
  /** Signed distance of the crease line from the sheet center, along the travel direction. */
1442
- offset: z14.number().min(-20).max(20).default(0),
1532
+ offset: z15.number().min(-20).max(20).default(0),
1443
1533
  /** How far the flap folds over, degrees. 180 = flat against the sheet. */
1444
- foldAngle: z14.number().min(-180).max(180).default(90),
1534
+ foldAngle: z15.number().min(-180).max(180).default(90),
1445
1535
  /** Width of the soft hinge — paper never creases to a mathematical edge. */
1446
- radius: z14.number().min(5e-3).max(0.5).default(0.04)
1536
+ radius: z15.number().min(5e-3).max(0.5).default(0.04)
1447
1537
  });
1448
1538
  var DEG4 = Math.PI / 180;
1449
1539
  var fold = {
@@ -1511,6 +1601,7 @@ void FN(inout vec3 p, vec2 uv, float t) {
1511
1601
  }
1512
1602
  `
1513
1603
  ),
1604
+ strength: "foldAngle",
1514
1605
  uniforms: (o) => ({
1515
1606
  angle: o.angle * DEG4,
1516
1607
  offset: o.offset,
@@ -1521,16 +1612,16 @@ void FN(inout vec3 p, vec2 uv, float t) {
1521
1612
  };
1522
1613
 
1523
1614
  // src/deformers/wave.ts
1524
- import { z as z15 } from "zod";
1525
- var waveOptionsSchema = z15.object({
1526
- amplitude: z15.number().min(0).max(0.3).default(0.04),
1527
- wavelength: z15.number().min(0.05).max(2).default(0.5),
1615
+ import { z as z16 } from "zod";
1616
+ var waveOptionsSchema = z16.object({
1617
+ amplitude: z16.number().min(0).max(0.3).default(0.04),
1618
+ wavelength: z16.number().min(0.05).max(2).default(0.5),
1528
1619
  /** Travel speed; 0 freezes the ripple. */
1529
- speed: z15.number().min(0).max(3).default(0.8),
1620
+ speed: z16.number().min(0).max(3).default(0.8),
1530
1621
  /** Travel direction in the sheet plane, degrees. */
1531
- angle: z15.number().min(-360).max(360).default(90),
1622
+ angle: z16.number().min(-360).max(360).default(90),
1532
1623
  /** Zero the displacement at one edge (a taped/pinned edge doesn't ripple). */
1533
- pinnedEdge: z15.enum(["none", "top", "bottom", "left", "right"]).default("none")
1624
+ pinnedEdge: z16.enum(["none", "top", "bottom", "left", "right"]).default("none")
1534
1625
  });
1535
1626
  var DEG5 = Math.PI / 180;
1536
1627
  var TAU = Math.PI * 2;
@@ -1572,6 +1663,7 @@ void FN(inout vec3 p, vec2 uv, float t) {
1572
1663
  }
1573
1664
  `
1574
1665
  ),
1666
+ strength: "amplitude",
1575
1667
  uniforms: (o) => ({
1576
1668
  amplitude: o.amplitude,
1577
1669
  wavelength: o.wavelength,
@@ -1582,6 +1674,188 @@ void FN(inout vec3 p, vec2 uv, float t) {
1582
1674
  }
1583
1675
  };
1584
1676
 
1677
+ // src/deformers/drape.ts
1678
+ import { z as z17 } from "zod";
1679
+ var drapeOptionsSchema = z17.object({
1680
+ /** Fold depth at the free edge, world units. */
1681
+ amplitude: z17.number().min(0).max(0.6).default(0.12),
1682
+ /** How many folds run down the drop. */
1683
+ folds: z17.number().min(0.5).max(16).default(4),
1684
+ /**
1685
+ * How fast folds deepen away from the pinned edge. 1 is linear; higher
1686
+ * holds the top flat and gathers all the movement at the free end, which
1687
+ * is what a sheet hung from a rod actually does.
1688
+ */
1689
+ falloff: z17.number().min(0.3).max(4).default(1.6),
1690
+ /** How much a second, non-harmonic fold breaks the regularity. */
1691
+ irregular: z17.number().min(0).max(1).default(0.45),
1692
+ /** How much the sheet narrows as its folds deepen. */
1693
+ gather: z17.number().min(0).max(1).default(0.5),
1694
+ pinnedEdge: z17.enum(["top", "bottom"]).default("top")
1695
+ });
1696
+ var TAU2 = Math.PI * 2;
1697
+ var drape = {
1698
+ id: "drape",
1699
+ label: "Drape",
1700
+ defaults: drapeOptionsSchema.parse({}),
1701
+ optionsSchema: drapeOptionsSchema,
1702
+ geometry: { minSegments: 48 },
1703
+ displace(out, uv, o) {
1704
+ if (o.amplitude === 0) return;
1705
+ const drop = o.pinnedEdge === "top" ? 1 - uv.y : uv.y;
1706
+ const depth = drop ** o.falloff;
1707
+ const u = uv.x * TAU2 * o.folds;
1708
+ const fold2 = Math.sin(u) + o.irregular * 0.6 * Math.sin(u * 1.7 + 2.1);
1709
+ out.z += o.amplitude * depth * fold2;
1710
+ const pinch = o.gather * depth * Math.min(o.amplitude * o.folds * 0.8, 0.6);
1711
+ out.x *= 1 - pinch;
1712
+ },
1713
+ glsl: {
1714
+ chunk: (
1715
+ /* glsl */
1716
+ `
1717
+ void FN(inout vec3 p, vec2 uv, float t) {
1718
+ if (U_amplitude == 0.0) return;
1719
+ float drop = U_pin == 1.0 ? 1.0 - uv.y : uv.y;
1720
+ float depth = pow(drop, U_falloff);
1721
+ float u = uv.x * 6.283185307179586 * U_folds;
1722
+ float fold = sin(u) + U_irregular * 0.6 * sin(u * 1.7 + 2.1);
1723
+ p.z += U_amplitude * depth * fold;
1724
+ float pinch = U_gather * depth * min(U_amplitude * U_folds * 0.8, 0.6);
1725
+ p.x *= 1.0 - pinch;
1726
+ }
1727
+ `
1728
+ ),
1729
+ strength: "amplitude",
1730
+ uniforms: (o) => ({
1731
+ amplitude: o.amplitude,
1732
+ folds: o.folds,
1733
+ falloff: o.falloff,
1734
+ irregular: o.irregular,
1735
+ gather: o.gather,
1736
+ pin: o.pinnedEdge === "top" ? 1 : 2
1737
+ })
1738
+ }
1739
+ };
1740
+
1741
+ // src/deformers/crumple.ts
1742
+ import { z as z18 } from "zod";
1743
+ var crumpleOptionsSchema = z18.object({
1744
+ /** How crushed, 0..1. Peak-to-peak height, and it drives the pull too. */
1745
+ amount: z18.number().min(0).max(1).default(0.35),
1746
+ /** Facets per world unit. Higher is finer, and needs more segments to resolve. */
1747
+ scale: z18.number().min(0.5).max(8).default(3),
1748
+ /**
1749
+ * How much the sheet draws in on itself. Crumpled paper occupies a smaller
1750
+ * footprint than flat paper; without this it reads as an embossed sheet
1751
+ * rather than a crushed one.
1752
+ */
1753
+ pull: z18.number().min(0).max(1).default(0.4),
1754
+ /** A different crush of the same paper. */
1755
+ seed: z18.number().int().min(0).max(7).default(0)
1756
+ });
1757
+ function mod(x, y) {
1758
+ return x - y * Math.floor(x / y);
1759
+ }
1760
+ function jitter(cx, cy, seed) {
1761
+ const hx = mod(cx * 37 + cy * 17 + seed * 5, 64);
1762
+ const hy = mod(cx * 23 + cy * 41 + seed * 11, 64);
1763
+ return [0.2 + 0.6 * mod(hx * 13, 7) / 6, 0.2 + 0.6 * mod(hy * 29, 11) / 10];
1764
+ }
1765
+ var NORM = 0.5366;
1766
+ function cellSign(cx, cy, seed) {
1767
+ return 1 - 2 * mod(cx * 11 + cy * 7 + seed * 3, 2);
1768
+ }
1769
+ var crumple = {
1770
+ id: "crumple",
1771
+ label: "Crumple",
1772
+ defaults: crumpleOptionsSchema.parse({}),
1773
+ optionsSchema: crumpleOptionsSchema,
1774
+ // A crease the grid cannot resolve is a smooth bump, and a sheet of smooth
1775
+ // bumps is not a crumple. Note this is a FLOOR, and `segments: 'auto'`
1776
+ // already hands the long side 72 — so this only bites when a preset asks
1777
+ // for a coarser grid by hand. Measured (`pnpm perf:field`), the real cost
1778
+ // of this deformer is not geometry at all: it is nine cell evaluations per
1779
+ // probe and three probes per vertex for the normal.
1780
+ geometry: { minSegments: 72 },
1781
+ displace(out, _uv, o) {
1782
+ if (o.amount === 0) return;
1783
+ const qx = out.x * o.scale;
1784
+ const qy = out.y * o.scale;
1785
+ const gx = Math.floor(qx);
1786
+ const gy = Math.floor(qy);
1787
+ let f1 = 1e9;
1788
+ let f2 = 1e9;
1789
+ let winX = gx;
1790
+ let winY = gy;
1791
+ for (let dy = -1; dy <= 1; dy++) {
1792
+ for (let dx = -1; dx <= 1; dx++) {
1793
+ const cx = gx + dx;
1794
+ const cy = gy + dy;
1795
+ const [jx, jy] = jitter(cx, cy, o.seed);
1796
+ const ex = cx + jx - qx;
1797
+ const ey = cy + jy - qy;
1798
+ const dist = Math.sqrt(ex * ex + ey * ey);
1799
+ if (dist < f1) {
1800
+ f2 = f1;
1801
+ f1 = dist;
1802
+ winX = cx;
1803
+ winY = cy;
1804
+ } else if (dist < f2) {
1805
+ f2 = dist;
1806
+ }
1807
+ }
1808
+ }
1809
+ out.z += cellSign(winX, winY, o.seed) * (f2 - f1) * o.amount * NORM;
1810
+ const pull = 1 - o.amount * o.pull * 0.35;
1811
+ out.x *= pull;
1812
+ out.y *= pull;
1813
+ },
1814
+ glsl: {
1815
+ chunk: (
1816
+ /* glsl */
1817
+ `
1818
+ vec2 FN_jitter(float cx, float cy, float seed) {
1819
+ float hx = mod(cx * 37.0 + cy * 17.0 + seed * 5.0, 64.0);
1820
+ float hy = mod(cx * 23.0 + cy * 41.0 + seed * 11.0, 64.0);
1821
+ return vec2(0.2 + 0.6 * mod(hx * 13.0, 7.0) / 6.0, 0.2 + 0.6 * mod(hy * 29.0, 11.0) / 10.0);
1822
+ }
1823
+
1824
+ float FN_sign(float cx, float cy, float seed) {
1825
+ return 1.0 - 2.0 * mod(cx * 11.0 + cy * 7.0 + seed * 3.0, 2.0);
1826
+ }
1827
+
1828
+ void FN(inout vec3 p, vec2 uv, float t) {
1829
+ if (U_amount == 0.0) return;
1830
+ vec2 flat2 = p.xy;
1831
+ vec2 q = flat2 * U_scale;
1832
+ vec2 g = floor(q);
1833
+
1834
+ float f1 = 1e9;
1835
+ float f2 = 1e9;
1836
+ vec2 win = g;
1837
+ for (int dy = -1; dy <= 1; dy++) {
1838
+ for (int dx = -1; dx <= 1; dx++) {
1839
+ vec2 c = g + vec2(float(dx), float(dy));
1840
+ float dist = length(c + FN_jitter(c.x, c.y, U_seed) - q);
1841
+ if (dist < f1) { f2 = f1; f1 = dist; win = c; }
1842
+ else if (dist < f2) { f2 = dist; }
1843
+ }
1844
+ }
1845
+
1846
+ p.z += FN_sign(win.x, win.y, U_seed) * (f2 - f1) * U_amount * ${NORM};
1847
+ float pull = 1.0 - U_amount * U_pull * 0.35;
1848
+ p.xy = flat2 * pull;
1849
+ }
1850
+ `
1851
+ ),
1852
+ // `amount` drives both the height and the pull, so a field instance's
1853
+ // bias scales the whole crush rather than half of it.
1854
+ strength: "amount",
1855
+ uniforms: (o) => ({ amount: o.amount, scale: o.scale, pull: o.pull, seed: o.seed })
1856
+ }
1857
+ };
1858
+
1585
1859
  // src/deformers/registry.ts
1586
1860
  var registry = /* @__PURE__ */ new Map();
1587
1861
  function registerDeformer(deformer) {
@@ -1602,6 +1876,26 @@ registerDeformer(curl);
1602
1876
  registerDeformer(bend);
1603
1877
  registerDeformer(fold);
1604
1878
  registerDeformer(wave);
1879
+ registerDeformer(drape);
1880
+ registerDeformer(crumple);
1881
+ function resolveDeformerStack(raw) {
1882
+ return raw.map((instance, i) => {
1883
+ const deformer = getDeformer(instance.type);
1884
+ const schema = deformer.optionsSchema instanceof z19.ZodObject ? deformer.optionsSchema.strict() : deformer.optionsSchema;
1885
+ const parsed = schema.safeParse(instance.options ?? {});
1886
+ if (!parsed.success) {
1887
+ const issue = parsed.error.issues[0];
1888
+ throw new Error(
1889
+ `[paperlab] deformers[${i}] ("${instance.type}"): ${issue ? `${issue.path.join(".") || "options"} \u2014 ${issue.message}` : "invalid options"}`
1890
+ );
1891
+ }
1892
+ return {
1893
+ type: instance.type,
1894
+ options: parsed.data,
1895
+ enabled: instance.enabled
1896
+ };
1897
+ });
1898
+ }
1605
1899
  function stackIsAnimated(stack) {
1606
1900
  return stack.some((i) => i.enabled !== false && registry.get(i.type)?.animated);
1607
1901
  }
@@ -1669,6 +1963,7 @@ registerBehavior(fly);
1669
1963
  registerBehavior(fall);
1670
1964
  registerBehavior(carry);
1671
1965
  registerBehavior(flight);
1966
+ registerBehavior(crumpleBehavior);
1672
1967
 
1673
1968
  // src/physics/idle.ts
1674
1969
  var idleNames = ["float", "tumble", "dangle", "taped", "breeze"];
@@ -1817,13 +2112,13 @@ var ClothSim = class {
1817
2112
  this.stillFrames = 0;
1818
2113
  }
1819
2114
  /** Nearest particle to a local-space point — the grab interface. */
1820
- grabNearest(x, y, z18) {
2115
+ grabNearest(x, y, z27) {
1821
2116
  let best = -1;
1822
2117
  let bestDist = Infinity;
1823
2118
  for (let i = 0; i < this.count; i++) {
1824
2119
  const dx = this.positions[i * 3] - x;
1825
2120
  const dy = this.positions[i * 3 + 1] - y;
1826
- const dz = this.positions[i * 3 + 2] - z18;
2121
+ const dz = this.positions[i * 3 + 2] - z27;
1827
2122
  const d = dx * dx + dy * dy + dz * dz;
1828
2123
  if (d < bestDist) {
1829
2124
  bestDist = d;
@@ -1834,15 +2129,15 @@ var ClothSim = class {
1834
2129
  this.wake();
1835
2130
  return best;
1836
2131
  }
1837
- moveGrab(x, y, z18) {
2132
+ moveGrab(x, y, z27) {
1838
2133
  if (this.grabbedIndex < 0) return;
1839
2134
  const i3 = this.grabbedIndex * 3;
1840
2135
  this.positions[i3] = x;
1841
2136
  this.positions[i3 + 1] = y;
1842
- this.positions[i3 + 2] = z18;
2137
+ this.positions[i3 + 2] = z27;
1843
2138
  this.prev[i3] = x;
1844
2139
  this.prev[i3 + 1] = y;
1845
- this.prev[i3 + 2] = z18;
2140
+ this.prev[i3 + 2] = z27;
1846
2141
  this.wake();
1847
2142
  }
1848
2143
  release() {
@@ -1878,19 +2173,19 @@ var ClothSim = class {
1878
2173
  }
1879
2174
  const x = p[i3];
1880
2175
  const y = p[i3 + 1];
1881
- const z18 = p[i3 + 2];
2176
+ const z27 = p[i3 + 2];
1882
2177
  const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
1883
2178
  const ax = gust2 * 0.25;
1884
2179
  const az = gust2;
1885
2180
  const vx = (x - this.prev[i3]) * damping;
1886
2181
  const vy = (y - this.prev[i3 + 1]) * damping;
1887
- const vz = (z18 - this.prev[i3 + 2]) * damping;
2182
+ const vz = (z27 - this.prev[i3 + 2]) * damping;
1888
2183
  this.prev[i3] = x;
1889
2184
  this.prev[i3 + 1] = y;
1890
- this.prev[i3 + 2] = z18;
2185
+ this.prev[i3 + 2] = z27;
1891
2186
  p[i3] = x + vx + ax * dt2;
1892
2187
  p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
1893
- p[i3 + 2] = z18 + vz + az * dt2;
2188
+ p[i3 + 2] = z27 + vz + az * dt2;
1894
2189
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
1895
2190
  }
1896
2191
  for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
@@ -1936,18 +2231,171 @@ var ClothSim = class {
1936
2231
  };
1937
2232
 
1938
2233
  // src/surface/PaperMaterial.tsx
1939
- import * as THREE5 from "three";
2234
+ import * as THREE6 from "three";
1940
2235
  import { useEffect as useEffect2, useMemo } from "react";
1941
2236
  import CustomShaderMaterial from "three-custom-shader-material";
1942
2237
 
1943
2238
  // src/surface/compose.ts
2239
+ import * as THREE5 from "three";
2240
+
2241
+ // src/surface/translucency.ts
1944
2242
  import * as THREE4 from "three";
2243
+
2244
+ // src/scene/lighting.ts
2245
+ var lightingPresets = {
2246
+ studio: {
2247
+ id: "studio",
2248
+ label: "Studio",
2249
+ ambient: 0.65,
2250
+ key: { color: "#ffffff", intensity: 1.6, position: [2.5, 4, 3] },
2251
+ contactShadowOpacity: 0.3,
2252
+ contactShadowBlur: 2.4,
2253
+ exposure: 1,
2254
+ shadow: { mapSize: 1024, radius: 4 }
2255
+ },
2256
+ window: {
2257
+ id: "window",
2258
+ label: "Window",
2259
+ ambient: 0.5,
2260
+ key: { color: "#ffe3c0", intensity: 1.9, position: [3, 2.6, 2.6] },
2261
+ contactShadowOpacity: 0.35,
2262
+ contactShadowBlur: 2.6,
2263
+ exposure: 1,
2264
+ shadow: { mapSize: 1024, radius: 5 },
2265
+ gobo: { kind: "blinds", drift: 4e-3, angle: 0.62 }
2266
+ },
2267
+ leaves: {
2268
+ id: "leaves",
2269
+ label: "Leaves",
2270
+ ambient: 0.45,
2271
+ key: { color: "#fff2d8", intensity: 2, position: [2.2, 3.6, 2.4] },
2272
+ contactShadowOpacity: 0.4,
2273
+ contactShadowBlur: 2.8,
2274
+ exposure: 1,
2275
+ shadow: { mapSize: 1024, radius: 6 },
2276
+ gobo: { kind: "leaves", drift: 0.012, angle: 0.7 }
2277
+ },
2278
+ goldenhour: {
2279
+ id: "goldenhour",
2280
+ label: "Golden hour",
2281
+ ambient: 0.32,
2282
+ key: { color: "#ffb066", intensity: 2.4, position: [4, 0.9, 2.2] },
2283
+ contactShadowOpacity: 0.45,
2284
+ contactShadowBlur: 3.2,
2285
+ exposure: 1.15,
2286
+ shadow: { mapSize: 1024, radius: 7 }
2287
+ },
2288
+ noir: {
2289
+ id: "noir",
2290
+ label: "Noir",
2291
+ ambient: 0.07,
2292
+ key: { color: "#ffffff", intensity: 2.6, position: [2, 3, 1.6] },
2293
+ contactShadowOpacity: 0.7,
2294
+ contactShadowBlur: 1.1,
2295
+ exposure: 1.05,
2296
+ shadow: { mapSize: 2048, radius: 1 }
2297
+ },
2298
+ nave: {
2299
+ id: "nave",
2300
+ label: "Nave",
2301
+ // Dim, and the key sits BEHIND the walk rather than beside it: this mode
2302
+ // is carried by light coming through the paper, not off it. Ambient is
2303
+ // nearly nothing so the only bright thing in frame is the source itself.
2304
+ ambient: 0.09,
2305
+ key: { color: "#fff1dc", intensity: 3.4, position: [0, 7, -16] },
2306
+ contactShadowOpacity: 0.55,
2307
+ contactShadowBlur: 3.6,
2308
+ exposure: 1.2,
2309
+ shadow: { mapSize: 2048, radius: 6 },
2310
+ // Warm and light, not black: distance in a backlit hall washes TOWARD
2311
+ // the source, which is what separates haze from murk.
2312
+ fog: { color: "#c9baa3", near: 5, far: 38 }
2313
+ }
2314
+ };
2315
+ function getLightingPreset(name) {
2316
+ return lightingPresets[name];
2317
+ }
2318
+
2319
+ // src/surface/translucency.ts
2320
+ var TRANSLUCENCY_VARYINGS = (
2321
+ /* glsl */
2322
+ `
2323
+ varying vec3 vPlWorldNormal;
2324
+ varying vec3 vPlViewDir;
2325
+ `
2326
+ );
2327
+ function translucencyVertexChunk(slots) {
2328
+ return (
2329
+ /* glsl */
2330
+ `
2331
+ {
2332
+ mat4 plModel = ${slots.model};
2333
+ vec4 plWorld = plModel * vec4(${slots.position}, 1.0);
2334
+ // Uniform scale only \u2014 layouts scale sheets evenly, so the plain 3\xD73 is
2335
+ // the correct normal matrix here and skips an inverse-transpose.
2336
+ vPlWorldNormal = normalize(mat3(plModel) * ${slots.normal});
2337
+ vPlViewDir = cameraPosition - plWorld.xyz;
2338
+ }
2339
+ `
2340
+ );
2341
+ }
2342
+ var TRANSMISSION_GAIN = 0.5;
2343
+ var TRANSLUCENCY_FRAGMENT = (
2344
+ /* glsl */
2345
+ `
2346
+ uniform float uTranslucency;
2347
+ uniform vec3 uBackLightDir;
2348
+ uniform vec3 uBackLightColor;
2349
+ uniform float uAmbientTransmission;
2350
+ ${TRANSLUCENCY_VARYINGS}
2351
+
2352
+ vec3 plTransmission(vec3 inkFilter) {
2353
+ if (uTranslucency <= 0.0) return vec3(0.0);
2354
+ vec3 n = normalize(vPlWorldNormal);
2355
+ // Sheets render double-sided; the back face needs the normal it actually shows.
2356
+ if (!gl_FrontFacing) n = -n;
2357
+ // The lamp is BEHIND this sheet when the face we are looking at points away
2358
+ // from it \u2014 that is the whole test.
2359
+ float behind = clamp(-dot(n, uBackLightDir), 0.0, 1.0);
2360
+ // A grazing view looks through more paper, and more paper passes less light.
2361
+ float thickness = abs(dot(n, normalize(vPlViewDir)));
2362
+ // Paper in a lit room glows whatever way it is turned \u2014 a sheet standing
2363
+ // edge-on to the only lamp is not black. Without this floor, a banner
2364
+ // whose face runs parallel to the key light gets neither diffuse nor
2365
+ // transmission and drops out of the picture entirely.
2366
+ vec3 arriving = uBackLightColor * behind + uAmbientTransmission;
2367
+ return arriving * uTranslucency * mix(0.25, 1.0, thickness) * inkFilter;
2368
+ }
2369
+ `
2370
+ );
2371
+ function translucencyValues(translucency, lighting) {
2372
+ const preset = getLightingPreset(lighting);
2373
+ const [x, y, z27] = preset.key.position;
2374
+ const direction = new THREE4.Vector3(x, y, z27);
2375
+ if (direction.lengthSq() < 1e-12) direction.set(0, 1, 0);
2376
+ direction.normalize();
2377
+ const color = new THREE4.Color(preset.key.color).multiplyScalar(preset.key.intensity * TRANSMISSION_GAIN);
2378
+ return { translucency, direction, color, ambient: preset.ambient * TRANSMISSION_GAIN };
2379
+ }
2380
+ function translucencyUniforms(translucency, lighting) {
2381
+ const values = translucencyValues(translucency, lighting);
2382
+ return {
2383
+ uTranslucency: { value: values.translucency },
2384
+ uBackLightDir: { value: values.direction },
2385
+ uBackLightColor: { value: values.color },
2386
+ uAmbientTransmission: { value: values.ambient }
2387
+ };
2388
+ }
2389
+
2390
+ // src/surface/compose.ts
1945
2391
  var VERTEX = (
1946
2392
  /* glsl */
1947
2393
  `
1948
2394
  varying vec2 vPaperUv;
2395
+ ${TRANSLUCENCY_VARYINGS}
1949
2396
  void main() {
1950
2397
  vPaperUv = uv;
2398
+ ${translucencyVertexChunk({ model: "modelMatrix", position: "position", normal: "normal" })}
1951
2399
  }
1952
2400
  `
1953
2401
  );
@@ -1984,7 +2432,7 @@ float plFbm(vec2 p) {
1984
2432
  }
1985
2433
  `
1986
2434
  );
1987
- var edgeFlags = (edges) => new THREE4.Vector4(
2435
+ var edgeFlags = (edges) => new THREE5.Vector4(
1988
2436
  edges.includes("top") ? 1 : 0,
1989
2437
  edges.includes("right") ? 1 : 0,
1990
2438
  edges.includes("bottom") ? 1 : 0,
@@ -2119,7 +2567,7 @@ void plAging(inout vec4 color) {
2119
2567
  }
2120
2568
  `
2121
2569
  );
2122
- function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }) {
2570
+ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
2123
2571
  const grain = surface.grain ?? stock.defaultSurface.grain;
2124
2572
  const aging = surface.aging ?? stock.defaultSurface.aging;
2125
2573
  const deckle = surface.deckle;
@@ -2135,9 +2583,12 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
2135
2583
  uBackDarken: {
2136
2584
  value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
2137
2585
  },
2138
- uStockColor: { value: new THREE4.Color(stock.color) },
2586
+ uStockColor: { value: new THREE5.Color(stock.color) },
2139
2587
  uOpacity: { value: stock.opacity },
2140
- uShowThrough: { value: showThrough }
2588
+ uShowThrough: { value: showThrough },
2589
+ // Always compiled in: the shader early-outs at zero translucency, which
2590
+ // is cheaper than carrying a second program structure for it.
2591
+ ...translucencyUniforms(surface.translucency ?? stock.translucency, lighting)
2141
2592
  };
2142
2593
  if (maps.hasFrontMap) uniforms.uFrontMap = { value: null };
2143
2594
  if (maps.hasBackMap) uniforms.uBackMap = { value: null };
@@ -2159,13 +2610,13 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
2159
2610
  calls.push("plPerforation(csm_DiffuseColor);");
2160
2611
  uniforms.uPerfEdges = { value: edgeFlags(edges) };
2161
2612
  uniforms.uPerfTorn = {
2162
- value: new THREE4.Vector4(
2613
+ value: new THREE5.Vector4(
2163
2614
  ...paperEdges.map((e) => edges.includes(e) && perforation.state[e] === "torn" ? 1 : 0)
2164
2615
  )
2165
2616
  };
2166
2617
  uniforms.uPerfRadius = { value: perforation.holeRadius };
2167
2618
  uniforms.uPerfSpacing = { value: perforation.spacing };
2168
- uniforms.uSheetSize = { value: new THREE4.Vector2(sheet2.width, sheet2.height) };
2619
+ uniforms.uSheetSize = { value: new THREE5.Vector2(sheet2.width, sheet2.height) };
2169
2620
  }
2170
2621
  if (creases) {
2171
2622
  chunks.push(CREASE_CHUNK);
@@ -2191,6 +2642,7 @@ uniform float uOpacity;
2191
2642
  uniform float uShowThrough;
2192
2643
  ${maps.hasFrontMap ? "uniform sampler2D uFrontMap;" : ""}
2193
2644
  ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
2645
+ ${TRANSLUCENCY_FRAGMENT}
2194
2646
  ${chunks.join("\n")}
2195
2647
  void main() {
2196
2648
  vec3 front = ${frontExpr};
@@ -2203,6 +2655,8 @@ void main() {
2203
2655
  ${calls.join("\n ")}
2204
2656
  if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
2205
2657
  ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
2658
+ // What the key light pushes through the sheet, filtered by the ink on it.
2659
+ csm_Emissive = plTransmission(front);
2206
2660
  }
2207
2661
  `
2208
2662
  );
@@ -2235,7 +2689,8 @@ function PaperMaterial({
2235
2689
  backTexture,
2236
2690
  surface,
2237
2691
  thickness,
2238
- sheet: sheet2
2692
+ sheet: sheet2,
2693
+ lighting = "studio"
2239
2694
  }) {
2240
2695
  const composed = composeSurface(
2241
2696
  surface,
@@ -2245,13 +2700,14 @@ function PaperMaterial({
2245
2700
  hasFrontMap: Boolean(texture),
2246
2701
  hasBackMap: Boolean(backTexture)
2247
2702
  },
2248
- sheet2
2703
+ sheet2,
2704
+ lighting
2249
2705
  );
2250
2706
  const bound = useMemo(() => composed.uniforms, [composed.structureKey]);
2251
2707
  useEffect2(() => {
2252
2708
  for (const [key, uniform] of Object.entries(composed.uniforms)) {
2253
2709
  if (!bound[key] || key === "uFrontMap" || key === "uBackMap") continue;
2254
- if (bound[key].value instanceof THREE5.Color && uniform.value instanceof THREE5.Color) {
2710
+ if (bound[key].value instanceof THREE6.Color && uniform.value instanceof THREE6.Color) {
2255
2711
  ;
2256
2712
  bound[key].value.copy(uniform.value);
2257
2713
  } else {
@@ -2266,7 +2722,7 @@ function PaperMaterial({
2266
2722
  return /* @__PURE__ */ jsx(
2267
2723
  CustomShaderMaterial,
2268
2724
  {
2269
- baseMaterial: THREE5.MeshStandardMaterial,
2725
+ baseMaterial: THREE6.MeshStandardMaterial,
2270
2726
  vertexShader: composed.vertexShader,
2271
2727
  fragmentShader: composed.fragmentShader,
2272
2728
  uniforms: bound,
@@ -2276,7 +2732,7 @@ function PaperMaterial({
2276
2732
  transparent: stock.opacity < 1,
2277
2733
  opacity: stock.opacity,
2278
2734
  alphaTest: composed.alphaTest,
2279
- side: THREE5.DoubleSide
2735
+ side: THREE6.DoubleSide
2280
2736
  },
2281
2737
  composed.structureKey
2282
2738
  );
@@ -2700,6 +3156,8 @@ function resolveConfigKey(props) {
2700
3156
  props.content ?? null,
2701
3157
  props.behavior ?? null,
2702
3158
  props.deformers ?? null,
3159
+ props.surface ?? null,
3160
+ props.scene ?? null,
2703
3161
  props.physics ?? null,
2704
3162
  props.onTwos ?? null
2705
3163
  ]);
@@ -2712,17 +3170,19 @@ function resolveConfig(props) {
2712
3170
  if (props.content) overrides.content = props.content;
2713
3171
  if (props.behavior) overrides.behavior = props.behavior;
2714
3172
  if (props.deformers) overrides.deformers = props.deformers;
3173
+ if (props.surface) overrides.surface = { ...base.surface, ...props.surface };
3174
+ if (props.scene) overrides.scene = { ...base.scene, ...props.scene };
2715
3175
  if (props.physics) overrides.physics = props.physics;
2716
3176
  if (props.onTwos !== void 0) overrides.onTwos = props.onTwos;
2717
3177
  return paperConfigSchema.parse(mergeConfig(base, overrides));
2718
3178
  }
2719
3179
  var CLOTH_MAX_SEGMENTS = 28;
2720
- var dragPlane = new THREE6.Plane();
2721
- var dragPoint = new THREE6.Vector3();
2722
- var planeNormal = new THREE6.Vector3();
2723
- var anchorScratch = new THREE6.Vector3();
2724
- var worldScratch = new THREE6.Vector3();
2725
- var quatScratch = new THREE6.Quaternion();
3180
+ var dragPlane = new THREE7.Plane();
3181
+ var dragPoint = new THREE7.Vector3();
3182
+ var planeNormal = new THREE7.Vector3();
3183
+ var anchorScratch = new THREE7.Vector3();
3184
+ var worldScratch = new THREE7.Vector3();
3185
+ var quatScratch = new THREE7.Quaternion();
2726
3186
  var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
2727
3187
  const resolved = useMemo3(() => resolveConfig(props), [resolveConfigKey(props)]);
2728
3188
  const reduced = usePrefersReducedMotion(props.reducedMotion);
@@ -2767,7 +3227,7 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
2767
3227
  if (!isCloth) return createSheetGeometry(config.sheet, minSegments);
2768
3228
  const [sx, sy] = resolveSegments(config.sheet, 2);
2769
3229
  const capped = Math.min(Math.max(sx, sy), CLOTH_MAX_SEGMENTS);
2770
- return new THREE6.PlaneGeometry(config.sheet.width, config.sheet.height, capped, capped);
3230
+ return new THREE7.PlaneGeometry(config.sheet.width, config.sheet.height, capped, capped);
2771
3231
  }, [sheetKey, minSegments, isCloth]);
2772
3232
  useEffect5(() => () => geometry.dispose(), [geometry]);
2773
3233
  const basePositions = useMemo3(
@@ -2958,7 +3418,7 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
2958
3418
  const p = overridesRef.current[behavior.progressParam];
2959
3419
  if (typeof p === "number") props.onProgress?.(p);
2960
3420
  };
2961
- const grabAnchor = useRef2(new THREE6.Vector3());
3421
+ const grabAnchor = useRef2(new THREE7.Vector3());
2962
3422
  const clothDown = (e) => {
2963
3423
  if (!isCloth || !sim || !props.interactive || !groupRef.current) return;
2964
3424
  e.stopPropagation();
@@ -3014,7 +3474,8 @@ var PaperMesh = forwardRef(function PaperMesh2(props, ref) {
3014
3474
  backTexture,
3015
3475
  surface: config.surface,
3016
3476
  thickness: config.sheet.thickness,
3017
- sheet: config.sheet
3477
+ sheet: config.sheet,
3478
+ lighting: config.scene.lighting
3018
3479
  }
3019
3480
  )
3020
3481
  }
@@ -3055,7 +3516,7 @@ function buildStack(config, overrides, behavior, t = 0) {
3055
3516
  const idleStack = idle?.stack?.() ?? [];
3056
3517
  let shapeStack = [];
3057
3518
  if (config.deformers) {
3058
- shapeStack = config.deformers;
3519
+ shapeStack = resolveDeformerStack(config.deformers);
3059
3520
  } else if (config.behavior) {
3060
3521
  const b = behavior ?? getBehavior(config.behavior.type);
3061
3522
  let options = { ...config.behavior, ...overrides };
@@ -3067,71 +3528,10 @@ function buildStack(config, overrides, behavior, t = 0) {
3067
3528
  }
3068
3529
 
3069
3530
  // src/scene/PaperLighting.tsx
3070
- import * as THREE7 from "three";
3531
+ import * as THREE8 from "three";
3071
3532
  import { useEffect as useEffect6, useMemo as useMemo4, useRef as useRef3 } from "react";
3072
3533
  import { useFrame as useFrame2, useThree as useThree2 } from "@react-three/fiber";
3073
3534
  import { ContactShadows } from "@react-three/drei";
3074
-
3075
- // src/scene/lighting.ts
3076
- var lightingPresets = {
3077
- studio: {
3078
- id: "studio",
3079
- label: "Studio",
3080
- ambient: 0.65,
3081
- key: { color: "#ffffff", intensity: 1.6, position: [2.5, 4, 3] },
3082
- contactShadowOpacity: 0.3,
3083
- contactShadowBlur: 2.4,
3084
- exposure: 1,
3085
- shadow: { mapSize: 1024, radius: 4 }
3086
- },
3087
- window: {
3088
- id: "window",
3089
- label: "Window",
3090
- ambient: 0.5,
3091
- key: { color: "#ffe3c0", intensity: 1.9, position: [3, 2.6, 2.6] },
3092
- contactShadowOpacity: 0.35,
3093
- contactShadowBlur: 2.6,
3094
- exposure: 1,
3095
- shadow: { mapSize: 1024, radius: 5 },
3096
- gobo: { kind: "blinds", drift: 4e-3, angle: 0.62 }
3097
- },
3098
- leaves: {
3099
- id: "leaves",
3100
- label: "Leaves",
3101
- ambient: 0.45,
3102
- key: { color: "#fff2d8", intensity: 2, position: [2.2, 3.6, 2.4] },
3103
- contactShadowOpacity: 0.4,
3104
- contactShadowBlur: 2.8,
3105
- exposure: 1,
3106
- shadow: { mapSize: 1024, radius: 6 },
3107
- gobo: { kind: "leaves", drift: 0.012, angle: 0.7 }
3108
- },
3109
- goldenhour: {
3110
- id: "goldenhour",
3111
- label: "Golden hour",
3112
- ambient: 0.32,
3113
- key: { color: "#ffb066", intensity: 2.4, position: [4, 0.9, 2.2] },
3114
- contactShadowOpacity: 0.45,
3115
- contactShadowBlur: 3.2,
3116
- exposure: 1.15,
3117
- shadow: { mapSize: 1024, radius: 7 }
3118
- },
3119
- noir: {
3120
- id: "noir",
3121
- label: "Noir",
3122
- ambient: 0.07,
3123
- key: { color: "#ffffff", intensity: 2.6, position: [2, 3, 1.6] },
3124
- contactShadowOpacity: 0.7,
3125
- contactShadowBlur: 1.1,
3126
- exposure: 1.05,
3127
- shadow: { mapSize: 2048, radius: 1 }
3128
- }
3129
- };
3130
- function getLightingPreset(name) {
3131
- return lightingPresets[name];
3132
- }
3133
-
3134
- // src/scene/PaperLighting.tsx
3135
3535
  import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
3136
3536
  function mulberry32(seed) {
3137
3537
  let a = seed;
@@ -3188,19 +3588,24 @@ function makeGoboTexture(kind) {
3188
3588
  }
3189
3589
  }
3190
3590
  }
3191
- const texture = new THREE7.CanvasTexture(canvas);
3192
- texture.wrapS = texture.wrapT = THREE7.RepeatWrapping;
3591
+ const texture = new THREE8.CanvasTexture(canvas);
3592
+ texture.wrapS = texture.wrapT = THREE8.RepeatWrapping;
3193
3593
  return texture;
3194
3594
  }
3195
3595
  function PaperLighting({
3196
3596
  preset = "studio",
3197
3597
  floor = -1.2,
3198
3598
  scale = 10,
3199
- reducedMotion
3599
+ reducedMotion,
3600
+ shadowMapSize,
3601
+ contactShadow = true
3200
3602
  }) {
3201
3603
  const p = getLightingPreset(preset);
3604
+ const mapSize = shadowMapSize ?? p.shadow.mapSize;
3605
+ const castShadow = mapSize > 0;
3202
3606
  const reduced = usePrefersReducedMotion(reducedMotion);
3203
3607
  const gl = useThree2((s) => s.gl);
3608
+ const scene = useThree2((s) => s.scene);
3204
3609
  useEffect6(() => {
3205
3610
  const previous = gl.toneMappingExposure;
3206
3611
  gl.toneMappingExposure = p.exposure;
@@ -3208,11 +3613,15 @@ function PaperLighting({
3208
3613
  gl.toneMappingExposure = previous;
3209
3614
  };
3210
3615
  }, [gl, p.exposure]);
3211
- const goboMap = useMemo4(
3212
- () => p.gobo ? makeGoboTexture(p.gobo.kind) : null,
3213
- // eslint-disable-next-line react-hooks/exhaustive-deps
3214
- [p.gobo?.kind]
3215
- );
3616
+ useEffect6(() => {
3617
+ if (!p.fog) return;
3618
+ const previous = scene.fog;
3619
+ scene.fog = new THREE8.Fog(p.fog.color, p.fog.near, p.fog.far);
3620
+ return () => {
3621
+ scene.fog = previous;
3622
+ };
3623
+ }, [scene, p.fog]);
3624
+ const goboMap = useMemo4(() => p.gobo ? makeGoboTexture(p.gobo.kind) : null, [p.gobo?.kind]);
3216
3625
  useEffect6(() => () => goboMap?.dispose(), [goboMap]);
3217
3626
  const driftRef = useRef3(0);
3218
3627
  useFrame2((_, delta) => {
@@ -3231,9 +3640,9 @@ function PaperLighting({
3231
3640
  angle: p.gobo.angle,
3232
3641
  penumbra: 0.5,
3233
3642
  decay: 0,
3234
- castShadow: true,
3643
+ castShadow,
3235
3644
  map: goboMap,
3236
- "shadow-mapSize": [p.shadow.mapSize, p.shadow.mapSize],
3645
+ "shadow-mapSize": [mapSize || 1, mapSize || 1],
3237
3646
  "shadow-radius": p.shadow.radius,
3238
3647
  "shadow-normalBias": 0.05
3239
3648
  }
@@ -3243,13 +3652,13 @@ function PaperLighting({
3243
3652
  position: p.key.position,
3244
3653
  color: p.key.color,
3245
3654
  intensity: p.key.intensity,
3246
- castShadow: true,
3247
- "shadow-mapSize": [p.shadow.mapSize, p.shadow.mapSize],
3655
+ castShadow,
3656
+ "shadow-mapSize": [mapSize || 1, mapSize || 1],
3248
3657
  "shadow-radius": p.shadow.radius,
3249
3658
  "shadow-normalBias": 0.05
3250
3659
  }
3251
3660
  ),
3252
- /* @__PURE__ */ jsx4(
3661
+ contactShadow && /* @__PURE__ */ jsx4(
3253
3662
  ContactShadows,
3254
3663
  {
3255
3664
  position: [0, floor, 0],
@@ -3286,12 +3695,13 @@ var Paper = forwardRef2(function Paper2({ children, className, style, ...meshPro
3286
3695
  });
3287
3696
 
3288
3697
  // src/PaperField.tsx
3698
+ import * as THREE14 from "three";
3289
3699
  import { gsap as gsap5 } from "gsap";
3290
3700
  import { Canvas as Canvas2, useFrame as useFrame5, useThree as useThree4 } from "@react-three/fiber";
3291
3701
  import { forwardRef as forwardRef3, useEffect as useEffect12, useMemo as useMemo10, useRef as useRef6 } from "react";
3292
3702
 
3293
3703
  // src/field/dropZones.tsx
3294
- import * as THREE8 from "three";
3704
+ import * as THREE9 from "three";
3295
3705
  import { createContext, useContext, useEffect as useEffect7, useMemo as useMemo6, useSyncExternalStore } from "react";
3296
3706
  import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
3297
3707
  var DropZoneRegistry = class {
@@ -3362,8 +3772,8 @@ function DropZoneVisual({ registry: registry4, config }) {
3362
3772
  const style = config.highlight ?? "glow";
3363
3773
  const [w, h] = config.bounds.size;
3364
3774
  const edges = useMemo6(() => {
3365
- const plane = new THREE8.PlaneGeometry(w, h);
3366
- const geo = new THREE8.EdgesGeometry(plane);
3775
+ const plane = new THREE9.PlaneGeometry(w, h);
3776
+ const geo = new THREE9.EdgesGeometry(plane);
3367
3777
  plane.dispose();
3368
3778
  return geo;
3369
3779
  }, [w, h]);
@@ -3386,18 +3796,18 @@ function DropZoneVisual({ registry: registry4, config }) {
3386
3796
  }
3387
3797
 
3388
3798
  // src/field/sheetGrid.ts
3389
- import { z as z16 } from "zod";
3390
- var sheetLayoutSchema = z16.object({
3391
- rows: z16.number().int().min(1).max(12).default(2),
3392
- columns: z16.number().int().min(1).max(12).default(5),
3799
+ import { z as z20 } from "zod";
3800
+ var sheetLayoutSchema = z20.object({
3801
+ rows: z20.number().int().min(1).max(12).default(2),
3802
+ columns: z20.number().int().min(1).max(12).default(5),
3393
3803
  /** World-units gap between slots. Stamps are printed in register — no jitter. */
3394
- gutter: z16.number().min(0).max(1).default(0.08),
3804
+ gutter: z20.number().min(0).max(1).default(0.08),
3395
3805
  /** Slot footprint in world units (the paper preset should match). */
3396
- cellWidth: z16.number().min(0.1).max(4).default(0.72),
3397
- cellHeight: z16.number().min(0.1).max(4).default(0.86),
3806
+ cellWidth: z20.number().min(0.1).max(4).default(0.72),
3807
+ cellHeight: z20.number().min(0.1).max(4).default(0.86),
3398
3808
  /** Render the shared backing sheet behind the grid. */
3399
- backing: z16.boolean().default(true),
3400
- backingMargin: z16.number().min(0).max(1).default(0.12)
3809
+ backing: z20.boolean().default(true),
3810
+ backingMargin: z20.number().min(0).max(1).default(0.12)
3401
3811
  });
3402
3812
  var SHEET_LIFT = 0.012;
3403
3813
  function withSheetCellFromPaper(parsed, rawOptions, paperDims) {
@@ -3489,18 +3899,18 @@ function resolveFieldSlotConfig(slot, fallback, index, layoutId, layoutOptions)
3489
3899
  }
3490
3900
 
3491
3901
  // src/field/fieldGroup.tsx
3492
- import * as THREE10 from "three";
3902
+ import * as THREE11 from "three";
3493
3903
  import { gsap as gsap3 } from "gsap";
3494
3904
  import { useFrame as useFrame3 } from "@react-three/fiber";
3495
3905
  import { useEffect as useEffect9, useMemo as useMemo7, useRef as useRef4 } from "react";
3496
3906
  import CustomShaderMaterial2 from "three-custom-shader-material";
3497
3907
 
3498
3908
  // src/content/atlas.ts
3499
- import * as THREE9 from "three";
3909
+ import * as THREE10 from "three";
3500
3910
  import { useEffect as useEffect8, useState as useState4 } from "react";
3501
3911
  var MAX_ATLAS = 4096;
3502
- function atlasGrid(count) {
3503
- const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
3912
+ function atlasGrid(count, aspect = 1) {
3913
+ const cols = Math.max(1, Math.min(count, Math.ceil(Math.sqrt(count * Math.max(aspect, 0.01)))));
3504
3914
  return { cols, rows: Math.max(1, Math.ceil(count / cols)) };
3505
3915
  }
3506
3916
  function useContentAtlas(contents, sheet2, stock) {
@@ -3508,18 +3918,22 @@ function useContentAtlas(contents, sheet2, stock) {
3508
3918
  const key = JSON.stringify({ contents, w: sheet2.width, h: sheet2.height, stock: stock.id });
3509
3919
  useEffect8(() => {
3510
3920
  let disposed = false;
3511
- const { cols, rows } = atlasGrid(contents.length);
3512
3921
  const aspect = sheet2.height / sheet2.width;
3513
- const tileW = Math.min(1024, Math.floor(MAX_ATLAS / cols));
3514
- const tileH = Math.min(Math.round(tileW * aspect), Math.floor(MAX_ATLAS / rows));
3922
+ const { cols, rows } = atlasGrid(contents.length, aspect);
3923
+ let tileW = Math.min(1024, Math.floor(MAX_ATLAS / cols));
3924
+ let tileH = Math.round(tileW * aspect);
3925
+ if (tileH * rows > MAX_ATLAS) {
3926
+ tileH = Math.floor(MAX_ATLAS / rows);
3927
+ tileW = Math.max(1, Math.round(tileH / aspect));
3928
+ }
3515
3929
  const canvas = document.createElement("canvas");
3516
3930
  canvas.width = tileW * cols;
3517
3931
  canvas.height = tileH * rows;
3518
3932
  const ctx = canvas.getContext("2d");
3519
3933
  ctx.fillStyle = stock.color;
3520
3934
  ctx.fillRect(0, 0, canvas.width, canvas.height);
3521
- const texture = new THREE9.CanvasTexture(canvas);
3522
- texture.colorSpace = THREE9.SRGBColorSpace;
3935
+ const texture = new THREE10.CanvasTexture(canvas);
3936
+ texture.colorSpace = THREE10.SRGBColorSpace;
3523
3937
  texture.anisotropy = 4;
3524
3938
  setAtlas({ texture, cols, rows });
3525
3939
  const drawTile = (index, tile) => {
@@ -3567,7 +3981,7 @@ function stackUniformValues(stack, sheet2) {
3567
3981
  return uniforms;
3568
3982
  }
3569
3983
  function buildDisplacementGLSL(stack, sheet2) {
3570
- const decls = ["uniform vec2 uSheet;"];
3984
+ const decls = ["uniform vec2 uSheet;", "float plBias = 1.0;"];
3571
3985
  const functions = [];
3572
3986
  const calls = [];
3573
3987
  const uniforms = { uSheet: [sheet2.width, sheet2.height] };
@@ -3586,15 +4000,24 @@ function buildDisplacementGLSL(stack, sheet2) {
3586
4000
  decls.push(`uniform ${glslType(value)} ${ns}${key};`);
3587
4001
  uniforms[ns + key] = value;
3588
4002
  }
4003
+ const strength = deformer.glsl.strength;
3589
4004
  functions.push(
3590
- deformer.glsl.chunk.replaceAll("FN", fn).replace(/U_(\w+)/g, (_, name) => ns + name)
4005
+ deformer.glsl.chunk.replaceAll("FN", fn).replace(
4006
+ /U_(\w+)/g,
4007
+ (_, name) => (
4008
+ // The strength uniform reads through the per-instance bias, so one
4009
+ // instanced draw call can bend every sheet by a different amount.
4010
+ name === strength ? `(${ns}${name} * plBias)` : ns + name
4011
+ )
4012
+ )
3591
4013
  );
3592
4014
  calls.push(`${fn}(q, uv, t);`);
3593
4015
  });
3594
4016
  const displaceSrc = (
3595
4017
  /* glsl */
3596
4018
  `
3597
- vec3 plDisplace(vec3 p, vec2 uv, float t) {
4019
+ vec3 plDisplace(vec3 p, vec2 uv, float t, float bias) {
4020
+ plBias = bias;
3598
4021
  vec3 q = p;
3599
4022
  ${calls.join("\n ")}
3600
4023
  return q;
@@ -3611,19 +4034,22 @@ function buildFieldVertexShader(composed) {
3611
4034
  uniform float uPlTime;
3612
4035
  attribute float aPhase;
3613
4036
  attribute float aAtlas;
4037
+ attribute float aBias;
3614
4038
  varying vec2 vPaperUv;
3615
4039
  varying float vAtlas;
4040
+ ${TRANSLUCENCY_VARYINGS}
3616
4041
  ${composed.functionsSrc}
3617
4042
  ${composed.displaceSrc}
3618
4043
  void main() {
3619
4044
  float t = uPlTime + aPhase;
3620
- vec3 p = plDisplace(position, uv, t);
4045
+ vec3 p = plDisplace(position, uv, t, aBias);
3621
4046
  vec2 step = uSheet * 0.01;
3622
- vec3 px = plDisplace(position + vec3(step.x, 0.0, 0.0), uv + vec2(0.01, 0.0), t);
3623
- vec3 py = plDisplace(position + vec3(0.0, step.y, 0.0), uv + vec2(0.0, 0.01), t);
4047
+ vec3 px = plDisplace(position + vec3(step.x, 0.0, 0.0), uv + vec2(0.01, 0.0), t, aBias);
4048
+ vec3 py = plDisplace(position + vec3(0.0, step.y, 0.0), uv + vec2(0.0, 0.01), t, aBias);
3624
4049
  vec3 n = cross(px - p, py - p);
3625
4050
  csm_Normal = length(n) > 1e-12 ? normalize(n) : vec3(0.0, 0.0, 1.0);
3626
4051
  csm_Position = p;
4052
+ ${translucencyVertexChunk({ model: "modelMatrix * instanceMatrix", position: "p", normal: "csm_Normal" })}
3627
4053
  vPaperUv = uv;
3628
4054
  vAtlas = aAtlas;
3629
4055
  }
@@ -3641,6 +4067,7 @@ uniform vec3 uStockColor;
3641
4067
  uniform float uShowThrough;
3642
4068
  varying vec2 vPaperUv;
3643
4069
  varying float vAtlas;
4070
+ ${TRANSLUCENCY_FRAGMENT}
3644
4071
  void main() {
3645
4072
  float col = mod(vAtlas, uAtlasGrid.x);
3646
4073
  float row = floor(vAtlas / uAtlasGrid.x);
@@ -3652,6 +4079,10 @@ void main() {
3652
4079
  csm_DiffuseColor = vec4(uStockColor * mix(vec3(1.0), front.rgb, uShowThrough), 1.0);
3653
4080
  csm_DiffuseColor.rgb *= uBackDarken;
3654
4081
  }
4082
+ // Light coming through the sheet, filtered by what is printed on it. Same
4083
+ // ink either side \u2014 the light passes through the same fibres regardless of
4084
+ // which face happens to be turned toward the camera.
4085
+ csm_Emissive = plTransmission(front.rgb);
3655
4086
  }
3656
4087
  `
3657
4088
  );
@@ -3661,16 +4092,149 @@ function cap(s) {
3661
4092
  }
3662
4093
 
3663
4094
  // src/field/layouts/index.ts
3664
- import { z as z17 } from "zod";
3665
- var TAU2 = Math.PI * 2;
3666
- function jitter(seed, i) {
4095
+ import { z as z22 } from "zod";
4096
+
4097
+ // src/stage/path.ts
4098
+ import { z as z21 } from "zod";
4099
+ var walkPathSchema = z21.object({
4100
+ /**
4101
+ * Control points on the ground plane, [x, z]. The default walks away from
4102
+ * the camera down -Z — the shot every reference image is composed on.
4103
+ */
4104
+ points: z21.array(z21.tuple([z21.number(), z21.number()])).min(2).default([
4105
+ [0, 9],
4106
+ [0, -9]
4107
+ ]),
4108
+ /** Join the last point back to the first: an endless walk, and the only form `phase` can slide. */
4109
+ closed: z21.boolean().default(false)
4110
+ });
4111
+ var SAMPLES_PER_SEGMENT = 24;
4112
+ var EPSILON = 1e-6;
4113
+ var ALPHA = 0.5;
4114
+ function knot(a, b, t) {
4115
+ return t + Math.max(Math.hypot(b[0] - a[0], b[1] - a[1]), EPSILON) ** ALPHA;
4116
+ }
4117
+ function lerpKnot(a, b, ta, tb, t) {
4118
+ const span = tb - ta;
4119
+ const k = span === 0 ? 0 : (t - ta) / span;
4120
+ return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
4121
+ }
4122
+ function segmentPoint(p0, p1, p2, p3, u) {
4123
+ const t0 = 0;
4124
+ const t1 = knot(p0, p1, t0);
4125
+ const t2 = knot(p1, p2, t1);
4126
+ const t3 = knot(p2, p3, t2);
4127
+ const t = t1 + (t2 - t1) * u;
4128
+ const a1 = lerpKnot(p0, p1, t0, t1, t);
4129
+ const a2 = lerpKnot(p1, p2, t1, t2, t);
4130
+ const a3 = lerpKnot(p2, p3, t2, t3, t);
4131
+ const b1 = lerpKnot(a1, a2, t0, t2, t);
4132
+ const b2 = lerpKnot(a2, a3, t1, t3, t);
4133
+ return lerpKnot(b1, b2, t1, t2, t);
4134
+ }
4135
+ function reflect(end, inner) {
4136
+ return [2 * end[0] - inner[0], 2 * end[1] - inner[1]];
4137
+ }
4138
+ function createWalkPath(options) {
4139
+ const pts = options.points;
4140
+ const n = pts.length;
4141
+ const closed = options.closed && n > 2;
4142
+ const segments = closed ? n : n - 1;
4143
+ const samples = [];
4144
+ const cumulative = [];
4145
+ let total = 0;
4146
+ for (let seg = 0; seg < segments; seg++) {
4147
+ const p1 = pts[seg];
4148
+ const p2 = pts[(seg + 1) % n];
4149
+ const p0 = closed ? pts[(seg - 1 + n) % n] : seg > 0 ? pts[seg - 1] : reflect(pts[0], pts[1]);
4150
+ const p3 = closed ? pts[(seg + 2) % n] : seg + 2 < n ? pts[seg + 2] : reflect(pts[n - 1], pts[n - 2]);
4151
+ const last = seg === segments - 1 && !closed ? SAMPLES_PER_SEGMENT : SAMPLES_PER_SEGMENT - 1;
4152
+ for (let k = 0; k <= last; k++) {
4153
+ const point = segmentPoint(p0, p1, p2, p3, k / SAMPLES_PER_SEGMENT);
4154
+ const previous = samples[samples.length - 1];
4155
+ if (previous) total += Math.hypot(point[0] - previous[0], point[1] - previous[1]);
4156
+ samples.push(point);
4157
+ cumulative.push(total);
4158
+ }
4159
+ }
4160
+ if (closed) {
4161
+ const first = samples[0];
4162
+ const previous = samples[samples.length - 1];
4163
+ total += Math.hypot(first[0] - previous[0], first[1] - previous[1]);
4164
+ samples.push([first[0], first[1]]);
4165
+ cumulative.push(total);
4166
+ }
4167
+ const length = total;
4168
+ function normalize(s) {
4169
+ if (!Number.isFinite(s)) return 0;
4170
+ if (!closed) return Math.min(Math.max(s, 0), 1);
4171
+ const wrapped = s - Math.floor(s);
4172
+ return wrapped;
4173
+ }
4174
+ function pointAt(s) {
4175
+ const target = normalize(s) * length;
4176
+ if (length === 0) return [samples[0][0], samples[0][1]];
4177
+ let low = 0;
4178
+ let high = cumulative.length - 1;
4179
+ while (low < high) {
4180
+ const mid = low + high >> 1;
4181
+ if (cumulative[mid] < target) low = mid + 1;
4182
+ else high = mid;
4183
+ }
4184
+ const i = Math.max(low, 1);
4185
+ const before = cumulative[i - 1];
4186
+ const span = cumulative[i] - before;
4187
+ const k = span <= 0 ? 0 : (target - before) / span;
4188
+ const a = samples[i - 1];
4189
+ const b = samples[i];
4190
+ return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
4191
+ }
4192
+ function tangentAt(s) {
4193
+ const ds = length > 0 ? Math.min(0.01, 0.5 / length) : 0.01;
4194
+ const here = normalize(s);
4195
+ const a = pointAt(closed ? here - ds : Math.max(here - ds, 0));
4196
+ const b = pointAt(closed ? here + ds : Math.min(here + ds, 1));
4197
+ const dx = b[0] - a[0];
4198
+ const dz = b[1] - a[1];
4199
+ const len = Math.hypot(dx, dz);
4200
+ return len < EPSILON ? [0, -1] : [dx / len, dz / len];
4201
+ }
4202
+ function normalAt(s) {
4203
+ const [tx, tz] = tangentAt(s);
4204
+ return [tz, -tx];
4205
+ }
4206
+ return { length, closed, pointAt, tangentAt, normalAt };
4207
+ }
4208
+ var cache = /* @__PURE__ */ new Map();
4209
+ var CACHE_LIMIT = 32;
4210
+ function getWalkPath(options) {
4211
+ const key = `${options.closed ? "c" : "o"}|${options.points.map((p) => `${p[0]},${p[1]}`).join(";")}`;
4212
+ const hit = cache.get(key);
4213
+ if (hit) return hit;
4214
+ const path = createWalkPath(options);
4215
+ if (cache.size >= CACHE_LIMIT) {
4216
+ const oldest = cache.keys().next().value;
4217
+ if (oldest !== void 0) cache.delete(oldest);
4218
+ }
4219
+ cache.set(key, path);
4220
+ return path;
4221
+ }
4222
+
4223
+ // src/field/layouts/index.ts
4224
+ var DEFAULT_SHEET = { width: 1, height: 1.4 };
4225
+ var TAU3 = Math.PI * 2;
4226
+ var DEG6 = Math.PI / 180;
4227
+ function jitter2(seed, i) {
3667
4228
  let h = Math.imul(seed * 1e3 + i + 1 ^ 2654435769, 2654435761);
3668
4229
  h = Math.imul(h ^ h >>> 13, 3266489917);
3669
4230
  return ((h ^ h >>> 16) >>> 0) / 4294967295 * 2 - 1;
3670
4231
  }
3671
- var ringSchema = z17.object({
3672
- radius: z17.number().min(0.5).max(12).default(2.6),
3673
- tiltDeg: z17.number().min(-45).max(45).default(8)
4232
+ function ramp(i, n) {
4233
+ return n > 1 ? i / (n - 1) : 1;
4234
+ }
4235
+ var ringSchema = z22.object({
4236
+ radius: z22.number().min(0.5).max(12).default(2.6),
4237
+ tiltDeg: z22.number().min(-45).max(45).default(8)
3674
4238
  });
3675
4239
  var ring = {
3676
4240
  id: "ring",
@@ -3678,7 +4242,7 @@ var ring = {
3678
4242
  defaults: ringSchema.parse({}),
3679
4243
  optionsSchema: ringSchema,
3680
4244
  pose(i, n, o, phase) {
3681
- const theta = (i / n + phase) * TAU2;
4245
+ const theta = (i / n + phase) * TAU3;
3682
4246
  return {
3683
4247
  position: [Math.sin(theta) * o.radius, 0, Math.cos(theta) * o.radius],
3684
4248
  // Face radially OUTWARD so the papers nearest the camera show their
@@ -3688,128 +4252,314 @@ var ring = {
3688
4252
  };
3689
4253
  }
3690
4254
  };
3691
- var deckSchema = z17.object({
3692
- spread: z17.number().min(0).max(1).default(0.3),
3693
- lift: z17.number().min(5e-3).max(0.08).default(0.014)
4255
+ var fanSchema = z22.object({
4256
+ /** Total angular sweep from the first sheet to the last, degrees. */
4257
+ sweep: z22.number().min(0).max(180).default(72),
4258
+ /** Where the shared pin sits, in half-sheet-heights below center. 1 = the bottom edge. */
4259
+ hinge: z22.number().min(0).max(4).default(1.15),
4260
+ /** Thickness step so the sheets stack in order instead of z-fighting. */
4261
+ lift: z22.number().min(2e-3).max(0.08).default(0.012),
4262
+ /** How much flatter the middle of the fan sits than its outer sheets. */
4263
+ bow: z22.number().min(0).max(1).default(0.7)
3694
4264
  });
3695
- var deck = {
3696
- id: "deck",
3697
- label: "Deck",
3698
- defaults: deckSchema.parse({}),
3699
- optionsSchema: deckSchema,
3700
- pose(i, _n, o) {
4265
+ var fan = {
4266
+ id: "fan",
4267
+ label: "Fan",
4268
+ defaults: fanSchema.parse({}),
4269
+ optionsSchema: fanSchema,
4270
+ pose(i, n, o, _phase, sheet2) {
4271
+ const f = n > 1 ? i / (n - 1) : 0.5;
4272
+ const theta = (f - 0.5) * o.sweep * DEG6;
4273
+ const hinge = o.hinge * sheet2.height / 2;
4274
+ const open = Math.abs(f - 0.5) * 2;
3701
4275
  return {
3702
- position: [jitter(1, i) * 0.09 * o.spread * 3, jitter(2, i) * 0.07 * o.spread * 3, i * o.lift],
3703
- rotation: [0, 0, jitter(3, i) * 0.35 * o.spread],
3704
- scale: 1
4276
+ position: [-Math.sin(theta) * hinge, Math.cos(theta) * hinge - hinge, i * o.lift],
4277
+ rotation: [0, 0, theta],
4278
+ scale: 1,
4279
+ bias: 1 - (1 - open) * o.bow
3705
4280
  };
3706
4281
  }
3707
4282
  };
3708
- var cascadeSchema = z17.object({
3709
- gap: z17.number().min(0.2).max(2).default(0.55),
3710
- drop: z17.number().min(0).max(1).default(0.2)
4283
+ var spreadSchema = z22.object({
4284
+ /** How far each sheet slides past the one below it. */
4285
+ slip: z22.number().min(0.02).max(2).default(0.3),
4286
+ /** Direction of the slide, degrees. 0 slides right, 90 slides up. */
4287
+ angle: z22.number().min(-180).max(180).default(28),
4288
+ lift: z22.number().min(2e-3).max(0.08).default(0.012),
4289
+ /** How much more the sheets at the far end of the slide bow. */
4290
+ bow: z22.number().min(0).max(1).default(0.6),
4291
+ /** Nothing hand-slid is perfectly square — a touch of per-sheet rotation. */
4292
+ drift: z22.number().min(0).max(1).default(0.15)
3711
4293
  });
3712
- var cascade = {
3713
- id: "cascade",
3714
- label: "Cascade",
3715
- defaults: cascadeSchema.parse({}),
3716
- optionsSchema: cascadeSchema,
4294
+ var spread = {
4295
+ id: "spread",
4296
+ label: "Spread",
4297
+ defaults: spreadSchema.parse({}),
4298
+ optionsSchema: spreadSchema,
3717
4299
  pose(i, n, o) {
3718
4300
  const centered = i - (n - 1) / 2;
4301
+ const a = o.angle * DEG6;
3719
4302
  return {
3720
- position: [centered * o.gap, -centered * o.drop, -i * 0.06],
3721
- rotation: [0, 0, jitter(4, i) * 0.06],
3722
- scale: 1
4303
+ position: [Math.cos(a) * o.slip * centered, Math.sin(a) * o.slip * centered, i * o.lift],
4304
+ rotation: [0, 0, jitter2(11, i) * 0.2 * o.drift],
4305
+ scale: 1,
4306
+ bias: 1 - (1 - ramp(i, n)) * o.bow
3723
4307
  };
3724
4308
  }
3725
4309
  };
3726
- var helixSchema = z17.object({
3727
- radius: z17.number().min(0.5).max(12).default(2.2),
3728
- height: z17.number().min(0.5).max(10).default(2.6),
3729
- turns: z17.number().min(0.5).max(5).default(1.5)
4310
+ var pileSchema = z22.object({
4311
+ /** How far sheets wander from the center of the heap. */
4312
+ scatter: z22.number().min(0).max(2).default(0.22),
4313
+ /** Widest angle a sheet sits off square, degrees. */
4314
+ turn: z22.number().min(0).max(180).default(24),
4315
+ lift: z22.number().min(2e-3).max(0.08).default(0.011),
4316
+ /** How flat the sheets underneath are pressed by the ones on top. */
4317
+ press: z22.number().min(0).max(1).default(0.85),
4318
+ seed: z22.number().int().min(0).max(9999).default(3)
3730
4319
  });
3731
- var helix = {
3732
- id: "helix",
3733
- label: "Helix",
3734
- defaults: helixSchema.parse({}),
3735
- optionsSchema: helixSchema,
3736
- pose(i, n, o, phase) {
3737
- const f = n > 1 ? i / (n - 1) : 0;
3738
- const theta = (f * o.turns + phase) * TAU2;
4320
+ var pile = {
4321
+ id: "pile",
4322
+ label: "Pile",
4323
+ defaults: pileSchema.parse({}),
4324
+ optionsSchema: pileSchema,
4325
+ pose(i, n, o) {
3739
4326
  return {
3740
- position: [Math.sin(theta) * o.radius, (f - 0.5) * o.height, Math.cos(theta) * o.radius],
3741
- // Outward-facing, same as the ring fronts toward the viewer outside.
3742
- rotation: [0, theta, 0],
3743
- scale: 1
4327
+ position: [jitter2(o.seed, i) * o.scatter, jitter2(o.seed + 1, i) * o.scatter * 0.8, i * o.lift],
4328
+ rotation: [0, 0, jitter2(o.seed + 2, i) * o.turn * DEG6],
4329
+ scale: 1,
4330
+ bias: 1 - (1 - ramp(i, n)) * o.press
3744
4331
  };
3745
4332
  }
3746
4333
  };
3747
- var wallSchema = z17.object({
3748
- gapX: z17.number().min(0.05).max(1).default(0.22),
3749
- gapY: z17.number().min(0.05).max(1).default(0.3),
3750
- jitterAmt: z17.number().min(0).max(1).default(0.25)
4334
+ var wallSchema = z22.object({
4335
+ gapX: z22.number().min(0.05).max(1).default(0.22),
4336
+ gapY: z22.number().min(0.05).max(1).default(0.3),
4337
+ jitterAmt: z22.number().min(0).max(1).default(0.25),
4338
+ /** Spread of sag across the wall — no two pinned sheets hang alike. */
4339
+ sag: z22.number().min(0).max(1).default(0.45)
3751
4340
  });
3752
4341
  var wall = {
3753
4342
  id: "wall",
3754
4343
  label: "Wall",
3755
4344
  defaults: wallSchema.parse({}),
3756
4345
  optionsSchema: wallSchema,
3757
- pose(i, n, o) {
3758
- const cols = Math.ceil(Math.sqrt(n * 1.4));
4346
+ pose(i, n, o, _phase, sheet2) {
4347
+ const cols = Math.ceil(Math.sqrt(n * sheet2.height / sheet2.width));
3759
4348
  const rows = Math.ceil(n / cols);
3760
4349
  const col = i % cols;
3761
4350
  const row = Math.floor(i / cols);
3762
- const cellW = 1 + o.gapX;
3763
- const cellH = 1.4 + o.gapY;
4351
+ const cellW = sheet2.width + o.gapX;
4352
+ const cellH = sheet2.height + o.gapY;
3764
4353
  return {
3765
4354
  position: [
3766
4355
  (col - (cols - 1) / 2) * cellW,
3767
4356
  ((rows - 1) / 2 - row) * cellH,
3768
- jitter(5, i) * 0.04 * o.jitterAmt * 4
4357
+ jitter2(5, i) * 0.04 * o.jitterAmt * 4
3769
4358
  ],
3770
- rotation: [0, 0, jitter(6, i) * 0.05 * o.jitterAmt * 4],
3771
- scale: 1
4359
+ rotation: [0, 0, jitter2(6, i) * 0.05 * o.jitterAmt * 4],
4360
+ scale: 1,
4361
+ bias: 1 - Math.abs(jitter2(7, i)) * o.sag
3772
4362
  };
3773
4363
  }
3774
4364
  };
3775
- var tunnelSchema = z17.object({
3776
- radius: z17.number().min(0.5).max(6).default(1.7),
3777
- spacing: z17.number().min(0.1).max(3).default(0.55)
4365
+ var spillSchema = z22.object({
4366
+ spreadX: z22.number().min(0.5).max(8).default(2.4),
4367
+ spreadY: z22.number().min(0.5).max(8).default(1.5),
4368
+ depth: z22.number().min(0).max(6).default(1.6),
4369
+ /** How far sheets pitch and roll out of the picture plane. */
4370
+ tumble: z22.number().min(0).max(1).default(0.5),
4371
+ /** Spread of bend across the sheets — a spill does not fold them alike. */
4372
+ vary: z22.number().min(0).max(1).default(0.6),
4373
+ seed: z22.number().int().min(0).max(9999).default(7)
3778
4374
  });
3779
- var tunnel = {
3780
- id: "tunnel",
3781
- label: "Tunnel",
3782
- defaults: tunnelSchema.parse({}),
3783
- optionsSchema: tunnelSchema,
3784
- pose(i, _n, o, phase) {
3785
- const theta = i * 2.39996 + phase * TAU2;
4375
+ var spill = {
4376
+ id: "spill",
4377
+ label: "Spill",
4378
+ defaults: spillSchema.parse({}),
4379
+ optionsSchema: spillSchema,
4380
+ pose(i, _n, o) {
4381
+ const tumble = o.tumble * 2;
3786
4382
  return {
3787
- position: [Math.cos(theta) * o.radius, Math.sin(theta) * o.radius, -i * o.spacing],
3788
- rotation: [0, 0, theta - Math.PI / 2],
3789
- scale: 1
4383
+ position: [
4384
+ jitter2(o.seed, i) * o.spreadX,
4385
+ jitter2(o.seed + 1, i) * o.spreadY,
4386
+ jitter2(o.seed + 2, i) * o.depth
4387
+ ],
4388
+ rotation: [
4389
+ jitter2(o.seed + 3, i) * 0.4 * tumble,
4390
+ jitter2(o.seed + 4, i) * 0.5 * tumble,
4391
+ jitter2(o.seed + 5, i) * 0.4 * tumble
4392
+ ],
4393
+ scale: 0.85 + Math.abs(jitter2(o.seed + 6, i)) * 0.3,
4394
+ bias: 1 - Math.abs(jitter2(o.seed + 7, i)) * o.vary
3790
4395
  };
3791
4396
  }
3792
4397
  };
3793
- var scatterSchema = z17.object({
3794
- spreadX: z17.number().min(0.5).max(8).default(2.4),
3795
- spreadY: z17.number().min(0.5).max(8).default(1.5),
3796
- depth: z17.number().min(0).max(6).default(1.6),
3797
- seed: z17.number().int().min(0).max(9999).default(7)
4398
+ var sweepSchema = z22.object({
4399
+ columns: z22.number().int().min(1).max(24).default(5),
4400
+ /** Breathing room around each specimen. */
4401
+ gap: z22.number().min(0).max(2).default(0.22),
4402
+ /** Deformation at the first specimen and at the last. */
4403
+ from: z22.number().min(0).max(1).default(0),
4404
+ to: z22.number().min(0).max(1).default(1)
3798
4405
  });
3799
- var scatter = {
3800
- id: "scatter",
3801
- label: "Scatter",
3802
- defaults: scatterSchema.parse({}),
3803
- optionsSchema: scatterSchema,
3804
- pose(i, _n, o) {
4406
+ var sweep = {
4407
+ id: "sweep",
4408
+ label: "Sweep",
4409
+ defaults: sweepSchema.parse({}),
4410
+ optionsSchema: sweepSchema,
4411
+ pose(i, n, o, _phase, sheet2) {
4412
+ const cols = Math.min(o.columns, Math.max(n, 1));
4413
+ const rows = Math.ceil(n / cols);
4414
+ const col = i % cols;
4415
+ const row = Math.floor(i / cols);
3805
4416
  return {
3806
4417
  position: [
3807
- jitter(o.seed, i) * o.spreadX,
3808
- jitter(o.seed + 1, i) * o.spreadY,
3809
- jitter(o.seed + 2, i) * o.depth
4418
+ (col - (cols - 1) / 2) * (sheet2.width + o.gap),
4419
+ ((rows - 1) / 2 - row) * (sheet2.height + o.gap),
4420
+ 0
3810
4421
  ],
3811
- rotation: [jitter(o.seed + 3, i) * 0.4, jitter(o.seed + 4, i) * 0.5, jitter(o.seed + 5, i) * 0.4],
3812
- scale: 0.85 + Math.abs(jitter(o.seed + 6, i)) * 0.3
4422
+ rotation: [0, 0, 0],
4423
+ scale: 1,
4424
+ bias: o.from + (o.to - o.from) * ramp(i, n)
4425
+ };
4426
+ }
4427
+ };
4428
+ var bookSchema = z22.object({
4429
+ /** How far the outermost page lifts off the block, degrees. */
4430
+ spread: z22.number().min(0).max(150).default(55),
4431
+ /** Fraction of the pages bound to the left. 0 = a one-sided sample book. */
4432
+ split: z22.number().min(0).max(1).default(0.5),
4433
+ /** Page thickness — the gap between pages of one block. */
4434
+ lift: z22.number().min(1e-3).max(0.05).default(8e-3),
4435
+ /** How much more a lifted page arcs than one lying flat in the block. */
4436
+ gutter: z22.number().min(0).max(1).default(0.6)
4437
+ });
4438
+ var book = {
4439
+ id: "book",
4440
+ label: "Book",
4441
+ defaults: bookSchema.parse({}),
4442
+ optionsSchema: bookSchema,
4443
+ pose(i, n, o, _phase, sheet2) {
4444
+ const half = sheet2.width / 2;
4445
+ const left = Math.round(n * o.split);
4446
+ const onLeft = i < left;
4447
+ const count = onLeft ? left : n - left;
4448
+ const k = onLeft ? i : i - left;
4449
+ const f = count > 1 ? k / (count - 1) : 1;
4450
+ const theta = f * o.spread * DEG6;
4451
+ const side = onLeft ? -1 : 1;
4452
+ const cos = Math.cos(theta);
4453
+ const sin = Math.sin(theta);
4454
+ const offset = k * o.lift;
4455
+ return {
4456
+ position: [side * (half * cos - offset * sin), 0, half * sin + offset * cos],
4457
+ rotation: [0, -side * theta, 0],
4458
+ scale: 1,
4459
+ bias: 1 - (1 - f) * o.gutter
4460
+ };
4461
+ }
4462
+ };
4463
+ var accordionSchema = z22.object({
4464
+ /** How far each panel tilts off the strip's line, degrees. 0 = flat, 90 = shut. */
4465
+ angle: z22.number().min(0).max(89).default(55),
4466
+ /** A concertina holds its creases — how much bow the panels keep. */
4467
+ slack: z22.number().min(0).max(1).default(0.15)
4468
+ });
4469
+ var accordion = {
4470
+ id: "accordion",
4471
+ label: "Accordion",
4472
+ defaults: accordionSchema.parse({}),
4473
+ optionsSchema: accordionSchema,
4474
+ pose(i, n, o, _phase, sheet2) {
4475
+ const theta = o.angle * DEG6;
4476
+ const side = i % 2 === 0 ? 1 : -1;
4477
+ const step = sheet2.width * Math.cos(theta);
4478
+ return {
4479
+ position: [(i - (n - 1) / 2) * step, 0, 0],
4480
+ rotation: [0, side * theta, 0],
4481
+ scale: 1,
4482
+ bias: o.slack
4483
+ };
4484
+ }
4485
+ };
4486
+ var rackSchema = z22.object({
4487
+ /** Gap along the row, as a fraction of the paper's width. Under 1 they overlap. */
4488
+ spacing: z22.number().min(0.05).max(2).default(0.82),
4489
+ /** How far a sheet leans back off vertical, degrees. */
4490
+ lean: z22.number().min(0).max(70).default(16),
4491
+ /** How much that lean differs sheet to sheet — nothing propped is uniform. */
4492
+ vary: z22.number().min(0).max(1).default(0.55),
4493
+ /** Small rotations off square. */
4494
+ sway: z22.number().min(0).max(1).default(0.35),
4495
+ seed: z22.number().int().min(0).max(9999).default(5)
4496
+ });
4497
+ var rack = {
4498
+ id: "rack",
4499
+ label: "Rack",
4500
+ defaults: rackSchema.parse({}),
4501
+ optionsSchema: rackSchema,
4502
+ pose(i, n, o, _phase, sheet2) {
4503
+ const lean = o.lean * DEG6 * (1 + jitter2(o.seed, i) * o.vary);
4504
+ const half = sheet2.height / 2;
4505
+ return {
4506
+ position: [
4507
+ (i - (n - 1) / 2) * sheet2.width * o.spacing,
4508
+ // Standing on the floor: the bottom edge stays at y = 0 as it leans.
4509
+ half * Math.cos(lean),
4510
+ -half * Math.sin(lean) + i * 4e-3
4511
+ ],
4512
+ rotation: [-lean, jitter2(o.seed + 1, i) * 0.12 * o.sway, jitter2(o.seed + 2, i) * 0.06 * o.sway],
4513
+ scale: 1,
4514
+ // A sheet leaning further has more of its own weight to carry.
4515
+ bias: o.lean === 0 ? 0 : Math.min(1, lean / (o.lean * DEG6 * (1 + o.vary)))
4516
+ };
4517
+ }
4518
+ };
4519
+ var colonnadeSchema = z22.object({
4520
+ /** The walk the colonnade is built along — see `stage/path`. */
4521
+ path: walkPathSchema.default({}),
4522
+ /** Half-width of the clear aisle: how far each banner stands off the walk line. */
4523
+ aisle: z22.number().min(0.2).max(20).default(2.4),
4524
+ /** How much that gap opens and closes along the walk. Nothing hung by hand is a corridor. */
4525
+ breathe: z22.number().min(0).max(1).default(0.3),
4526
+ /** Widest angle a banner turns off square to the aisle, degrees. */
4527
+ twist: z22.number().min(0).max(90).default(22),
4528
+ /** Fraction of the walk left clear at each end, so the figure has somewhere to enter from. */
4529
+ margin: z22.number().min(0).max(0.45).default(0.05),
4530
+ /** Spread of banner heights, 0..1. */
4531
+ rise: z22.number().min(0).max(1).default(0.28),
4532
+ /** How far the banners lift off the floor, as a fraction of their height. 0 = they pool on it. */
4533
+ hover: z22.number().min(0).max(1).default(0),
4534
+ /** Spread of deformation — no two lengths of hung paper drape alike. */
4535
+ drape: z22.number().min(0).max(1).default(0.5),
4536
+ seed: z22.number().int().min(0).max(9999).default(2)
4537
+ });
4538
+ var colonnade = {
4539
+ id: "colonnade",
4540
+ label: "Colonnade",
4541
+ defaults: colonnadeSchema.parse({}),
4542
+ optionsSchema: colonnadeSchema,
4543
+ pose(i, n, o, phase, sheet2) {
4544
+ const path = getWalkPath(o.path);
4545
+ const side = i % 2 === 0 ? 1 : -1;
4546
+ const pairs = Math.max(Math.ceil(n / 2), 1);
4547
+ const k = Math.floor(i / 2);
4548
+ const span = 1 - o.margin * 2;
4549
+ const step = pairs > 1 ? span / (pairs - 1) : 0;
4550
+ const base = o.margin + (pairs > 1 ? k * step : span / 2) + side * step * 0.25;
4551
+ const s = path.closed ? base + phase : base;
4552
+ const [px, pz] = path.pointAt(s);
4553
+ const [nx, nz] = path.normalAt(s);
4554
+ const scale = 1 + jitter2(o.seed, i) * o.rise * 0.5;
4555
+ const offset = o.aisle * (1 + jitter2(o.seed + 1, i) * o.breathe);
4556
+ const height = sheet2.height * scale;
4557
+ const yaw = Math.atan2(-side * nx, -side * nz) + jitter2(o.seed + 2, i) * o.twist * DEG6;
4558
+ return {
4559
+ position: [px + nx * side * offset, height / 2 + height * o.hover, pz + nz * side * offset],
4560
+ rotation: [0, yaw, 0],
4561
+ scale,
4562
+ bias: 1 - Math.abs(jitter2(o.seed + 3, i)) * o.drape
3813
4563
  };
3814
4564
  }
3815
4565
  };
@@ -3838,36 +4588,48 @@ function listLayouts() {
3838
4588
  return [...registry3.keys()];
3839
4589
  }
3840
4590
  registerLayout(ring);
3841
- registerLayout(deck);
3842
- registerLayout(cascade);
3843
- registerLayout(helix);
4591
+ registerLayout(fan);
4592
+ registerLayout(spread);
4593
+ registerLayout(pile);
3844
4594
  registerLayout(wall);
3845
- registerLayout(tunnel);
3846
- registerLayout(scatter);
4595
+ registerLayout(spill);
4596
+ registerLayout(sweep);
4597
+ registerLayout(book);
4598
+ registerLayout(accordion);
4599
+ registerLayout(rack);
4600
+ registerLayout(colonnade);
3847
4601
  registerLayout(sheet);
3848
4602
 
4603
+ // src/field/stack.ts
4604
+ function fieldShapeStack(config, progress) {
4605
+ if (config.deformers) return resolveDeformerStack(config.deformers);
4606
+ if (!config.behavior) return [];
4607
+ const behavior = getBehavior(config.behavior.type);
4608
+ const options = { ...config.behavior, [behavior.progressParam]: progress };
4609
+ return behavior.stack(options, config.sheet);
4610
+ }
4611
+
3849
4612
  // src/field/fieldGroup.tsx
3850
4613
  import { jsx as jsx7 } from "react/jsx-runtime";
3851
- var scratchObj = new THREE10.Object3D();
4614
+ var scratchObj = new THREE11.Object3D();
3852
4615
  var scratchAero = { position: [0, 0, 0], rotation: [0, 0, 0] };
3853
4616
  var FIELD_SEGMENT_CAP = 48;
3854
4617
  function FieldGroup({ group, shared }) {
3855
4618
  const { config, indices, contents } = group;
3856
4619
  const count = indices.length;
3857
4620
  const stock = getStock(config.stock);
3858
- const behavior = config.behavior ? getBehavior(config.behavior.type) : null;
4621
+ const behavior = config.behavior && !config.deformers ? getBehavior(config.behavior.type) : null;
3859
4622
  const progressRef = useRef4(
3860
4623
  behavior ? config.behavior[behavior.progressParam] : 0
3861
4624
  );
3862
- const buildStackAt = (progress) => {
3863
- if (!config.behavior || !behavior) return [];
3864
- const options = { ...config.behavior, [behavior.progressParam]: progress };
3865
- return behavior.stack(options, config.sheet);
3866
- };
4625
+ const buildStackAt = (progress) => fieldShapeStack(config, progress);
3867
4626
  const initialStack = useMemo7(
3868
4627
  () => buildStackAt(progressRef.current),
3869
- // eslint-disable-next-line react-hooks/exhaustive-deps
3870
- [JSON.stringify(config.behavior ?? null), JSON.stringify(config.sheet)]
4628
+ [
4629
+ JSON.stringify(config.behavior ?? null),
4630
+ JSON.stringify(config.deformers ?? null),
4631
+ JSON.stringify(config.sheet)
4632
+ ]
3871
4633
  );
3872
4634
  const structureKey = initialStack.map((i) => i.type).join("|");
3873
4635
  const geometry = useMemo7(() => {
@@ -3880,12 +4642,14 @@ function FieldGroup({ group, shared }) {
3880
4642
  );
3881
4643
  const atlasIdx = new Float32Array(count);
3882
4644
  const phase = new Float32Array(count);
4645
+ const bias = new Float32Array(count).fill(1);
3883
4646
  for (let i = 0; i < count; i++) {
3884
4647
  atlasIdx[i] = i;
3885
4648
  phase[i] = indices[i] * 0.618034 % 1 * 4;
3886
4649
  }
3887
- geo.setAttribute("aAtlas", new THREE10.InstancedBufferAttribute(atlasIdx, 1));
3888
- geo.setAttribute("aPhase", new THREE10.InstancedBufferAttribute(phase, 1));
4650
+ geo.setAttribute("aAtlas", new THREE11.InstancedBufferAttribute(atlasIdx, 1));
4651
+ geo.setAttribute("aPhase", new THREE11.InstancedBufferAttribute(phase, 1));
4652
+ geo.setAttribute("aBias", new THREE11.InstancedBufferAttribute(bias, 1));
3889
4653
  return geo;
3890
4654
  }, [JSON.stringify(config.sheet), structureKey, count]);
3891
4655
  useEffect9(() => () => geometry.dispose(), [geometry]);
@@ -3895,23 +4659,34 @@ function FieldGroup({ group, shared }) {
3895
4659
  const uniforms = {};
3896
4660
  for (const [name, value] of Object.entries(composed.uniforms)) {
3897
4661
  uniforms[name] = {
3898
- value: Array.isArray(value) && value.length === 2 ? new THREE10.Vector2(...value) : value
4662
+ value: Array.isArray(value) && value.length === 2 ? new THREE11.Vector2(...value) : value
3899
4663
  };
3900
4664
  }
3901
4665
  uniforms.uPlTime = { value: 0 };
3902
4666
  uniforms.uAtlas = { value: null };
3903
- uniforms.uAtlasGrid = { value: new THREE10.Vector2(1, 1) };
4667
+ uniforms.uAtlasGrid = { value: new THREE11.Vector2(1, 1) };
3904
4668
  uniforms.uBackDarken = {
3905
4669
  value: 1 - Math.min(0.45, 0.12 + config.sheet.thickness * 0.9) * stock.opacity
3906
4670
  };
3907
- uniforms.uStockColor = { value: new THREE10.Color(stock.color) };
4671
+ uniforms.uStockColor = { value: new THREE11.Color(stock.color) };
3908
4672
  uniforms.uShowThrough = { value: config.surface.showThrough ?? stock.showThrough };
4673
+ Object.assign(
4674
+ uniforms,
4675
+ translucencyUniforms(config.surface.translucency ?? stock.translucency, config.scene.lighting)
4676
+ );
3909
4677
  return {
3910
4678
  vertexShader: buildFieldVertexShader(composed),
3911
4679
  fragmentShader: buildFieldFragmentShader(),
3912
4680
  uniforms
3913
4681
  };
3914
- }, [structureKey, JSON.stringify(config.sheet), stock.id, config.surface.showThrough]);
4682
+ }, [
4683
+ structureKey,
4684
+ JSON.stringify(config.sheet),
4685
+ stock.id,
4686
+ config.surface.showThrough,
4687
+ config.surface.translucency,
4688
+ config.scene.lighting
4689
+ ]);
3915
4690
  useEffect9(() => {
3916
4691
  if (!atlas) return;
3917
4692
  shader.uniforms.uAtlas.value = atlas.texture;
@@ -3945,7 +4720,7 @@ function FieldGroup({ group, shared }) {
3945
4720
  for (const [name, value] of Object.entries(values)) {
3946
4721
  const uniform = shader.uniforms[name];
3947
4722
  if (!uniform) continue;
3948
- if (uniform.value instanceof THREE10.Vector2 && Array.isArray(value)) {
4723
+ if (uniform.value instanceof THREE11.Vector2 && Array.isArray(value)) {
3949
4724
  uniform.value.set(value[0], value[1]);
3950
4725
  } else {
3951
4726
  uniform.value = value;
@@ -3955,15 +4730,19 @@ function FieldGroup({ group, shared }) {
3955
4730
  const layout = getLayout(shared.layoutId);
3956
4731
  const morph = shared.morphRef.current;
3957
4732
  const behaviorTransform = behavior?.transform && config.behavior ? behavior : null;
4733
+ const biasAttr = geometry.getAttribute("aBias");
4734
+ const biases = biasAttr.array;
4735
+ let biasChanged = false;
3958
4736
  for (let j = 0; j < count; j++) {
3959
4737
  const i = indices[j];
3960
- let pose = layout.pose(i, shared.total, shared.layoutOptions, shared.phaseRef.current);
4738
+ let pose = layout.pose(i, shared.total, shared.layoutOptions, shared.phaseRef.current, shared.sheet);
3961
4739
  if (morph.from && morph.t < 1) {
3962
4740
  const prev = getLayout(morph.from.id).pose(
3963
4741
  i,
3964
4742
  shared.total,
3965
4743
  morph.from.options,
3966
- shared.phaseRef.current
4744
+ shared.phaseRef.current,
4745
+ shared.sheet
3967
4746
  );
3968
4747
  pose = lerpPose(prev, pose, easeInOut(morph.t));
3969
4748
  }
@@ -3973,6 +4752,11 @@ function FieldGroup({ group, shared }) {
3973
4752
  pose = lerpPose(entrancePose(shared.entranceType, i, pose), pose, easeOut(tIn));
3974
4753
  }
3975
4754
  }
4755
+ const bias = Math.min(1, Math.max(0, pose.bias ?? 1));
4756
+ if (biases[j] !== bias) {
4757
+ biases[j] = bias;
4758
+ biasChanged = true;
4759
+ }
3976
4760
  scratchObj.position.set(...pose.position);
3977
4761
  scratchObj.rotation.set(...pose.rotation);
3978
4762
  scratchObj.scale.setScalar(pose.scale);
@@ -3997,6 +4781,7 @@ function FieldGroup({ group, shared }) {
3997
4781
  mesh.setMatrixAt(j, scratchObj.matrix);
3998
4782
  }
3999
4783
  mesh.instanceMatrix.needsUpdate = true;
4784
+ if (biasChanged) biasAttr.needsUpdate = true;
4000
4785
  });
4001
4786
  return /* @__PURE__ */ jsx7(
4002
4787
  "instancedMesh",
@@ -4009,13 +4794,13 @@ function FieldGroup({ group, shared }) {
4009
4794
  children: /* @__PURE__ */ jsx7(
4010
4795
  CustomShaderMaterial2,
4011
4796
  {
4012
- baseMaterial: THREE10.MeshStandardMaterial,
4797
+ baseMaterial: THREE11.MeshStandardMaterial,
4013
4798
  vertexShader: shader.vertexShader,
4014
4799
  fragmentShader: shader.fragmentShader,
4015
4800
  uniforms: shader.uniforms,
4016
4801
  roughness: stock.roughness,
4017
4802
  metalness: 0,
4018
- side: THREE10.DoubleSide
4803
+ side: THREE11.DoubleSide
4019
4804
  },
4020
4805
  `${structureKey}:${count}`
4021
4806
  )
@@ -4027,14 +4812,16 @@ function entrancePose(type, i, target) {
4027
4812
  return {
4028
4813
  position: [target.position[0], target.position[1] - 3.2, target.position[2] - 0.5],
4029
4814
  rotation: [target.rotation[0] - 0.7, target.rotation[1], target.rotation[2] + 0.25],
4030
- scale: target.scale * 0.85
4815
+ scale: target.scale * 0.85,
4816
+ bias: target.bias
4031
4817
  };
4032
4818
  }
4033
4819
  const a = i * 2.399;
4034
4820
  return {
4035
4821
  position: [Math.cos(a) * 7, Math.sin(a * 1.3) * 4, Math.sin(a) * 6],
4036
4822
  rotation: [Math.sin(a) * 2, a, Math.cos(a) * 2],
4037
- scale: target.scale * 0.6
4823
+ scale: target.scale * 0.6,
4824
+ bias: target.bias
4038
4825
  };
4039
4826
  }
4040
4827
  function lerpPose(a, b, t) {
@@ -4050,14 +4837,15 @@ function lerpPose(a, b, t) {
4050
4837
  lerp(a.rotation[1], b.rotation[1]),
4051
4838
  lerp(a.rotation[2], b.rotation[2])
4052
4839
  ],
4053
- scale: lerp(a.scale, b.scale)
4840
+ scale: lerp(a.scale, b.scale),
4841
+ bias: lerp(a.bias ?? 1, b.bias ?? 1)
4054
4842
  };
4055
4843
  }
4056
4844
  var easeOut = (t) => 1 - (1 - t) ** 3;
4057
4845
  var easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
4058
4846
 
4059
4847
  // src/field/backingSheet.tsx
4060
- import * as THREE11 from "three";
4848
+ import * as THREE12 from "three";
4061
4849
  import { useEffect as useEffect10, useMemo as useMemo8 } from "react";
4062
4850
 
4063
4851
  // src/content/backing.ts
@@ -4139,7 +4927,7 @@ function BackingSheet({
4139
4927
  c.height = Math.max(2, Math.round(height * scale));
4140
4928
  return c;
4141
4929
  }, [width, height]);
4142
- const texture = useMemo8(() => canvas ? new THREE11.CanvasTexture(canvas) : null, [canvas]);
4930
+ const texture = useMemo8(() => canvas ? new THREE12.CanvasTexture(canvas) : null, [canvas]);
4143
4931
  const removedKey = [...removed].sort((a, b) => a - b).join(",");
4144
4932
  useEffect10(() => {
4145
4933
  if (!canvas || !texture) return;
@@ -4154,7 +4942,7 @@ function BackingSheet({
4154
4942
  }
4155
4943
 
4156
4944
  // src/field/interactiveField.tsx
4157
- import * as THREE12 from "three";
4945
+ import * as THREE13 from "three";
4158
4946
  import { gsap as gsap4 } from "gsap";
4159
4947
  import { useFrame as useFrame4, useThree as useThree3 } from "@react-three/fiber";
4160
4948
  import { useContext as useContext2, useEffect as useEffect11, useMemo as useMemo9, useRef as useRef5, useState as useState5 } from "react";
@@ -4179,7 +4967,6 @@ function InteractiveField(props) {
4179
4967
  const patch = slotPatches[i];
4180
4968
  return patch ? paperConfigSchema.parse(mergeConfig(config, patch)) : config;
4181
4969
  }),
4182
- // eslint-disable-next-line react-hooks/exhaustive-deps
4183
4970
  [
4184
4971
  JSON.stringify(papers),
4185
4972
  JSON.stringify(fallback ?? null),
@@ -4189,9 +4976,8 @@ function InteractiveField(props) {
4189
4976
  ]
4190
4977
  );
4191
4978
  const poses = useMemo9(
4192
- () => slotConfigs.map((_, i) => layout.pose(i, total, layoutOptions, 0)),
4193
- // eslint-disable-next-line react-hooks/exhaustive-deps
4194
- [layoutId, JSON.stringify(layoutOptions), total]
4979
+ () => slotConfigs.map((_, i) => layout.pose(i, total, layoutOptions, 0, props.sheet)),
4980
+ [layoutId, JSON.stringify(layoutOptions), total, props.sheet.width, props.sheet.height]
4195
4981
  );
4196
4982
  const groupRefs = useRef5([]);
4197
4983
  const handleRefs = useRef5([]);
@@ -4209,10 +4995,10 @@ function InteractiveField(props) {
4209
4995
  }
4210
4996
  props.onSlotStateChange?.(i, state);
4211
4997
  };
4212
- const raycaster = useMemo9(() => new THREE12.Raycaster(), []);
4213
- const planeScratch = useMemo9(() => new THREE12.Plane(new THREE12.Vector3(0, 0, 1), 0), []);
4214
- const pointScratch = useMemo9(() => new THREE12.Vector3(), []);
4215
- const ndcScratch = useMemo9(() => new THREE12.Vector2(), []);
4998
+ const raycaster = useMemo9(() => new THREE13.Raycaster(), []);
4999
+ const planeScratch = useMemo9(() => new THREE13.Plane(new THREE13.Vector3(0, 0, 1), 0), []);
5000
+ const pointScratch = useMemo9(() => new THREE13.Vector3(), []);
5001
+ const ndcScratch = useMemo9(() => new THREE13.Vector2(), []);
4216
5002
  const planePoint = (clientX, clientY, planeZ) => {
4217
5003
  const rect = gl.domElement.getBoundingClientRect();
4218
5004
  ndcScratch.set(
@@ -4375,7 +5161,7 @@ function InteractiveField(props) {
4375
5161
  if (carried.pointerId !== null && e.pointerId !== carried.pointerId) return;
4376
5162
  const name = slotName(carried.slot);
4377
5163
  const zone = zonesLive().find(
4378
- (z18) => zoneContains(z18, carried.x.value, carried.y.value) && zoneAccepts(z18, name)
5164
+ (z27) => zoneContains(z27, carried.x.value, carried.y.value) && zoneAccepts(z27, name)
4379
5165
  );
4380
5166
  if (zone) settleInto(carried, zone);
4381
5167
  else returnHome(carried);
@@ -4416,12 +5202,12 @@ function InteractiveField(props) {
4416
5202
  group.position.z = carried.homePose.position[2] + 0.15;
4417
5203
  handle.set("drive", carryDrive(speed));
4418
5204
  const lag = 0.25;
4419
- group.rotation.y += (THREE12.MathUtils.clamp(-vx * lag, -0.6, 0.6) - group.rotation.y) * 0.12;
4420
- group.rotation.x += (THREE12.MathUtils.clamp(vy * lag * 0.7, -0.5, 0.5) - group.rotation.x) * 0.12;
5205
+ group.rotation.y += (THREE13.MathUtils.clamp(-vx * lag, -0.6, 0.6) - group.rotation.y) * 0.12;
5206
+ group.rotation.x += (THREE13.MathUtils.clamp(vy * lag * 0.7, -0.5, 0.5) - group.rotation.x) * 0.12;
4421
5207
  }
4422
5208
  const name = slotName(carried.slot);
4423
5209
  const zone = zonesLive().find(
4424
- (z18) => zoneContains(z18, group.position.x, group.position.y) && zoneAccepts(z18, name)
5210
+ (z27) => zoneContains(z27, group.position.x, group.position.y) && zoneAccepts(z27, name)
4425
5211
  );
4426
5212
  registry4.setHovered(zone?.id ?? null);
4427
5213
  const targetScale = (zone ? 1.03 : 1) * carried.homePose.scale;
@@ -4437,7 +5223,7 @@ function InteractiveField(props) {
4437
5223
  },
4438
5224
  placeAtZone: (slot, zoneId) => {
4439
5225
  const carried = carriedRef.current;
4440
- const zone = zonesLive().find((z18) => z18.id === zoneId);
5226
+ const zone = zonesLive().find((z27) => z27.id === zoneId);
4441
5227
  const group = groupRefs.current[slot];
4442
5228
  if (!carried || carried.slot !== slot || !zone || !group) return;
4443
5229
  group.position.x = zone.bounds.position[0];
@@ -4450,7 +5236,7 @@ function InteractiveField(props) {
4450
5236
  const carried = carriedRef.current;
4451
5237
  if (carried && carried.slot === slot && !carried.settling) returnHome(carried);
4452
5238
  },
4453
- zoneIds: () => zonesLive().map((z18) => z18.id),
5239
+ zoneIds: () => zonesLive().map((z27) => z27.id),
4454
5240
  slotState: (slot) => slotStates[slot] ?? "rest"
4455
5241
  };
4456
5242
  useEffect11(() => {
@@ -4568,25 +5354,88 @@ function FieldKeyboardMirror({
4568
5354
  if (handled) e.preventDefault();
4569
5355
  if (carry2 !== carrying) setCarrying(carry2);
4570
5356
  };
4571
- return /* @__PURE__ */ jsx10("div", { style: mirrorHidden, role: "group", "aria-label": "Interactive papers", children: papers.map((slot, i) => /* @__PURE__ */ jsxs7(
4572
- "button",
4573
- {
4574
- type: "button",
4575
- onKeyDown: onKeyDown(i),
4576
- "aria-label": paperLabel(slot, i),
4577
- "aria-pressed": carrying?.slot === i,
4578
- children: [
4579
- paperLabel(slot, i),
4580
- carrying?.slot === i && /* @__PURE__ */ jsxs7("span", { "aria-live": "polite", children: [
4581
- " ",
4582
- "\u2014 carrying; zone ",
4583
- controller.current?.zoneIds()[carrying.zoneIndex] ?? "none",
4584
- "; Enter places, Escape returns"
4585
- ] })
4586
- ]
4587
- },
4588
- i
4589
- )) });
5357
+ return /* @__PURE__ */ jsxs7("fieldset", { style: mirrorHidden, children: [
5358
+ /* @__PURE__ */ jsx10("legend", { children: "Interactive papers" }),
5359
+ papers.map((slot, i) => /* @__PURE__ */ jsxs7(
5360
+ "button",
5361
+ {
5362
+ type: "button",
5363
+ onKeyDown: onKeyDown(i),
5364
+ "aria-label": paperLabel(slot, i),
5365
+ "aria-pressed": carrying?.slot === i,
5366
+ children: [
5367
+ paperLabel(slot, i),
5368
+ carrying?.slot === i && /* @__PURE__ */ jsxs7("span", { "aria-live": "polite", children: [
5369
+ " ",
5370
+ "\u2014 carrying; zone ",
5371
+ controller.current?.zoneIds()[carrying.zoneIndex] ?? "none",
5372
+ "; Enter places, Escape returns"
5373
+ ] })
5374
+ ]
5375
+ },
5376
+ i
5377
+ ))
5378
+ ] });
5379
+ }
5380
+
5381
+ // src/field/framing.ts
5382
+ var PHASE_SAMPLES = 8;
5383
+ function resolveLayoutOptions(layoutId, layout, propOptions, firstSheet) {
5384
+ const parsed = layout.optionsSchema.parse({
5385
+ ...layout.defaults,
5386
+ ...propOptions
5387
+ });
5388
+ if (layoutId !== "sheet") return parsed;
5389
+ return withSheetCellFromPaper(
5390
+ parsed,
5391
+ propOptions,
5392
+ firstSheet
5393
+ );
5394
+ }
5395
+ function fieldBounds(layout, n, options, sheet2) {
5396
+ const reach = Math.hypot(sheet2.width, sheet2.height) / 2;
5397
+ if (n <= 0) return { center: [0, 0, 0], half: [reach, reach, 0] };
5398
+ const min = [Infinity, Infinity, Infinity];
5399
+ const max = [-Infinity, -Infinity, -Infinity];
5400
+ for (let s = 0; s < PHASE_SAMPLES; s++) {
5401
+ for (let i = 0; i < n; i++) {
5402
+ const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
5403
+ const r = reach * Math.max(pose.scale, 0);
5404
+ for (let axis = 0; axis < 3; axis++) {
5405
+ min[axis] = Math.min(min[axis], pose.position[axis] - r);
5406
+ max[axis] = Math.max(max[axis], pose.position[axis] + r);
5407
+ }
5408
+ }
5409
+ }
5410
+ return {
5411
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
5412
+ half: [(max[0] - min[0]) / 2, (max[1] - min[1]) / 2, (max[2] - min[2]) / 2]
5413
+ };
5414
+ }
5415
+ var LIFT = 0.11;
5416
+ var DEG7 = Math.PI / 180;
5417
+ function fitCamera(layout, n, options, sheet2, fovDeg, aspect, margin = 1.06) {
5418
+ const { center } = fieldBounds(layout, n, options, sheet2);
5419
+ const reach = Math.hypot(sheet2.width, sheet2.height) / 2 * margin;
5420
+ const vTan = Math.tan(fovDeg * DEG7 / 2);
5421
+ const hTan = vTan * Math.max(aspect, 0.01);
5422
+ let distance = 0.1;
5423
+ for (let s = 0; s < PHASE_SAMPLES; s++) {
5424
+ for (let i = 0; i < Math.max(n, 0); i++) {
5425
+ const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
5426
+ const r = reach * Math.max(pose.scale, 0);
5427
+ const depth = pose.position[2] - center[2];
5428
+ distance = Math.max(
5429
+ distance,
5430
+ (Math.abs(pose.position[0] - center[0]) + r) / hTan + depth,
5431
+ (Math.abs(pose.position[1] - center[1]) + r) / vTan + depth
5432
+ );
5433
+ }
5434
+ }
5435
+ return {
5436
+ position: [center[0], center[1] + distance * LIFT, center[2] + distance],
5437
+ target: center
5438
+ };
4590
5439
  }
4591
5440
 
4592
5441
  // src/PaperField.tsx
@@ -4600,25 +5449,18 @@ var PaperFieldMesh = forwardRef3(
4600
5449
  );
4601
5450
  const groups = useMemo10(
4602
5451
  () => groupFieldPapers(papers, props.preset),
4603
- // eslint-disable-next-line react-hooks/exhaustive-deps
4604
5452
  [JSON.stringify(papers), JSON.stringify(props.preset ?? null)]
4605
5453
  );
4606
5454
  const total = papers.length;
4607
5455
  const layoutId = props.layout ?? "ring";
4608
5456
  const layout = getLayout(layoutId);
4609
5457
  const firstSheet = groups[0]?.config.sheet;
4610
- const layoutOptions = useMemo10(() => {
4611
- const parsed = layout.optionsSchema.parse({
4612
- ...layout.defaults,
4613
- ...props.layoutOptions
4614
- });
4615
- if (layoutId !== "sheet") return parsed;
4616
- return withSheetCellFromPaper(
4617
- parsed,
4618
- props.layoutOptions,
4619
- firstSheet
4620
- );
4621
- }, [layoutId, JSON.stringify(props.layoutOptions ?? {}), firstSheet?.width, firstSheet?.height]);
5458
+ const layoutOptions = useMemo10(
5459
+ // Sheet grids size their cells from the papers themselves — gutter is
5460
+ // then literally the spacing between stamps (explicit cell dims win).
5461
+ () => resolveLayoutOptions(layoutId, layout, props.layoutOptions, firstSheet),
5462
+ [layoutId, JSON.stringify(props.layoutOptions ?? {}), firstSheet?.width, firstSheet?.height]
5463
+ );
4622
5464
  const phaseRef = useRef6(0);
4623
5465
  const dragVelRef = useRef6(0);
4624
5466
  const mountTimeRef = useRef6(-1);
@@ -4676,6 +5518,7 @@ var PaperFieldMesh = forwardRef3(
4676
5518
  morphRef,
4677
5519
  layoutId,
4678
5520
  layoutOptions,
5521
+ sheet: firstSheet ?? DEFAULT_SHEET,
4679
5522
  entranceType,
4680
5523
  stagger: props.entrance?.stagger ?? 0.06,
4681
5524
  entranceDuration: props.entrance?.duration ?? 0.9,
@@ -4694,6 +5537,7 @@ var PaperFieldMesh = forwardRef3(
4694
5537
  layoutId,
4695
5538
  layoutOptions,
4696
5539
  sheetOptions,
5540
+ sheet: firstSheet ?? DEFAULT_SHEET,
4697
5541
  reducedMotion: props.reducedMotion,
4698
5542
  zones: props.zones,
4699
5543
  onSlotStateChange: props.onSlotStateChange,
@@ -4704,10 +5548,47 @@ var PaperFieldMesh = forwardRef3(
4704
5548
  }
4705
5549
  return /* @__PURE__ */ jsxs8("group", { ref, children: [
4706
5550
  sheetOptions?.backing && /* @__PURE__ */ jsx11(BackingSheet, { options: sheetOptions, count: total, removed: EMPTY_SET }),
4707
- groups.map((group, gi) => /* @__PURE__ */ jsx11(FieldGroup, { group, shared }, `${gi}:${group.indices.length}`))
5551
+ groups.map((group, gi) => (
5552
+ // biome-ignore lint/suspicious/noArrayIndexKey: groups are derived from the slot list in order, so position is their only identity.
5553
+ /* @__PURE__ */ jsx11(FieldGroup, { group, shared }, `${gi}:${group.indices.length}`)
5554
+ ))
4708
5555
  ] });
4709
5556
  }
4710
5557
  );
5558
+ function FitCamera(meshProps) {
5559
+ const camera = useThree4((s) => s.camera);
5560
+ const width = useThree4((s) => s.size.width);
5561
+ const height = useThree4((s) => s.size.height);
5562
+ const field = useMemo10(() => {
5563
+ const papers = effectiveFieldPapers(meshProps.papers, meshProps.images);
5564
+ const layoutId = meshProps.layout ?? "ring";
5565
+ const layout = getLayout(layoutId);
5566
+ const sheet2 = groupFieldPapers(papers, meshProps.preset)[0]?.config.sheet;
5567
+ const options = resolveLayoutOptions(layoutId, layout, meshProps.layoutOptions, sheet2);
5568
+ return { layout, n: papers.length, options, sheet: sheet2 ?? DEFAULT_SHEET };
5569
+ }, [
5570
+ JSON.stringify(meshProps.papers ?? null),
5571
+ JSON.stringify(meshProps.images ?? null),
5572
+ JSON.stringify(meshProps.preset ?? null),
5573
+ meshProps.layout,
5574
+ JSON.stringify(meshProps.layoutOptions ?? {})
5575
+ ]);
5576
+ useEffect12(() => {
5577
+ if (!(camera instanceof THREE14.PerspectiveCamera)) return;
5578
+ const { position, target } = fitCamera(
5579
+ field.layout,
5580
+ field.n,
5581
+ field.options,
5582
+ field.sheet,
5583
+ camera.fov,
5584
+ width / Math.max(height, 1)
5585
+ );
5586
+ camera.position.set(...position);
5587
+ camera.lookAt(...target);
5588
+ camera.updateProjectionMatrix();
5589
+ }, [camera, width, height, field]);
5590
+ return null;
5591
+ }
4711
5592
  var PaperField = forwardRef3(function PaperField2({ children, className, style, ...meshProps }, ref) {
4712
5593
  const registry4 = useMemo10(() => new DropZoneRegistry(), []);
4713
5594
  const a11yRef = useRef6(null);
@@ -4717,11 +5598,11 @@ var PaperField = forwardRef3(function PaperField2({ children, className, style,
4717
5598
  );
4718
5599
  const interactive = useMemo10(
4719
5600
  () => fieldIsInteractive(papers, meshProps.preset, meshProps.interactive),
4720
- // eslint-disable-next-line react-hooks/exhaustive-deps
4721
5601
  [JSON.stringify(papers), JSON.stringify(meshProps.preset ?? null), meshProps.interactive]
4722
5602
  );
4723
5603
  return /* @__PURE__ */ jsx11("div", { className, style: { width: "100%", height: "100%", ...style }, children: /* @__PURE__ */ jsxs8(DropZoneContext.Provider, { value: registry4, children: [
4724
5604
  /* @__PURE__ */ jsxs8(Canvas2, { shadows: true, camera: { position: [0, 0.6, 5.2], fov: 45 }, dpr: [1, 2], children: [
5605
+ /* @__PURE__ */ jsx11(FitCamera, { ...meshProps }),
4725
5606
  /* @__PURE__ */ jsx11("ambientLight", { intensity: 0.7 }),
4726
5607
  /* @__PURE__ */ jsx11(
4727
5608
  "directionalLight",
@@ -4740,202 +5621,762 @@ var PaperField = forwardRef3(function PaperField2({ children, className, style,
4740
5621
  ] }) });
4741
5622
  });
4742
5623
 
4743
- // src/field/parity.ts
4744
- import * as THREE13 from "three";
4745
- var GRID = 48;
4746
- var PARITY_EPSILON = 5e-4;
4747
- var parityCases = [
4748
- {
4749
- name: "roll: defaults",
4750
- stack: [{ type: "roll", options: { angle: 90, boundary: 0, radius: 0.12, spiral: 0.015 } }],
4751
- sheet: { width: 1, height: 1.4 },
4752
- t: 0
4753
- },
4754
- {
4755
- name: "roll: tight receipt roll, rolling down",
4756
- stack: [{ type: "roll", options: { angle: 270, boundary: -0.4, radius: 0.07, spiral: 0.02 } }],
4757
- sheet: { width: 1, height: 2.6 },
4758
- t: 0
4759
- },
4760
- {
4761
- name: "curl: bottom-right peel",
4762
- stack: [{ type: "curl", options: { corner: "bottom-right", amount: 0.45, radius: 0.2, skew: 0 } }],
4763
- sheet: { width: 1.5, height: 1 },
4764
- t: 0
4765
- },
4766
- {
4767
- name: "curl: skewed top-left",
4768
- stack: [{ type: "curl", options: { corner: "top-left", amount: 0.7, radius: 0.3, skew: 15 } }],
4769
- sheet: { width: 1, height: 1 },
4770
- t: 0
4771
- },
4772
- {
4773
- name: "bend: positive arc at an angle",
4774
- stack: [{ type: "bend", options: { curvature: 1.2, angle: 33 } }],
4775
- sheet: { width: 1, height: 1.4 },
4776
- t: 0
4777
- },
4778
- {
4779
- name: "bend: negative arc",
4780
- stack: [{ type: "bend", options: { curvature: -0.8, angle: 0 } }],
4781
- sheet: { width: 1, height: 1.4 },
4782
- t: 0
4783
- },
4784
- {
4785
- name: "fold: 90\xB0 hinge",
4786
- stack: [{ type: "fold", options: { angle: 90, offset: 0, foldAngle: 90, radius: 0.06 } }],
4787
- sheet: { width: 1, height: 1.4 },
4788
- t: 0
4789
- },
4790
- {
4791
- name: "fold: deep fold, travelling down",
4792
- stack: [{ type: "fold", options: { angle: 270, offset: 0.2, foldAngle: 165, radius: 0.04 } }],
4793
- sheet: { width: 1, height: 1.4 },
4794
- t: 0
4795
- },
4796
- {
4797
- name: "wave: free ripple at t=1.234",
4798
- stack: [
5624
+ // src/stage/PaperStage.tsx
5625
+ import * as THREE17 from "three";
5626
+ import { Canvas as Canvas3, useFrame as useFrame7, useThree as useThree5 } from "@react-three/fiber";
5627
+ import { useCallback, useEffect as useEffect15, useMemo as useMemo13, useRef as useRef8, useState as useState7 } from "react";
5628
+ import { z as z26 } from "zod";
5629
+
5630
+ // src/stage/camera.ts
5631
+ import { z as z23 } from "zod";
5632
+ var shotNames = ["follow", "lead", "low", "wide"];
5633
+ var shotSchema = z23.object({
5634
+ shot: z23.enum(shotNames).default("follow"),
5635
+ /**
5636
+ * How far the camera stands off the figure ALONG the walk, world units.
5637
+ * `wide` reads it as how far back it stands; how far it steps aside is
5638
+ * derived from the paper, since that is what it has to clear.
5639
+ */
5640
+ distance: z23.number().min(0.2).max(40).default(4.5),
5641
+ /** Multiplier on the shot's natural camera height. 1 is as designed. */
5642
+ height: z23.number().min(0).max(6).default(1),
5643
+ /** How far up the walk the camera looks past the figure, world units. */
5644
+ lookAhead: z23.number().min(0).max(40).default(7),
5645
+ /** Sideways step off the walk line, world units. Positive is the walker's left. */
5646
+ offset: z23.number().min(-20).max(20).default(0)
5647
+ });
5648
+ var EYE = {
5649
+ follow: 0.95,
5650
+ lead: 0.95,
5651
+ // Down near the floor, where the banners tower — the worm's-eye of the
5652
+ // reference frames, and the cheapest way to make paper read as architecture.
5653
+ low: 0.12,
5654
+ wide: 1.1
5655
+ };
5656
+ var AIM = {
5657
+ // Chest height on the figure, a third of the way up the paper — enough
5658
+ // tilt that a printed banner reads, not so much that the floor is lost.
5659
+ follow: { figure: 0.62, paper: 0.3 },
5660
+ // Framing the figure itself, so the paper only lifts the aim a little.
5661
+ lead: { figure: 0.62, paper: 0.1 },
5662
+ // Up the banners. The figure is incidental to this shot.
5663
+ low: { figure: 0, paper: 0.62 },
5664
+ wide: { figure: 0.62, paper: 0.2 }
5665
+ };
5666
+ var WIDE_STANDOFF = 1.5;
5667
+ var DEFAULT_PAPER_RATIO = 4.9;
5668
+ function resolveScale(scale) {
5669
+ if (typeof scale === "number") return { figure: scale, paper: scale * DEFAULT_PAPER_RATIO };
5670
+ return scale;
5671
+ }
5672
+ function walkPoint(path, distance) {
5673
+ if (path.length === 0) return path.pointAt(0);
5674
+ if (path.closed) return path.pointAt(distance / path.length);
5675
+ if (distance < 0) {
5676
+ const [x, z27] = path.pointAt(0);
5677
+ const [tx, tz] = path.tangentAt(0);
5678
+ return [x + tx * distance, z27 + tz * distance];
5679
+ }
5680
+ if (distance > path.length) {
5681
+ const over = distance - path.length;
5682
+ const [x, z27] = path.pointAt(1);
5683
+ const [tx, tz] = path.tangentAt(1);
5684
+ return [x + tx * over, z27 + tz * over];
5685
+ }
5686
+ return path.pointAt(distance / path.length);
5687
+ }
5688
+ function walkNormal(path, distance) {
5689
+ if (path.length === 0) return path.normalAt(0);
5690
+ if (path.closed) return path.normalAt(distance / path.length);
5691
+ return path.normalAt(Math.min(Math.max(distance, 0), path.length) / path.length);
5692
+ }
5693
+ function stageCamera(path, walked, scale, options) {
5694
+ const { figure, paper } = resolveScale(scale);
5695
+ const eye = figure * EYE[options.shot] * options.height;
5696
+ const aim = figure * AIM[options.shot].figure + paper * AIM[options.shot].paper;
5697
+ let station;
5698
+ let mark;
5699
+ if (options.shot === "lead") {
5700
+ station = walked + options.distance;
5701
+ mark = walked;
5702
+ } else if (options.shot === "wide") {
5703
+ station = walked - options.distance;
5704
+ mark = walked;
5705
+ } else {
5706
+ station = walked - options.distance;
5707
+ mark = walked + options.lookAhead;
5708
+ }
5709
+ const [sx, sz] = walkPoint(path, station);
5710
+ const [mx, mz] = walkPoint(path, mark);
5711
+ const [nx, nz] = walkNormal(path, station);
5712
+ const step = options.offset + (options.shot === "wide" ? paper * WIDE_STANDOFF : 0);
5713
+ return {
5714
+ position: [sx + nx * step, eye, sz + nz * step],
5715
+ target: [mx, aim, mz]
5716
+ };
5717
+ }
5718
+
5719
+ // src/stage/Figure.tsx
5720
+ import * as THREE15 from "three";
5721
+ import { useEffect as useEffect13, useMemo as useMemo11, useRef as useRef7 } from "react";
5722
+ import { useFrame as useFrame6 } from "@react-three/fiber";
5723
+
5724
+ // src/stage/gait.ts
5725
+ import { z as z24 } from "zod";
5726
+ var figureSchema = z24.object({
5727
+ /** Standing height in world units — the scale reference the whole stage is read against. */
5728
+ height: z24.number().min(0.5).max(4).default(1.75),
5729
+ /** World units per second along the walk. A relaxed indoor pace is ~1.2. */
5730
+ speed: z24.number().min(0).max(4).default(1.2),
5731
+ /** Stride length as a fraction of height — how far one step carries. */
5732
+ stride: z24.number().min(0.1).max(1).default(0.42),
5733
+ /** Arm swing, 0..1. Drop it toward 0 for hands-in-pockets stillness. */
5734
+ swing: z24.number().min(0).max(1).default(1),
5735
+ /** Silhouette color. Near-black by default: it should read as an absence, not an object. */
5736
+ color: z24.string().default("#0a0a0c")
5737
+ });
5738
+ var PROPORTIONS = {
5739
+ hip: 0.53,
5740
+ shoulder: 0.82,
5741
+ headRadius: 0.045,
5742
+ headCenter: 0.935,
5743
+ thigh: 0.245,
5744
+ shin: 0.235,
5745
+ upperArm: 0.185,
5746
+ foreArm: 0.165,
5747
+ torsoWidth: 0.19,
5748
+ hipWidth: 0.095,
5749
+ limbRadius: 0.028
5750
+ };
5751
+ var THIGH_SWING = 0.42;
5752
+ var ARM_SWING = 0.5;
5753
+ var KNEE_FLEX = 1.1;
5754
+ var BOB = 0.016;
5755
+ var LEAN = 0.045;
5756
+ var TAU4 = Math.PI * 2;
5757
+ function cycleLength(o) {
5758
+ return o.stride * o.height * 2;
5759
+ }
5760
+ function figureGait(distance, o) {
5761
+ const cycle = cycleLength(o);
5762
+ const phase = cycle > 0 ? (distance / cycle % 1 + 1) % 1 : 0;
5763
+ const w = phase * TAU4;
5764
+ const leftThigh = THIGH_SWING * Math.sin(w);
5765
+ const rightThigh = THIGH_SWING * Math.sin(w + Math.PI);
5766
+ const flex = (at) => -KNEE_FLEX * Math.max(0, Math.cos(w - at)) ** 1.5;
5767
+ const leftKnee = flex(7 * Math.PI / 4);
5768
+ const rightKnee = flex(3 * Math.PI / 4);
5769
+ const leftArm = -ARM_SWING * o.swing * Math.sin(w);
5770
+ const rightArm = -ARM_SWING * o.swing * Math.sin(w + Math.PI);
5771
+ const bob = -BOB * o.height * (1 - Math.abs(Math.cos(w)));
5772
+ return {
5773
+ phase,
5774
+ bob,
5775
+ lean: LEAN * Math.min(o.speed / 1.2, 1),
5776
+ leftThigh,
5777
+ rightThigh,
5778
+ leftKnee,
5779
+ rightKnee,
5780
+ leftArm,
5781
+ rightArm
5782
+ };
5783
+ }
5784
+ function placeFigure(path, distance, o) {
5785
+ const raw = path.length > 0 ? distance / path.length : 0;
5786
+ const s = path.closed ? (raw % 1 + 1) % 1 : Math.min(Math.max(raw, 0), 1);
5787
+ const [x, z27] = path.pointAt(s);
5788
+ const [tx, tz] = path.tangentAt(s);
5789
+ const travelled = path.closed ? distance : Math.min(distance, path.length);
5790
+ return {
5791
+ position: [x, 0, z27],
5792
+ yaw: Math.atan2(tx, tz),
5793
+ pose: figureGait(travelled, o),
5794
+ s
5795
+ };
5796
+ }
5797
+
5798
+ // src/stage/Figure.tsx
5799
+ import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
5800
+ function Segment({ length, radius, material }) {
5801
+ const shaft = Math.max(length - radius * 2, 1e-3);
5802
+ return /* @__PURE__ */ jsx12("mesh", { position: [0, -length / 2, 0], material, castShadow: true, children: /* @__PURE__ */ jsx12("capsuleGeometry", { args: [radius, shaft, 4, 10] }) });
5803
+ }
5804
+ function Figure({ path, figure, distance, frozen }) {
5805
+ const reducedMotion = usePrefersReducedMotion();
5806
+ const still = frozen ?? reducedMotion;
5807
+ const options = useMemo11(() => figureSchema.parse(figure ?? {}), [figure]);
5808
+ const walk = useMemo11(() => getWalkPath(walkPathSchema.parse(path ?? {})), [path]);
5809
+ const root = useRef7(null);
5810
+ const hips = useRef7(null);
5811
+ const legL = useRef7(null);
5812
+ const legR = useRef7(null);
5813
+ const kneeL = useRef7(null);
5814
+ const kneeR = useRef7(null);
5815
+ const armL = useRef7(null);
5816
+ const armR = useRef7(null);
5817
+ const material = useMemo11(
5818
+ () => new THREE15.MeshBasicMaterial({ color: options.color, toneMapped: false }),
5819
+ [options.color]
5820
+ );
5821
+ useEffect13(() => () => material.dispose(), [material]);
5822
+ const h = options.height;
5823
+ const p = PROPORTIONS;
5824
+ const torso = (p.shoulder - p.hip) * h;
5825
+ useFrame6((state) => {
5826
+ const walked = distance ?? (still ? 0 : state.clock.elapsedTime * options.speed);
5827
+ const { position, yaw, pose } = placeFigure(walk, walked, options);
5828
+ root.current?.position.set(position[0], position[1], position[2]);
5829
+ if (root.current) root.current.rotation.y = yaw;
5830
+ if (hips.current) {
5831
+ hips.current.position.y = p.hip * h + (still ? 0 : pose.bob);
5832
+ hips.current.rotation.x = pose.lean;
5833
+ }
5834
+ if (legL.current) legL.current.rotation.x = still ? 0 : -pose.leftThigh;
5835
+ if (legR.current) legR.current.rotation.x = still ? 0 : -pose.rightThigh;
5836
+ if (kneeL.current) kneeL.current.rotation.x = still ? 0 : -pose.leftKnee;
5837
+ if (kneeR.current) kneeR.current.rotation.x = still ? 0 : -pose.rightKnee;
5838
+ if (armL.current) armL.current.rotation.x = still ? 0 : -pose.leftArm;
5839
+ if (armR.current) armR.current.rotation.x = still ? 0 : -pose.rightArm;
5840
+ });
5841
+ return /* @__PURE__ */ jsx12("group", { ref: root, children: /* @__PURE__ */ jsxs9("group", { ref: hips, children: [
5842
+ /* @__PURE__ */ jsx12("mesh", { position: [0, torso / 2, 0], material, castShadow: true, children: /* @__PURE__ */ jsx12("capsuleGeometry", { args: [p.torsoWidth * h / 2, torso * 0.72, 4, 12] }) }),
5843
+ /* @__PURE__ */ jsx12("mesh", { position: [0, (p.headCenter - p.hip) * h, 0], material, castShadow: true, children: /* @__PURE__ */ jsx12("sphereGeometry", { args: [p.headRadius * h, 14, 12] }) }),
5844
+ [-1, 1].map((side) => {
5845
+ const leg = side < 0 ? legL : legR;
5846
+ const knee = side < 0 ? kneeL : kneeR;
5847
+ return /* @__PURE__ */ jsxs9("group", { ref: leg, position: [side * p.hipWidth * h / 2, 0, 0], children: [
5848
+ /* @__PURE__ */ jsx12(Segment, { length: p.thigh * h, radius: p.limbRadius * h, material }),
5849
+ /* @__PURE__ */ jsx12("group", { ref: knee, position: [0, -p.thigh * h, 0], children: /* @__PURE__ */ jsx12(Segment, { length: p.shin * h, radius: p.limbRadius * h * 0.9, material }) })
5850
+ ] }, `leg${side}`);
5851
+ }),
5852
+ [-1, 1].map((side) => /* @__PURE__ */ jsx12(
5853
+ "group",
4799
5854
  {
4800
- type: "wave",
4801
- options: { amplitude: 0.05, wavelength: 0.5, speed: 1, angle: 20, pinnedEdge: "none" }
4802
- }
4803
- ],
4804
- sheet: { width: 1, height: 1 },
4805
- t: 1.234
4806
- },
4807
- {
4808
- name: "wave: pinned top at t=2.5",
4809
- stack: [
5855
+ ref: side < 0 ? armL : armR,
5856
+ position: [side * p.torsoWidth * h / 2, torso, 0],
5857
+ children: /* @__PURE__ */ jsx12(
5858
+ Segment,
5859
+ {
5860
+ length: (p.upperArm + p.foreArm) * h,
5861
+ radius: p.limbRadius * h * 0.8,
5862
+ material
5863
+ }
5864
+ )
5865
+ },
5866
+ `arm${side}`
5867
+ ))
5868
+ ] }) });
5869
+ }
5870
+
5871
+ // src/stage/Surround.tsx
5872
+ import * as THREE16 from "three";
5873
+ import { useEffect as useEffect14, useMemo as useMemo12 } from "react";
5874
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
5875
+ function makeSkyTexture(horizon, zenith) {
5876
+ const canvas = document.createElement("canvas");
5877
+ canvas.width = 4;
5878
+ canvas.height = 256;
5879
+ const ctx = canvas.getContext("2d");
5880
+ const grade = ctx.createLinearGradient(0, 0, 0, canvas.height);
5881
+ grade.addColorStop(0, zenith);
5882
+ grade.addColorStop(0.35, zenith);
5883
+ grade.addColorStop(0.92, horizon);
5884
+ grade.addColorStop(1, horizon);
5885
+ ctx.fillStyle = grade;
5886
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
5887
+ const texture = new THREE16.CanvasTexture(canvas);
5888
+ texture.colorSpace = THREE16.SRGBColorSpace;
5889
+ return texture;
5890
+ }
5891
+ function makeGlowTexture(color) {
5892
+ const size = 256;
5893
+ const canvas = document.createElement("canvas");
5894
+ canvas.width = canvas.height = size;
5895
+ const ctx = canvas.getContext("2d");
5896
+ const glow = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
5897
+ glow.addColorStop(0, color);
5898
+ glow.addColorStop(0.55, color);
5899
+ glow.addColorStop(1, "rgba(0, 0, 0, 0)");
5900
+ ctx.fillStyle = glow;
5901
+ ctx.fillRect(0, 0, size, size);
5902
+ const texture = new THREE16.CanvasTexture(canvas);
5903
+ texture.colorSpace = THREE16.SRGBColorSpace;
5904
+ return texture;
5905
+ }
5906
+ function Source({
5907
+ size,
5908
+ position,
5909
+ yaw,
5910
+ color
5911
+ }) {
5912
+ const texture = useMemo12(() => makeGlowTexture(color), [color]);
5913
+ useEffect14(() => () => texture.dispose(), [texture]);
5914
+ return /* @__PURE__ */ jsxs10("mesh", { position, rotation: [0, yaw, 0], children: [
5915
+ /* @__PURE__ */ jsx13("planeGeometry", { args: [size * 2.4, size * 1.8] }),
5916
+ /* @__PURE__ */ jsx13(
5917
+ "meshBasicMaterial",
4810
5918
  {
4811
- type: "wave",
4812
- options: { amplitude: 0.04, wavelength: 0.4, speed: 1.5, angle: 80, pinnedEdge: "top" }
5919
+ map: texture,
5920
+ transparent: true,
5921
+ depthWrite: false,
5922
+ toneMapped: false,
5923
+ fog: false
4813
5924
  }
4814
- ],
4815
- sheet: { width: 1.2, height: 1.5 },
4816
- t: 2.5
4817
- },
4818
- {
4819
- name: "stacked: letter-fold pair (fold \u2218 fold)",
4820
- stack: [
4821
- { type: "fold", options: { angle: 270, offset: 0.2333, foldAngle: 120, radius: 0.05 } },
4822
- { type: "fold", options: { angle: 90, offset: 0.2333, foldAngle: 100, radius: 0.08 } }
4823
- ],
4824
- sheet: { width: 1, height: 1.4 },
4825
- t: 0
4826
- },
4827
- {
4828
- name: "stacked: bend \u2218 roll \u2218 wave",
4829
- stack: [
4830
- { type: "bend", options: { curvature: 0.6, angle: 0 } },
4831
- { type: "roll", options: { angle: 90, boundary: 0.1, radius: 0.15, spiral: 0 } },
5925
+ )
5926
+ ] });
5927
+ }
5928
+ function Surround({ radius, horizon, zenith }) {
5929
+ const texture = useMemo12(() => makeSkyTexture(horizon, zenith), [horizon, zenith]);
5930
+ useEffect14(() => () => texture.dispose(), [texture]);
5931
+ return /* @__PURE__ */ jsxs10("mesh", { children: [
5932
+ /* @__PURE__ */ jsx13("sphereGeometry", { args: [radius, 32, 24] }),
5933
+ /* @__PURE__ */ jsx13("meshBasicMaterial", { map: texture, side: THREE16.BackSide, fog: false })
5934
+ ] });
5935
+ }
5936
+
5937
+ // src/stage/schema.ts
5938
+ import { z as z25 } from "zod";
5939
+ var stageSourceSchema = z25.object({
5940
+ /** The bright void the walk resolves toward. Without it the vanishing point is a hole. */
5941
+ enabled: z25.boolean().default(true),
5942
+ color: z25.string().default("#fff4e2"),
5943
+ /** How far past the end of the walk it stands, world units. */
5944
+ beyond: z25.number().min(0).max(80).default(10),
5945
+ /**
5946
+ * A cyclorama around the whole stage, graded from the source colour at the
5947
+ * horizon to near-dark overhead. The source plane only faces down the walk,
5948
+ * so without this every shot that isn't axial — `wide` especially — looks
5949
+ * out at a black void where the room should be.
5950
+ */
5951
+ surround: z25.boolean().default(true),
5952
+ /** Colour overhead. The horizon takes the source's own colour. */
5953
+ zenith: z25.string().default("#241c17"),
5954
+ /** Size, as a multiple of the PAPER height — it only has to out-fill the frame. */
5955
+ spread: z25.number().min(1).max(60).default(5)
5956
+ });
5957
+ var stageGroundSchema = z25.object({
5958
+ /** The floor. Without something to catch the shadows there is no ground and no scale. */
5959
+ enabled: z25.boolean().default(true),
5960
+ color: z25.string().default("#0e0b09")
5961
+ });
5962
+ var stageSchema = z25.object({
5963
+ path: walkPathSchema.default({}),
5964
+ shot: shotSchema.default({}),
5965
+ figure: figureSchema.default({}),
5966
+ /** Stage mode is built for `nave`; the others are all front-lit. */
5967
+ lighting: z25.enum(lightingNames).default("nave"),
5968
+ showFigure: z25.boolean().default(true),
5969
+ source: stageSourceSchema.default({}),
5970
+ ground: stageGroundSchema.default({})
5971
+ });
5972
+
5973
+ // src/stage/quality.ts
5974
+ var qualityNames = ["auto", "low", "medium", "high"];
5975
+ var qualityTiers = {
5976
+ /** Anything with a GPU. */
5977
+ high: { dpr: 2, shadowMapSize: 2048, segments: 72, surround: true, contactShadow: true },
5978
+ /** The default worth aiming at: an integrated laptop GPU from the last few years. */
5979
+ medium: { dpr: 1.5, shadowMapSize: 1024, segments: 48, surround: true, contactShadow: false },
5980
+ /**
5981
+ * Old integrated graphics, a throttled phone, a software rasterizer. The
5982
+ * scene still READS — banners, figure, backlight, walk — it just stops
5983
+ * paying for the parts nobody would miss at this framerate.
5984
+ */
5985
+ low: { dpr: 1, shadowMapSize: 0, segments: 28, surround: true, contactShadow: false }
5986
+ };
5987
+ var INITIAL_TIER = "medium";
5988
+ var FIRST_WINDOW = 20;
5989
+ var STEADY_WINDOW = 60;
5990
+ var SETTLE_FRAMES = 45;
5991
+ var TIER_ORDER = ["low", "medium", "high"];
5992
+ function qualityFor(name) {
5993
+ return qualityTiers[name === "auto" ? INITIAL_TIER : name];
5994
+ }
5995
+ function tierUp(tier) {
5996
+ return TIER_ORDER[Math.min(TIER_ORDER.indexOf(tier) + 1, TIER_ORDER.length - 1)];
5997
+ }
5998
+ function tierDown(tier) {
5999
+ return TIER_ORDER[Math.max(TIER_ORDER.indexOf(tier) - 1, 0)];
6000
+ }
6001
+
6002
+ // src/stage/PaperStage.tsx
6003
+ import { Fragment as Fragment2, jsx as jsx14, jsxs as jsxs11 } from "react/jsx-runtime";
6004
+ var BANNER = {
6005
+ sheet: { width: 1.5, height: 8.5, segments: "auto" },
6006
+ stock: "vellum",
6007
+ surface: { grain: 0.22 },
6008
+ deformers: [{ type: "drape", options: { amplitude: 0.16, folds: 3, falloff: 1.7, gather: 0.28 } }]
6009
+ };
6010
+ function splitAcrossBanners(text, banners) {
6011
+ const words = text.split(/\s+/).filter(Boolean);
6012
+ if (words.length === 0 || banners <= 0) return [];
6013
+ const per = Math.ceil(words.length / banners);
6014
+ const out = [];
6015
+ for (let i = 0; i < words.length; i += per) out.push(words.slice(i, i + per).join("\n"));
6016
+ return out;
6017
+ }
6018
+ function bannerTextSize(lines) {
6019
+ return Math.round(Math.min(150, Math.max(26, 720 / Math.max(lines, 1))));
6020
+ }
6021
+ function ShotRig({
6022
+ stage,
6023
+ paperHeight,
6024
+ progress,
6025
+ still
6026
+ }) {
6027
+ const camera = useThree5((s) => s.camera);
6028
+ const path = useMemo13(() => getWalkPath(stage.path), [stage.path]);
6029
+ const scale = useMemo13(
6030
+ () => ({ figure: stage.figure.height, paper: paperHeight }),
6031
+ [stage.figure.height, paperHeight]
6032
+ );
6033
+ useFrame7((state) => {
6034
+ const walked = stageWalked(path.length, stage, progress, still ? 0 : state.clock.elapsedTime);
6035
+ const { position, target } = stageCamera(path, walked, scale, stage.shot);
6036
+ camera.position.set(position[0], position[1], position[2]);
6037
+ camera.lookAt(target[0], target[1], target[2]);
6038
+ });
6039
+ return null;
6040
+ }
6041
+ function stageWalked(length, stage, progress, elapsed) {
6042
+ if (progress !== void 0) return progress * length;
6043
+ return elapsed * stage.figure.speed;
6044
+ }
6045
+ var FLOOR_FPS = 26;
6046
+ var CEILING_FPS = 55;
6047
+ function QualityWatch({ tier, onChange }) {
6048
+ const samples = useRef8([]);
6049
+ const settle = useRef8(SETTLE_FRAMES);
6050
+ const window2 = useRef8(FIRST_WINDOW);
6051
+ const settled = useCallback((next) => {
6052
+ samples.current = [];
6053
+ settle.current = SETTLE_FRAMES;
6054
+ window2.current = STEADY_WINDOW;
6055
+ return next;
6056
+ }, []);
6057
+ useFrame7((_, delta) => {
6058
+ if (settle.current > 0) {
6059
+ settle.current -= 1;
6060
+ return;
6061
+ }
6062
+ if (delta > 0.5) return;
6063
+ samples.current.push(delta);
6064
+ if (samples.current.length < window2.current) return;
6065
+ const sorted = [...samples.current].sort((a, b) => a - b);
6066
+ const median = sorted[Math.floor(sorted.length / 2)];
6067
+ const fps = 1 / median;
6068
+ samples.current = [];
6069
+ window2.current = STEADY_WINDOW;
6070
+ if (fps < FLOOR_FPS) {
6071
+ const next = tierDown(tier);
6072
+ if (next !== tier) onChange(settled(next));
6073
+ } else if (fps > CEILING_FPS) {
6074
+ const next = tierUp(tier);
6075
+ if (next !== tier) onChange(settled(next));
6076
+ }
6077
+ });
6078
+ return null;
6079
+ }
6080
+ function PaperStageScene({
6081
+ stage: stageInput,
6082
+ quality = "auto",
6083
+ onQualityChange,
6084
+ layout = "colonnade",
6085
+ layoutOptions,
6086
+ papers,
6087
+ images,
6088
+ text,
6089
+ preset,
6090
+ count = 22,
6091
+ progress,
6092
+ reducedMotion
6093
+ }) {
6094
+ const still = usePrefersReducedMotion(reducedMotion);
6095
+ const [tier, setTier] = useState7(quality === "auto" ? INITIAL_TIER : quality);
6096
+ useEffect15(() => {
6097
+ if (quality !== "auto") setTier(quality);
6098
+ }, [quality]);
6099
+ useEffect15(() => {
6100
+ onQualityChange?.(tier);
6101
+ }, [tier, onQualityChange]);
6102
+ const settings = quality === "auto" ? qualityTiers[tier] : qualityFor(quality);
6103
+ const stage = useMemo13(() => stageSchema.parse(stageInput ?? {}), [stageInput]);
6104
+ const path = useMemo13(() => getWalkPath(stage.path), [stage.path]);
6105
+ const paperHeight = useMemo13(() => resolveConfig({ preset: preset ?? BANNER }).sheet.height, [preset]);
6106
+ const paper = useMemo13(() => {
6107
+ const base = preset ?? BANNER;
6108
+ if (typeof base !== "object") return preset ?? BANNER;
6109
+ const sheet2 = base.sheet ?? {};
6110
+ return { ...base, sheet: { ...sheet2, segments: settings.segments } };
6111
+ }, [preset, settings.segments]);
6112
+ const resolvedLayoutOptions = useMemo13(() => {
6113
+ const schema = getLayout(layout).optionsSchema;
6114
+ const takesPath = schema instanceof z26.ZodObject && "path" in schema.shape;
6115
+ return takesPath ? { ...layoutOptions, path: stage.path } : layoutOptions;
6116
+ }, [layout, layoutOptions, stage.path]);
6117
+ const slots = useMemo13(() => {
6118
+ if (papers) return papers;
6119
+ if (images) return void 0;
6120
+ if (text !== void 0) {
6121
+ const columns = Array.isArray(text) ? text : splitAcrossBanners(text, count);
6122
+ const longest = columns.reduce((n, c) => Math.max(n, c.split("\n").length), 1);
6123
+ const size = bannerTextSize(longest);
6124
+ return columns.map((column) => ({
6125
+ content: {
6126
+ type: "text",
6127
+ text: column,
6128
+ size,
6129
+ align: "center",
6130
+ color: "#241f1a",
6131
+ lineHeight: 1.25,
6132
+ font: 'Georgia, "Times New Roman", serif',
6133
+ weight: 400,
6134
+ padding: 0.06
6135
+ }
6136
+ }));
6137
+ }
6138
+ return Array.from({ length: count }, () => ({}));
6139
+ }, [papers, images, text, count]);
6140
+ const figureDistance = progress !== void 0 ? progress * path.length : void 0;
6141
+ const surroundRadius = useMemo13(
6142
+ () => Math.max(path.length * 1.6, paperHeight * 9),
6143
+ [path.length, paperHeight]
6144
+ );
6145
+ const source = useMemo13(() => {
6146
+ const [x, z27] = walkPoint(path, path.length + stage.source.beyond);
6147
+ const [tx, tz] = path.tangentAt(1);
6148
+ const size = paperHeight * stage.source.spread;
6149
+ return { position: [x, size * 0.35, z27], yaw: Math.atan2(-tx, -tz), size };
6150
+ }, [path, stage.source.beyond, stage.source.spread, paperHeight]);
6151
+ return /* @__PURE__ */ jsxs11(Fragment2, { children: [
6152
+ /* @__PURE__ */ jsx14(ShotRig, { stage, paperHeight, progress, still }),
6153
+ /* @__PURE__ */ jsx14(
6154
+ PaperLighting,
4832
6155
  {
4833
- type: "wave",
4834
- options: { amplitude: 0.02, wavelength: 0.6, speed: 0.7, angle: 45, pinnedEdge: "none" }
6156
+ preset: stage.lighting,
6157
+ floor: 0,
6158
+ scale: 60,
6159
+ reducedMotion,
6160
+ shadowMapSize: settings.shadowMapSize,
6161
+ contactShadow: settings.contactShadow
4835
6162
  }
4836
- ],
4837
- sheet: { width: 1, height: 1.4 },
4838
- t: 0.8
4839
- }
4840
- ];
4841
- function buildParityFragment(stack, sheet2) {
4842
- const composed = buildDisplacementGLSL(stack, sheet2);
4843
- return (
4844
- /* glsl */
4845
- `#version 300 es
4846
- precision highp float;
4847
- uniform float uPlTime;
4848
- ${composed.functionsSrc}
4849
- ${composed.displaceSrc}
4850
- out vec4 outColor;
4851
- void main() {
4852
- vec2 uv = (gl_FragCoord.xy - 0.5) / float(${GRID - 1});
4853
- vec3 p = vec3((uv - 0.5) * uSheet, 0.0);
4854
- outColor = vec4(plDisplace(p, uv, uPlTime), 1.0);
6163
+ ),
6164
+ quality === "auto" && /* @__PURE__ */ jsx14(QualityWatch, { tier, onChange: setTier }),
6165
+ stage.source.surround && settings.surround && /* @__PURE__ */ jsx14(Surround, { radius: surroundRadius, horizon: stage.source.color, zenith: stage.source.zenith }),
6166
+ stage.source.enabled && /* @__PURE__ */ jsx14(Source, { size: source.size, position: source.position, yaw: source.yaw, color: stage.source.color }),
6167
+ stage.ground.enabled && /* @__PURE__ */ jsxs11("mesh", { rotation: [-Math.PI / 2, 0, 0], receiveShadow: true, children: [
6168
+ /* @__PURE__ */ jsx14("planeGeometry", { args: [surroundRadius * 1.3, surroundRadius * 1.3] }),
6169
+ /* @__PURE__ */ jsx14("meshStandardMaterial", { color: stage.ground.color, roughness: 1 })
6170
+ ] }),
6171
+ /* @__PURE__ */ jsx14(
6172
+ PaperFieldMesh,
6173
+ {
6174
+ preset: paper,
6175
+ papers: slots,
6176
+ images,
6177
+ layout,
6178
+ layoutOptions: resolvedLayoutOptions,
6179
+ motion: { driver: "none" },
6180
+ entrance: { type: "none" },
6181
+ reducedMotion
6182
+ }
6183
+ ),
6184
+ stage.showFigure && /* @__PURE__ */ jsx14(Figure, { path: stage.path, figure: stage.figure, distance: figureDistance, frozen: reducedMotion })
6185
+ ] });
4855
6186
  }
4856
- `
4857
- );
6187
+ function PaperStage({ children, className, style, ...sceneProps }) {
6188
+ const dpr = qualityFor(sceneProps.quality ?? "auto").dpr;
6189
+ return /* @__PURE__ */ jsx14("div", { className, style: { width: "100%", height: "100%", ...style }, children: /* @__PURE__ */ jsxs11(
6190
+ Canvas3,
6191
+ {
6192
+ shadows: true,
6193
+ dpr: [1, dpr],
6194
+ camera: { fov: 38, near: 0.05, far: 400 },
6195
+ onCreated: ({ gl, scene }) => {
6196
+ gl.toneMapping = THREE17.ACESFilmicToneMapping;
6197
+ scene.background = new THREE17.Color("#0c0a0b");
6198
+ },
6199
+ children: [
6200
+ /* @__PURE__ */ jsx14(PaperStageScene, { ...sceneProps }),
6201
+ children
6202
+ ]
6203
+ }
6204
+ ) });
4858
6205
  }
4859
- var VERT = `#version 300 es
4860
- in vec2 aPos;
4861
- void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
4862
- `;
4863
- function compile(gl, type, src) {
4864
- const shader = gl.createShader(type);
4865
- gl.shaderSource(shader, src);
4866
- gl.compileShader(shader);
4867
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
4868
- throw new Error(`[paperlab parity] shader compile failed: ${gl.getShaderInfoLog(shader)}`);
6206
+
6207
+ // src/stage/walks.ts
6208
+ var walkNames = ["straight", "bend", "ess", "ring", "spiral"];
6209
+ var walks = {
6210
+ /** Straight down the nave, away from the camera. The reference shot. */
6211
+ straight: {
6212
+ points: [
6213
+ [0, 16],
6214
+ [0, -20]
6215
+ ],
6216
+ closed: false
6217
+ },
6218
+ /** One long curve, so the far end of the colonnade stays hidden until you reach it. */
6219
+ bend: {
6220
+ points: [
6221
+ [-2, 16],
6222
+ [0, 6],
6223
+ [5, -3],
6224
+ [12, -10]
6225
+ ],
6226
+ closed: false
6227
+ },
6228
+ /** Two opposed curves — the walk turns twice and the banners turn with it. */
6229
+ ess: {
6230
+ points: [
6231
+ [6, 17],
6232
+ [-3, 7],
6233
+ [3, -5],
6234
+ [-6, -17]
6235
+ ],
6236
+ closed: false
6237
+ },
6238
+ /** A closed loop: the only walk `phase` can slide, and the only endless one. */
6239
+ ring: {
6240
+ points: [
6241
+ [11, 0],
6242
+ [0, 11],
6243
+ [-11, 0],
6244
+ [0, -11]
6245
+ ],
6246
+ closed: true
6247
+ },
6248
+ /** Inward and tightening — the space closes as the figure goes deeper. */
6249
+ spiral: {
6250
+ points: [
6251
+ [14, 2],
6252
+ [2, 13],
6253
+ [-11, 1],
6254
+ [-1, -9],
6255
+ [7, -2],
6256
+ [1, 4]
6257
+ ],
6258
+ closed: false
4869
6259
  }
4870
- return shader;
6260
+ };
6261
+ function getWalk(name) {
6262
+ return walks[name];
4871
6263
  }
4872
- function runCaseOnGPU(gl, c) {
4873
- const composed = buildDisplacementGLSL(c.stack, c.sheet);
4874
- const program = gl.createProgram();
4875
- gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
4876
- gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, buildParityFragment(c.stack, c.sheet)));
4877
- gl.linkProgram(program);
4878
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
4879
- throw new Error(`[paperlab parity] link failed: ${gl.getProgramInfoLog(program)}`);
6264
+
6265
+ // src/stage/presets.ts
6266
+ var banner = (width, height, drape2 = {}) => ({
6267
+ sheet: { width, height, segments: "auto" },
6268
+ stock: "vellum",
6269
+ surface: { grain: 0.22 },
6270
+ deformers: [
6271
+ { type: "drape", options: { amplitude: 0.16, folds: 3, falloff: 1.7, gather: 0.28, ...drape2 } }
6272
+ ]
6273
+ });
6274
+ var stagePresets = {
6275
+ nave: {
6276
+ id: "nave",
6277
+ label: "Nave",
6278
+ description: "A straight aisle of hanging banners, lit from the far end.",
6279
+ stage: {
6280
+ path: walks.straight,
6281
+ shot: { shot: "follow", distance: 5, lookAhead: 12, offset: 1.5 },
6282
+ lighting: "nave"
6283
+ },
6284
+ layout: "colonnade",
6285
+ layoutOptions: { aisle: 2.6, twist: 22, drape: 0.6, rise: 0.3 },
6286
+ paper: banner(1.5, 8.5),
6287
+ count: 18,
6288
+ text: "the paper remembers every hand that folded it and every room it was carried through"
6289
+ },
6290
+ procession: {
6291
+ id: "procession",
6292
+ label: "Procession",
6293
+ description: "The walk turns twice, so the far end stays hidden until you reach it.",
6294
+ stage: {
6295
+ path: walks.ess,
6296
+ shot: { shot: "low", distance: 4, lookAhead: 9, offset: 1.1 },
6297
+ lighting: "nave",
6298
+ figure: { speed: 1.05 }
6299
+ },
6300
+ layout: "colonnade",
6301
+ layoutOptions: { aisle: 2.2, twist: 34, breathe: 0.45, drape: 0.7 },
6302
+ paper: banner(1.3, 9.5, { folds: 4, amplitude: 0.2 }),
6303
+ count: 28,
6304
+ text: "every letter you did not send is still folded somewhere in the dark waiting to be read aloud"
6305
+ },
6306
+ cloister: {
6307
+ id: "cloister",
6308
+ label: "Cloister",
6309
+ description: "A closed loop. The figure walks it forever and the banners drift past.",
6310
+ stage: {
6311
+ path: walks.ring,
6312
+ shot: { shot: "follow", distance: 4.5, lookAhead: 8, offset: 1.2 },
6313
+ lighting: "nave"
6314
+ },
6315
+ layout: "colonnade",
6316
+ layoutOptions: { aisle: 2.4, twist: 18, rise: 0.22 },
6317
+ paper: banner(1.6, 7.5),
6318
+ count: 24,
6319
+ text: "around and around and the same words come back changed"
6320
+ },
6321
+ threshold: {
6322
+ id: "threshold",
6323
+ label: "Threshold",
6324
+ description: "A few enormous sheets, wide enough apart to walk between and read.",
6325
+ stage: {
6326
+ // Its own short walk. A colonnade spreads over the WHOLE path whatever
6327
+ // it is populating, so ten banners on the default 36-unit walk stand
6328
+ // seven apart and the shot looks down an empty corridor.
6329
+ path: {
6330
+ points: [
6331
+ [0, 9],
6332
+ [0, -11]
6333
+ ],
6334
+ closed: false
6335
+ },
6336
+ // The aisle has to stay inside the frustum at the distance the shot
6337
+ // stands: paper half a frame-width off the walk line is paper you
6338
+ // never see. `lead` fails here for the same reason and worse.
6339
+ shot: { shot: "follow", distance: 6.5, lookAhead: 9, offset: 0.9 },
6340
+ lighting: "nave",
6341
+ figure: { speed: 0.85 },
6342
+ source: { spread: 4 }
6343
+ },
6344
+ layout: "colonnade",
6345
+ layoutOptions: { aisle: 2.4, twist: 14, breathe: 0.18, margin: 0.12, rise: 0.2 },
6346
+ paper: banner(2.6, 10, { folds: 2, amplitude: 0.24, falloff: 2 }),
6347
+ count: 10,
6348
+ text: "stand closer and read what it cost to write this down"
6349
+ },
6350
+ archive: {
6351
+ id: "archive",
6352
+ label: "Archive",
6353
+ description: "Narrow strips packed tight \u2014 a corridor of records you edge through.",
6354
+ stage: {
6355
+ path: walks.bend,
6356
+ // Far enough back that the figure reads as small; a `low` camera
6357
+ // three units behind a body is all body.
6358
+ shot: { shot: "low", distance: 8, lookAhead: 13, offset: 0.5 },
6359
+ lighting: "nave",
6360
+ ground: { color: "#0b0908" }
6361
+ },
6362
+ layout: "colonnade",
6363
+ layoutOptions: { aisle: 1.7, twist: 44, breathe: 0.5, drape: 0.75, rise: 0.4 },
6364
+ paper: banner(0.85, 11, { folds: 2, amplitude: 0.12 }),
6365
+ count: 44,
6366
+ text: "catalogued indexed cross referenced filed and never once opened by anyone at all"
4880
6367
  }
4881
- gl.useProgram(program);
4882
- for (const [name, value] of Object.entries(composed.uniforms)) {
4883
- const loc = gl.getUniformLocation(program, name);
4884
- if (!loc) continue;
4885
- if (typeof value === "number") gl.uniform1f(loc, value);
4886
- else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
4887
- else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
4888
- else gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
6368
+ };
6369
+ function getStagePreset(id) {
6370
+ const preset = stagePresets[id];
6371
+ if (!preset) {
6372
+ throw new Error(
6373
+ `[paperlab] Unknown stage preset "${id}". Available: ${Object.keys(stagePresets).join(", ")}`
6374
+ );
4889
6375
  }
4890
- const timeLoc = gl.getUniformLocation(program, "uPlTime");
4891
- if (timeLoc) gl.uniform1f(timeLoc, c.t);
4892
- const quad = gl.createBuffer();
4893
- gl.bindBuffer(gl.ARRAY_BUFFER, quad);
4894
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
4895
- const posLoc = gl.getAttribLocation(program, "aPos");
4896
- gl.enableVertexAttribArray(posLoc);
4897
- gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
4898
- const tex = gl.createTexture();
4899
- gl.bindTexture(gl.TEXTURE_2D, tex);
4900
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, GRID, GRID, 0, gl.RGBA, gl.FLOAT, null);
4901
- const fbo = gl.createFramebuffer();
4902
- gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
4903
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
4904
- gl.viewport(0, 0, GRID, GRID);
4905
- gl.drawArrays(gl.TRIANGLES, 0, 3);
4906
- const out = new Float32Array(GRID * GRID * 4);
4907
- gl.readPixels(0, 0, GRID, GRID, gl.RGBA, gl.FLOAT, out);
4908
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
4909
- return out;
6376
+ return preset;
4910
6377
  }
4911
- function runParityHarness(canvas) {
4912
- const cnv = canvas ?? document.createElement("canvas");
4913
- const gl = cnv.getContext("webgl2");
4914
- if (!gl) throw new Error("[paperlab parity] WebGL2 unavailable");
4915
- if (!gl.getExtension("EXT_color_buffer_float")) {
4916
- throw new Error("[paperlab parity] EXT_color_buffer_float unavailable");
4917
- }
4918
- const point = new THREE13.Vector3();
4919
- return parityCases.map((c) => {
4920
- const gpu = runCaseOnGPU(gl, c);
4921
- let maxError = 0;
4922
- for (let row = 0; row < GRID; row++) {
4923
- for (let col = 0; col < GRID; col++) {
4924
- const u = col / (GRID - 1);
4925
- const v = row / (GRID - 1);
4926
- point.set((u - 0.5) * c.sheet.width, (v - 0.5) * c.sheet.height, 0);
4927
- displacePoint(point, u, v, c.stack, { t: c.t, sheet: c.sheet });
4928
- const i4 = (row * GRID + col) * 4;
4929
- maxError = Math.max(
4930
- maxError,
4931
- Math.abs(gpu[i4] - point.x),
4932
- Math.abs(gpu[i4 + 1] - point.y),
4933
- Math.abs(gpu[i4 + 2] - point.z)
4934
- );
4935
- }
4936
- }
4937
- return { name: c.name, maxError, pass: maxError < PARITY_EPSILON };
4938
- });
6378
+ function listStagePresets() {
6379
+ return Object.keys(stagePresets);
4939
6380
  }
4940
6381
 
4941
6382
  // src/config/diff.ts
@@ -5014,7 +6455,8 @@ var BEHAVIOR_PHRASES = {
5014
6455
  fly: () => "arched and fluttering like it is airborne",
5015
6456
  fall: () => "rippling with one corner lifted, like a dropped sheet",
5016
6457
  carry: () => "drooping from a pinched corner, fluttering as if being carried",
5017
- flight: (o) => o.path === "loop" ? "tumbling through a seamless airborne loop" : "tumbling across the scene on the wind"
6458
+ flight: (o) => o.path === "loop" ? "tumbling through a seamless airborne loop" : "tumbling across the scene on the wind",
6459
+ crumple: (o) => o.progress < 0.3 ? "lightly handled \u2014 a few soft creases across it" : o.progress < 0.7 ? "crushed into irregular creased facets, as if screwed up and flattened out again" : "balled up in a fist"
5018
6460
  };
5019
6461
  function describeConfig(config) {
5020
6462
  const stock = getStock(config.stock);
@@ -5079,6 +6521,498 @@ Constraints: don't modify the preset values; three >= 0.160 and React 19 are
5079
6521
  required; the component needs no props.`;
5080
6522
  }
5081
6523
 
6524
+ // src/stage/export.ts
6525
+ var SCROLL_HEIGHTS = 4;
6526
+ function walkNameFor(path) {
6527
+ const key = JSON.stringify({ points: path.points, closed: path.closed });
6528
+ return walkNames.find(
6529
+ (name) => JSON.stringify({ points: walks[name].points, closed: walks[name].closed }) === key
6530
+ );
6531
+ }
6532
+ function stripDefaults(value, defaults) {
6533
+ if (Array.isArray(value) || Array.isArray(defaults)) {
6534
+ return JSON.stringify(value) === JSON.stringify(defaults) ? void 0 : value;
6535
+ }
6536
+ if (value && defaults && typeof value === "object" && typeof defaults === "object") {
6537
+ const out = {};
6538
+ for (const [key, child] of Object.entries(value)) {
6539
+ const kept = stripDefaults(child, defaults[key]);
6540
+ if (kept !== void 0) out[key] = kept;
6541
+ }
6542
+ return Object.keys(out).length > 0 ? out : void 0;
6543
+ }
6544
+ return value === defaults ? void 0 : value;
6545
+ }
6546
+ function diffStage(stage) {
6547
+ const resolved = stageSchema.parse(stage);
6548
+ const defaults = stageSchema.parse({});
6549
+ const diff = stripDefaults(resolved, defaults) ?? {};
6550
+ if (diff.path !== void 0) diff.path = resolved.path;
6551
+ return diff;
6552
+ }
6553
+ function stringifyStage(value, indent = 0) {
6554
+ const pad = " ".repeat(indent);
6555
+ const inner = " ".repeat(indent + 1);
6556
+ if (Array.isArray(value)) {
6557
+ if (value.length === 0) return "[]";
6558
+ if (value.every((v) => typeof v === "number")) return `[${value.join(", ")}]`;
6559
+ const items = value.map((v) => `${inner}${stringifyStage(v, indent + 1)}`);
6560
+ return `[
6561
+ ${items.join(",\n")}
6562
+ ${pad}]`;
6563
+ }
6564
+ if (value && typeof value === "object") {
6565
+ const entries = Object.entries(value);
6566
+ if (entries.length === 0) return "{}";
6567
+ const items = entries.map(([k, v]) => `${inner}${JSON.stringify(k)}: ${stringifyStage(v, indent + 1)}`);
6568
+ return `{
6569
+ ${items.join(",\n")}
6570
+ ${pad}}`;
6571
+ }
6572
+ return JSON.stringify(value);
6573
+ }
6574
+ var SHOT_PHRASES = {
6575
+ follow: "from behind and a little above them, looking up the walk",
6576
+ lead: "from in front, walking backward as they come on",
6577
+ low: "from down at floor level, looking up the banners",
6578
+ wide: "from off to one side, level with them"
6579
+ };
6580
+ function describeStage(input) {
6581
+ const stage = stageSchema.parse(input.stage);
6582
+ const count = input.count ?? 22;
6583
+ const walk = walkNameFor(stage.path);
6584
+ const parts = [];
6585
+ const shape = walk === "straight" || walk === void 0 ? "a straight walk" : walk === "ring" ? "a closed loop of a walk" : `an "${walk}" walk that curves as it goes`;
6586
+ parts.push(`${count} tall paper banners standing along ${shape}`);
6587
+ if (input.text?.trim()) {
6588
+ parts.push("each printed with a column of your text running down it");
6589
+ }
6590
+ if (stage.showFigure) {
6591
+ parts.push(`a small dark figure walking between them, seen ${SHOT_PHRASES[stage.shot.shot]}`);
6592
+ }
6593
+ parts.push(
6594
+ stage.lighting === "nave" ? "the whole space dim and lit from behind, so the paper glows and the far end of the walk is a bright void" : `lit with the "${stage.lighting}" preset`
6595
+ );
6596
+ if (input.scroll) parts.push("and scrolling the page walks the figure deeper into it");
6597
+ return parts.join(", ");
6598
+ }
6599
+ function propLines(input, indent) {
6600
+ const lines = [];
6601
+ if (input.paper) lines.push(`${indent}preset={banner}`);
6602
+ if (input.text?.trim()) lines.push(`${indent}text={text}`);
6603
+ if (input.count !== void 0) lines.push(`${indent}count={${input.count}}`);
6604
+ if (input.layout !== "colonnade") lines.push(`${indent}layout="${input.layout}"`);
6605
+ const layoutOptions = input.layoutOptions ?? {};
6606
+ const layoutDefaults = getLayout(input.layout).defaults;
6607
+ const changed = {};
6608
+ for (const [key, value] of Object.entries(layoutOptions)) {
6609
+ if (JSON.stringify(value) !== JSON.stringify(layoutDefaults[key])) changed[key] = value;
6610
+ }
6611
+ if (Object.keys(changed).length > 0) {
6612
+ lines.push(`${indent}layoutOptions={${stringifyStage(changed).replace(/\n\s*/g, " ")}}`);
6613
+ }
6614
+ lines.push(`${indent}stage={stage}`);
6615
+ return lines.join("\n");
6616
+ }
6617
+ function buildStageComponentSource(input) {
6618
+ const name = input.componentName ?? "PaperNave";
6619
+ const stage = diffStage(input.stage);
6620
+ const stageConst = `const stage = ${stringifyStage(stage)} satisfies StageConfigInput`;
6621
+ const bannerConst = input.paper ? `
6622
+
6623
+ const banner = ${stringifyStage(diffConfig(paperConfigSchema.parse(input.paper)))} satisfies PaperConfigInput` : "";
6624
+ const textConst = input.text?.trim() ? `
6625
+
6626
+ const text = ${JSON.stringify(input.text)}` : "";
6627
+ if (!input.scroll) {
6628
+ return `import { PaperStage, type StageConfigInput${input.paper ? ", type PaperConfigInput" : ""} } from 'paperlab'
6629
+
6630
+ ${stageConst}${bannerConst}${textConst}
6631
+
6632
+ export function ${name}() {
6633
+ return (
6634
+ <PaperStage
6635
+ ${propLines(input, " ")}
6636
+ />
6637
+ )
6638
+ }`;
6639
+ }
6640
+ return `import { useEffect, useRef, useState } from 'react'
6641
+ import { PaperStage, type StageConfigInput${input.paper ? ", type PaperConfigInput" : ""} } from 'paperlab'
6642
+
6643
+ ${stageConst}${bannerConst}${textConst}
6644
+
6645
+ export function ${name}() {
6646
+ const ref = useRef<HTMLDivElement>(null)
6647
+ const [progress, setProgress] = useState(0)
6648
+
6649
+ // Scroll the section, walk the figure. The stage is pinned for the height
6650
+ // of the section, so the page scrolling past it IS the walk.
6651
+ useEffect(() => {
6652
+ const el = ref.current
6653
+ if (!el) return
6654
+ const onScroll = () => {
6655
+ const { top, height } = el.getBoundingClientRect()
6656
+ const travel = Math.max(height - window.innerHeight, 1)
6657
+ setProgress(Math.min(Math.max(-top / travel, 0), 1))
6658
+ }
6659
+ onScroll()
6660
+ window.addEventListener('scroll', onScroll, { passive: true })
6661
+ window.addEventListener('resize', onScroll)
6662
+ return () => {
6663
+ window.removeEventListener('scroll', onScroll)
6664
+ window.removeEventListener('resize', onScroll)
6665
+ }
6666
+ }, [])
6667
+
6668
+ return (
6669
+ <div ref={ref} style={{ height: '${SCROLL_HEIGHTS * 100}vh' }}>
6670
+ <div style={{ position: 'sticky', top: 0, height: '100vh' }}>
6671
+ <PaperStage
6672
+ ${propLines(input, " ")}
6673
+ progress={progress}
6674
+ />
6675
+ </div>
6676
+ </div>
6677
+ )
6678
+ }`;
6679
+ }
6680
+ function buildStageAgentPayload(input) {
6681
+ const name = input.componentName ?? "PaperNave";
6682
+ const sizing = input.scroll ? `4. Sizing: the component brings its own height \u2014 it reserves ${SCROLL_HEIGHTS} viewport
6683
+ heights of scroll and pins the canvas inside that. Drop it into the page
6684
+ flow as a section; do NOT wrap it in a fixed-height container.` : `4. Sizing: the component fills its parent container. Place it where I ask;
6685
+ give the parent an explicit height.`;
6686
+ return `Integrate a Paperlab stage \u2014 paper as architecture, with a figure walking through it \u2014 into this project. (paperlab agent-payload v${AGENT_PAYLOAD_VERSION})
6687
+
6688
+ 1. Install the dependencies:
6689
+
6690
+ npm i paperlab three @react-three/fiber gsap
6691
+
6692
+ 2. Create the component below as \`components/${name}.tsx\` (or the project's
6693
+ component convention). It is self-contained \u2014 it owns its own <Canvas>,
6694
+ its own camera and its own lighting:
6695
+
6696
+ \`\`\`tsx
6697
+ ${buildStageComponentSource(input)}
6698
+ \`\`\`
6699
+
6700
+ 3. Placement: this is a full-bleed scene, not an inline element. Give it the
6701
+ full width of the viewport.
6702
+
6703
+ ${sizing}
6704
+
6705
+ 5. Verify: run the dev server. You should see ${describeStage(input)}.
6706
+ If the canvas is blank, the container almost certainly has no height \u2014 give
6707
+ it one (this is the classic React Three Fiber integration bug, not a
6708
+ paperlab bug).
6709
+
6710
+ Constraints: don't modify the stage values; the camera is driven by the
6711
+ stage's own shot, so don't add OrbitControls; three >= 0.160 and React 19 are
6712
+ required; the component needs no props.`;
6713
+ }
6714
+
6715
+ // src/field/parity.ts
6716
+ import * as THREE18 from "three";
6717
+ var GRID = 48;
6718
+ var PARITY_EPSILON = 5e-4;
6719
+ var parityCases = [
6720
+ {
6721
+ name: "roll: defaults",
6722
+ stack: [{ type: "roll", options: { angle: 90, boundary: 0, radius: 0.12, spiral: 0.015 } }],
6723
+ sheet: { width: 1, height: 1.4 },
6724
+ t: 0
6725
+ },
6726
+ {
6727
+ name: "roll: tight receipt roll, rolling down",
6728
+ stack: [{ type: "roll", options: { angle: 270, boundary: -0.4, radius: 0.07, spiral: 0.02 } }],
6729
+ sheet: { width: 1, height: 2.6 },
6730
+ t: 0
6731
+ },
6732
+ {
6733
+ name: "curl: bottom-right peel",
6734
+ stack: [{ type: "curl", options: { corner: "bottom-right", amount: 0.45, radius: 0.2, skew: 0 } }],
6735
+ sheet: { width: 1.5, height: 1 },
6736
+ t: 0
6737
+ },
6738
+ {
6739
+ name: "curl: skewed top-left",
6740
+ stack: [{ type: "curl", options: { corner: "top-left", amount: 0.7, radius: 0.3, skew: 15 } }],
6741
+ sheet: { width: 1, height: 1 },
6742
+ t: 0
6743
+ },
6744
+ {
6745
+ name: "bend: positive arc at an angle",
6746
+ stack: [{ type: "bend", options: { curvature: 1.2, angle: 33 } }],
6747
+ sheet: { width: 1, height: 1.4 },
6748
+ t: 0
6749
+ },
6750
+ {
6751
+ /**
6752
+ * The gentle end, which nothing used to cover: the gate only ever tried
6753
+ * |curvature| ≥ 0.6, and `photo-print` — the field starter preset — bends
6754
+ * at 0.35. That band is where the arc's float32 evaluation loses its
6755
+ * cancellation, and where the two paths were 6e-4 apart until `bend` was
6756
+ * rewritten in cancellation-free form. Keep a case down here.
6757
+ */
6758
+ name: "bend: barely-there arc (the band photo-print lives in)",
6759
+ stack: [{ type: "bend", options: { curvature: 0.35, angle: 0 } }],
6760
+ sheet: { width: 1.2, height: 0.9 },
6761
+ t: 0
6762
+ },
6763
+ {
6764
+ name: "bend: the gentlest arc the schema allows",
6765
+ stack: [{ type: "bend", options: { curvature: 0.02, angle: 61 } }],
6766
+ sheet: { width: 2, height: 2.6 },
6767
+ t: 0
6768
+ },
6769
+ {
6770
+ name: "bend: negative arc",
6771
+ stack: [{ type: "bend", options: { curvature: -0.8, angle: 0 } }],
6772
+ sheet: { width: 1, height: 1.4 },
6773
+ t: 0
6774
+ },
6775
+ {
6776
+ name: "fold: 90\xB0 hinge",
6777
+ stack: [{ type: "fold", options: { angle: 90, offset: 0, foldAngle: 90, radius: 0.06 } }],
6778
+ sheet: { width: 1, height: 1.4 },
6779
+ t: 0
6780
+ },
6781
+ {
6782
+ name: "fold: deep fold, travelling down",
6783
+ stack: [{ type: "fold", options: { angle: 270, offset: 0.2, foldAngle: 165, radius: 0.04 } }],
6784
+ sheet: { width: 1, height: 1.4 },
6785
+ t: 0
6786
+ },
6787
+ {
6788
+ name: "wave: free ripple at t=1.234",
6789
+ stack: [
6790
+ {
6791
+ type: "wave",
6792
+ options: { amplitude: 0.05, wavelength: 0.5, speed: 1, angle: 20, pinnedEdge: "none" }
6793
+ }
6794
+ ],
6795
+ sheet: { width: 1, height: 1 },
6796
+ t: 1.234
6797
+ },
6798
+ {
6799
+ name: "wave: pinned top at t=2.5",
6800
+ stack: [
6801
+ {
6802
+ type: "wave",
6803
+ options: { amplitude: 0.04, wavelength: 0.4, speed: 1.5, angle: 80, pinnedEdge: "top" }
6804
+ }
6805
+ ],
6806
+ sheet: { width: 1.2, height: 1.5 },
6807
+ t: 2.5
6808
+ },
6809
+ {
6810
+ name: "drape: banner hung from the top",
6811
+ stack: [
6812
+ {
6813
+ type: "drape",
6814
+ options: { amplitude: 0.17, folds: 5, falloff: 1.6, irregular: 0.45, gather: 0.5, pinnedEdge: "top" }
6815
+ }
6816
+ ],
6817
+ sheet: { width: 1.5, height: 8.5 },
6818
+ t: 0
6819
+ },
6820
+ {
6821
+ name: "drape: deep irregular folds pinned at the bottom",
6822
+ stack: [
6823
+ {
6824
+ type: "drape",
6825
+ options: { amplitude: 0.55, folds: 11, falloff: 0.4, irregular: 1, gather: 1, pinnedEdge: "bottom" }
6826
+ }
6827
+ ],
6828
+ sheet: { width: 2.2, height: 3 },
6829
+ t: 0
6830
+ },
6831
+ {
6832
+ name: "crumple: defaults",
6833
+ stack: [{ type: "crumple", options: { amount: 0.35, scale: 3, pull: 0.4, seed: 0 } }],
6834
+ sheet: { width: 1, height: 1.4 },
6835
+ t: 0
6836
+ },
6837
+ {
6838
+ // The adversarial one. `fract` is a sawtooth, so the two halves disagree
6839
+ // hardest where a crease lands exactly on a sample — a fine scale and a
6840
+ // seed whose fold directions are near-axis puts the most creases in
6841
+ // reach of the grid.
6842
+ name: "crumple: fully crushed, fine creases, off-axis seed",
6843
+ stack: [{ type: "crumple", options: { amount: 1, scale: 7.5, pull: 1, seed: 5 } }],
6844
+ sheet: { width: 1.3, height: 0.9 },
6845
+ t: 0
6846
+ },
6847
+ {
6848
+ // Crush then curl, which is the order the crumple BEHAVIOR stacks them:
6849
+ // the creases have to be placed on the flat sheet, not on a bent one.
6850
+ name: "stacked: crumple \u2218 bend (the crumple behavior)",
6851
+ stack: [
6852
+ { type: "crumple", options: { amount: 0.62, scale: 3.3, pull: 0.5, seed: 2 } },
6853
+ { type: "bend", options: { curvature: 0.31, angle: 35 } }
6854
+ ],
6855
+ sheet: { width: 1.1, height: 1.4 },
6856
+ t: 0
6857
+ },
6858
+ {
6859
+ name: "stacked: letter-fold pair (fold \u2218 fold)",
6860
+ stack: [
6861
+ { type: "fold", options: { angle: 270, offset: 0.2333, foldAngle: 120, radius: 0.05 } },
6862
+ { type: "fold", options: { angle: 90, offset: 0.2333, foldAngle: 100, radius: 0.08 } }
6863
+ ],
6864
+ sheet: { width: 1, height: 1.4 },
6865
+ t: 0
6866
+ },
6867
+ {
6868
+ name: "stacked: bend \u2218 roll \u2218 wave",
6869
+ stack: [
6870
+ { type: "bend", options: { curvature: 0.6, angle: 0 } },
6871
+ { type: "roll", options: { angle: 90, boundary: 0.1, radius: 0.15, spiral: 0 } },
6872
+ {
6873
+ type: "wave",
6874
+ options: { amplitude: 0.02, wavelength: 0.6, speed: 0.7, angle: 45, pinnedEdge: "none" }
6875
+ }
6876
+ ],
6877
+ sheet: { width: 1, height: 1.4 },
6878
+ t: 0.8
6879
+ }
6880
+ ];
6881
+ function buildParityFragment(stack, sheet2) {
6882
+ const composed = buildDisplacementGLSL(stack, sheet2);
6883
+ return (
6884
+ /* glsl */
6885
+ `#version 300 es
6886
+ precision highp float;
6887
+ uniform float uPlTime;
6888
+ uniform float uPlBias;
6889
+ ${composed.functionsSrc}
6890
+ ${composed.displaceSrc}
6891
+ out vec4 outColor;
6892
+ void main() {
6893
+ vec2 uv = (gl_FragCoord.xy - 0.5) / float(${GRID - 1});
6894
+ vec3 p = vec3((uv - 0.5) * uSheet, 0.0);
6895
+ outColor = vec4(plDisplace(p, uv, uPlTime, uPlBias), 1.0);
6896
+ }
6897
+ `
6898
+ );
6899
+ }
6900
+ var VERT = `#version 300 es
6901
+ in vec2 aPos;
6902
+ void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
6903
+ `;
6904
+ function compile(gl, type, src) {
6905
+ const shader = gl.createShader(type);
6906
+ gl.shaderSource(shader, src);
6907
+ gl.compileShader(shader);
6908
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
6909
+ throw new Error(`[paperlab parity] shader compile failed: ${gl.getShaderInfoLog(shader)}`);
6910
+ }
6911
+ return shader;
6912
+ }
6913
+ function runCaseOnGPU(gl, c, bias = 1) {
6914
+ const composed = buildDisplacementGLSL(c.stack, c.sheet);
6915
+ const program = gl.createProgram();
6916
+ gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
6917
+ gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, buildParityFragment(c.stack, c.sheet)));
6918
+ gl.linkProgram(program);
6919
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
6920
+ throw new Error(`[paperlab parity] link failed: ${gl.getProgramInfoLog(program)}`);
6921
+ }
6922
+ gl.useProgram(program);
6923
+ for (const [name, value] of Object.entries(composed.uniforms)) {
6924
+ const loc = gl.getUniformLocation(program, name);
6925
+ if (!loc) continue;
6926
+ if (typeof value === "number") gl.uniform1f(loc, value);
6927
+ else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
6928
+ else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
6929
+ else gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
6930
+ }
6931
+ const timeLoc = gl.getUniformLocation(program, "uPlTime");
6932
+ if (timeLoc) gl.uniform1f(timeLoc, c.t);
6933
+ const biasLoc = gl.getUniformLocation(program, "uPlBias");
6934
+ if (biasLoc) gl.uniform1f(biasLoc, bias);
6935
+ const quad = gl.createBuffer();
6936
+ gl.bindBuffer(gl.ARRAY_BUFFER, quad);
6937
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
6938
+ const posLoc = gl.getAttribLocation(program, "aPos");
6939
+ gl.enableVertexAttribArray(posLoc);
6940
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
6941
+ const tex = gl.createTexture();
6942
+ gl.bindTexture(gl.TEXTURE_2D, tex);
6943
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, GRID, GRID, 0, gl.RGBA, gl.FLOAT, null);
6944
+ const fbo = gl.createFramebuffer();
6945
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
6946
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
6947
+ gl.viewport(0, 0, GRID, GRID);
6948
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
6949
+ const out = new Float32Array(GRID * GRID * 4);
6950
+ gl.readPixels(0, 0, GRID, GRID, gl.RGBA, gl.FLOAT, out);
6951
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
6952
+ return out;
6953
+ }
6954
+ function runBiasCases(gl) {
6955
+ const results = [];
6956
+ for (const c of parityCases) {
6957
+ const strengths = c.stack.map((i) => getDeformer(i.type).glsl?.strength !== void 0);
6958
+ if (!strengths.every((h) => h === strengths[0])) continue;
6959
+ const scales = strengths[0];
6960
+ const at0 = runCaseOnGPU(gl, c, 0);
6961
+ const reference = scales ? null : runCaseOnGPU(gl, c, 1);
6962
+ let maxError = 0;
6963
+ for (let row = 0; row < GRID; row++) {
6964
+ for (let col = 0; col < GRID; col++) {
6965
+ const i4 = (row * GRID + col) * 4;
6966
+ const ex = reference ? reference[i4] : (col / (GRID - 1) - 0.5) * c.sheet.width;
6967
+ const ey = reference ? reference[i4 + 1] : (row / (GRID - 1) - 0.5) * c.sheet.height;
6968
+ const ez = reference ? reference[i4 + 2] : 0;
6969
+ maxError = Math.max(
6970
+ maxError,
6971
+ Math.abs(at0[i4] - ex),
6972
+ Math.abs(at0[i4 + 1] - ey),
6973
+ Math.abs(at0[i4 + 2] - ez)
6974
+ );
6975
+ }
6976
+ }
6977
+ results.push({
6978
+ name: `bias: ${c.name} \u2192 ${scales ? "flat at 0" : "ignores bias"}`,
6979
+ maxError,
6980
+ pass: maxError < PARITY_EPSILON
6981
+ });
6982
+ }
6983
+ return results;
6984
+ }
6985
+ function runParityHarness(canvas) {
6986
+ const cnv = canvas ?? document.createElement("canvas");
6987
+ const gl = cnv.getContext("webgl2");
6988
+ if (!gl) throw new Error("[paperlab parity] WebGL2 unavailable");
6989
+ if (!gl.getExtension("EXT_color_buffer_float")) {
6990
+ throw new Error("[paperlab parity] EXT_color_buffer_float unavailable");
6991
+ }
6992
+ const point = new THREE18.Vector3();
6993
+ const parity = parityCases.map((c) => {
6994
+ const gpu = runCaseOnGPU(gl, c);
6995
+ let maxError = 0;
6996
+ for (let row = 0; row < GRID; row++) {
6997
+ for (let col = 0; col < GRID; col++) {
6998
+ const u = col / (GRID - 1);
6999
+ const v = row / (GRID - 1);
7000
+ point.set((u - 0.5) * c.sheet.width, (v - 0.5) * c.sheet.height, 0);
7001
+ displacePoint(point, u, v, c.stack, { t: c.t, sheet: c.sheet });
7002
+ const i4 = (row * GRID + col) * 4;
7003
+ maxError = Math.max(
7004
+ maxError,
7005
+ Math.abs(gpu[i4] - point.x),
7006
+ Math.abs(gpu[i4 + 1] - point.y),
7007
+ Math.abs(gpu[i4 + 2] - point.z)
7008
+ );
7009
+ }
7010
+ }
7011
+ return { name: c.name, maxError, pass: maxError < PARITY_EPSILON };
7012
+ });
7013
+ return [...parity, ...runBiasCases(gl)];
7014
+ }
7015
+
5082
7016
  // src/config/field-export.ts
5083
7017
  var MOTION_DEFAULTS = { driver: "autoplay", speed: 0.5 };
5084
7018
  var ENTRANCE_DEFAULTS = { type: "rise", stagger: 0.06, duration: 0.9 };
@@ -5120,12 +7054,15 @@ function diffFieldProps(input) {
5120
7054
  }
5121
7055
  var LAYOUT_PHRASES = {
5122
7056
  ring: "a ring you can see around",
5123
- deck: "a stacked deck",
5124
- cascade: "a diagonal cascade",
5125
- helix: "a climbing helix",
5126
- wall: "a jittered wall grid",
5127
- tunnel: "a receding tunnel",
5128
- scatter: "a loose scatter",
7057
+ fan: "a fanned swatch deck hinged at one corner",
7058
+ spread: "a stack slid sideways, each sheet bowing further than the last",
7059
+ pile: "a heap on a desk, the sheets underneath pressed flat",
7060
+ wall: "a pinned studio wall of sheets, none hanging quite square",
7061
+ spill: "a dropped stack caught mid-air, each sheet bent its own way",
7062
+ sweep: "a specimen chart of one sheet at every stage of its deformation",
7063
+ book: "an open book, its pages splayed from a shared spine",
7064
+ accordion: "one continuous strip folded into a concertina",
7065
+ rack: "prints stood in a row and leaning back against a wall",
5129
7066
  sheet: "a stamp-block grid on a shared backing sheet"
5130
7067
  };
5131
7068
  function describeFieldConfig(input) {
@@ -5146,7 +7083,7 @@ function describeFieldConfig(input) {
5146
7083
  parts.push("hovering peels/reacts per paper (interaction states)");
5147
7084
  if (zones.length > 0) {
5148
7085
  parts.push(
5149
- `dragging one past its tear threshold detaches it to carry to the ${zones.map((z18) => z18.id).join(" or ")} zone (release elsewhere flutters it back)`
7086
+ `dragging one past its tear threshold detaches it to carry to the ${zones.map((z27) => z27.id).join(" or ")} zone (release elsewhere flutters it back)`
5150
7087
  );
5151
7088
  }
5152
7089
  } else {
@@ -5275,8 +7212,12 @@ export {
5275
7212
  PaperMaterial,
5276
7213
  PaperMesh,
5277
7214
  PaperMirror,
7215
+ PaperStage,
7216
+ PaperStageScene,
5278
7217
  PaperStateMachine,
5279
7218
  SHEET_LIFT,
7219
+ TRANSMISSION_GAIN,
7220
+ accordion,
5280
7221
  applyDeformerStack,
5281
7222
  atlasGrid,
5282
7223
  backContentSchema,
@@ -5284,6 +7225,7 @@ export {
5284
7225
  behaviorConfigSchema,
5285
7226
  bend,
5286
7227
  bendOptionsSchema,
7228
+ book,
5287
7229
  buildAgentPayload,
5288
7230
  buildDisplacementGLSL,
5289
7231
  buildFieldAgentPayload,
@@ -5291,32 +7233,45 @@ export {
5291
7233
  buildFieldFragmentShader,
5292
7234
  buildFieldVertexShader,
5293
7235
  buildJsxSnippet,
7236
+ buildStageAgentPayload,
7237
+ buildStageComponentSource,
5294
7238
  carry,
5295
7239
  carryDrive,
5296
7240
  carryOptionsSchema,
5297
- cascade,
5298
7241
  clothConfigSchema,
7242
+ colonnade,
5299
7243
  composeSurface,
5300
7244
  contentSchema,
5301
7245
  contentText,
5302
7246
  coreStateNames,
5303
7247
  cornerNames,
5304
7248
  createSheetGeometry,
7249
+ createWalkPath,
7250
+ crumple,
7251
+ crumpleBehavior,
7252
+ crumpleBehaviorOptionsSchema,
7253
+ crumpleOptionsSchema,
5305
7254
  curl,
5306
7255
  curlOptionsSchema,
5307
7256
  dampTo,
5308
- deck,
5309
7257
  deformerInstanceSchema,
5310
7258
  describeConfig,
5311
7259
  describeFieldConfig,
7260
+ describeStage,
5312
7261
  diffConfig,
5313
7262
  diffFieldProps,
7263
+ diffStage,
5314
7264
  displacePoint,
5315
7265
  distinctFieldPresets,
7266
+ drape,
7267
+ drapeOptionsSchema,
5316
7268
  drawBacking,
5317
7269
  fall,
5318
7270
  fallOptionsSchema,
7271
+ fan,
7272
+ fieldBounds,
5319
7273
  fieldKeyboardStep,
7274
+ fitCamera,
5320
7275
  flattenNumeric,
5321
7276
  flight,
5322
7277
  flightOptionsSchema,
@@ -5333,12 +7288,13 @@ export {
5333
7288
  getLayout,
5334
7289
  getLightingPreset,
5335
7290
  getPreset,
7291
+ getStagePreset,
5336
7292
  getStock,
7293
+ getWalk,
5337
7294
  groupFieldPapers,
5338
7295
  gust,
5339
7296
  hang,
5340
7297
  hangOptionsSchema,
5341
- helix,
5342
7298
  idleNames,
5343
7299
  idlePresets,
5344
7300
  isBuiltinPreset,
@@ -5351,6 +7307,7 @@ export {
5351
7307
  listDeformers,
5352
7308
  listLayouts,
5353
7309
  listPresets,
7310
+ listStagePresets,
5354
7311
  makeGoboTexture,
5355
7312
  mergeConfig,
5356
7313
  mergeWithDeletes,
@@ -5364,8 +7321,11 @@ export {
5364
7321
  peelOptionsSchema,
5365
7322
  physicsNames,
5366
7323
  physicsSchema,
7324
+ pile,
7325
+ qualityNames,
5367
7326
  quantizeProgress,
5368
7327
  quantizeTime,
7328
+ rack,
5369
7329
  receiptContentSchema,
5370
7330
  receiptTotals,
5371
7331
  recordStateOverride,
@@ -5374,6 +7334,7 @@ export {
5374
7334
  registerLayout,
5375
7335
  registerPreset,
5376
7336
  resolveConfig,
7337
+ resolveDeformerStack,
5377
7338
  resolveFieldSlotConfig,
5378
7339
  resolveMode,
5379
7340
  resolveSegments,
@@ -5382,7 +7343,6 @@ export {
5382
7343
  roll,
5383
7344
  rollOptionsSchema,
5384
7345
  runParityHarness,
5385
- scatter,
5386
7346
  sceneSchema,
5387
7347
  serializePreset,
5388
7348
  sheet,
@@ -5390,20 +7350,28 @@ export {
5390
7350
  sheetLayoutSchema,
5391
7351
  sheetSchema,
5392
7352
  sheetSlotXY,
7353
+ shotNames,
5393
7354
  silhouetteRects,
7355
+ spill,
7356
+ spread,
5394
7357
  stackMinSegments,
5395
7358
  stackUniformValues,
7359
+ stagePresets,
7360
+ stageSchema,
5396
7361
  stateDefSchema,
5397
7362
  stateEventTransitions,
5398
7363
  stateTransitionSchema,
5399
7364
  stockNames,
5400
7365
  stockSchema,
5401
7366
  stocks,
7367
+ stringifyStage,
5402
7368
  stripStates,
5403
7369
  supportsWebGL,
5404
7370
  surfaceSchema,
7371
+ sweep,
5405
7372
  tornEdgesOnDetach,
5406
- tunnel,
7373
+ translucencyUniforms,
7374
+ translucencyValues,
5407
7375
  uniquePresetName,
5408
7376
  unregisterPreset,
5409
7377
  unroll,
@@ -5411,6 +7379,9 @@ export {
5411
7379
  useContentAtlas,
5412
7380
  usePaperStates,
5413
7381
  usePrefersReducedMotion,
7382
+ walkNameFor,
7383
+ walkNames,
7384
+ walks,
5414
7385
  wall,
5415
7386
  wave,
5416
7387
  waveOptionsSchema,