paperlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stage.js ADDED
@@ -0,0 +1,2214 @@
1
+ import {
2
+ AGENT_PAYLOAD_VERSION,
3
+ LightRig,
4
+ PaperFieldMesh,
5
+ PaperLighting,
6
+ createWalkPath,
7
+ cssColorOr,
8
+ diffConfig,
9
+ getLayout,
10
+ getWalkPath,
11
+ lightSchema,
12
+ lightingNames,
13
+ paperConfigSchema,
14
+ resolveConfig,
15
+ resolveLighting,
16
+ usePrefersReducedMotion,
17
+ walkPathSchema
18
+ } from "./chunk-3IMUEESH.js";
19
+
20
+ // src/stage/PaperStage.tsx
21
+ import * as THREE6 from "three";
22
+ import { Canvas, useFrame as useFrame4, useThree as useThree2 } from "@react-three/fiber";
23
+ import { useCallback as useCallback2, useEffect as useEffect7, useMemo as useMemo7, useRef as useRef5, useState } from "react";
24
+ import { z as z5 } from "zod";
25
+
26
+ // src/stage/camera.ts
27
+ import { z } from "zod";
28
+ var shotNames = ["follow", "lead", "low", "wide"];
29
+ var shotSchema = z.object({
30
+ shot: z.enum(shotNames).default("follow"),
31
+ /**
32
+ * How far the camera stands off the figure ALONG the walk, world units.
33
+ * `wide` reads it as how far back it stands; how far it steps aside is
34
+ * derived from the paper, since that is what it has to clear.
35
+ */
36
+ distance: z.number().min(0.2).max(40).default(4.5),
37
+ /** Multiplier on the shot's natural camera height. 1 is as designed. */
38
+ height: z.number().min(0).max(6).default(1),
39
+ /** How far up the walk the camera looks past the figure, world units. */
40
+ lookAhead: z.number().min(0).max(40).default(7),
41
+ /** Sideways step off the walk line, world units. Positive is the walker's left. */
42
+ offset: z.number().min(-20).max(20).default(0)
43
+ });
44
+ var EYE = {
45
+ follow: 0.95,
46
+ lead: 0.95,
47
+ // Down near the floor, where the banners tower — the worm's-eye of the
48
+ // reference frames, and the cheapest way to make paper read as architecture.
49
+ low: 0.12,
50
+ wide: 1.1
51
+ };
52
+ var AIM = {
53
+ // Chest height on the figure, a third of the way up the paper — enough
54
+ // tilt that a printed banner reads, not so much that the floor is lost.
55
+ follow: { figure: 0.62, paper: 0.3 },
56
+ // Framing the figure itself, so the paper only lifts the aim a little.
57
+ lead: { figure: 0.62, paper: 0.1 },
58
+ // Up the banners. The figure is incidental to this shot.
59
+ low: { figure: 0, paper: 0.62 },
60
+ wide: { figure: 0.62, paper: 0.2 }
61
+ };
62
+ var WIDE_STANDOFF = 1.5;
63
+ var DEFAULT_PAPER_RATIO = 4.9;
64
+ function resolveScale(scale) {
65
+ if (typeof scale === "number") return { figure: scale, paper: scale * DEFAULT_PAPER_RATIO };
66
+ return scale;
67
+ }
68
+ function walkPoint(path, distance) {
69
+ if (path.length === 0) return path.pointAt(0);
70
+ if (path.closed) return path.pointAt(distance / path.length);
71
+ if (distance < 0) {
72
+ const [x, z6] = path.pointAt(0);
73
+ const [tx, tz] = path.tangentAt(0);
74
+ return [x + tx * distance, z6 + tz * distance];
75
+ }
76
+ if (distance > path.length) {
77
+ const over = distance - path.length;
78
+ const [x, z6] = path.pointAt(1);
79
+ const [tx, tz] = path.tangentAt(1);
80
+ return [x + tx * over, z6 + tz * over];
81
+ }
82
+ return path.pointAt(distance / path.length);
83
+ }
84
+ function walkNormal(path, distance) {
85
+ if (path.length === 0) return path.normalAt(0);
86
+ if (path.closed) return path.normalAt(distance / path.length);
87
+ return path.normalAt(Math.min(Math.max(distance, 0), path.length) / path.length);
88
+ }
89
+ function stageCamera(path, walked, scale, options) {
90
+ const { figure, paper } = resolveScale(scale);
91
+ const eye = figure * EYE[options.shot] * options.height;
92
+ const aim = figure * AIM[options.shot].figure + paper * AIM[options.shot].paper;
93
+ let station;
94
+ let mark;
95
+ if (options.shot === "lead") {
96
+ station = walked + options.distance;
97
+ mark = walked;
98
+ } else if (options.shot === "wide") {
99
+ station = walked - options.distance;
100
+ mark = walked;
101
+ } else {
102
+ station = walked - options.distance;
103
+ mark = walked + options.lookAhead;
104
+ }
105
+ const [sx, sz] = walkPoint(path, station);
106
+ const [mx, mz] = walkPoint(path, mark);
107
+ const [nx, nz] = walkNormal(path, station);
108
+ const step = options.offset + (options.shot === "wide" ? paper * WIDE_STANDOFF : 0);
109
+ return {
110
+ position: [sx + nx * step, eye, sz + nz * step],
111
+ target: [mx, aim, mz]
112
+ };
113
+ }
114
+
115
+ // src/stage/Figure.tsx
116
+ import * as THREE2 from "three";
117
+ import { Suspense, useEffect as useEffect2, useMemo as useMemo2, useRef } from "react";
118
+ import { useFrame as useFrame2 } from "@react-three/fiber";
119
+
120
+ // src/stage/RiggedFigure.tsx
121
+ import { useGLTF } from "@react-three/drei";
122
+ import { useFrame } from "@react-three/fiber";
123
+ import { Component, useEffect, useMemo } from "react";
124
+ import * as THREE from "three";
125
+ import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
126
+
127
+ // src/stage/gait.ts
128
+ import { z as z2 } from "zod";
129
+ var figureSchema = z2.object({
130
+ /** Standing height in world units — the scale reference the whole stage is read against. */
131
+ height: z2.number().min(0.5).max(4).default(1.75),
132
+ /** World units per second along the walk. A relaxed indoor pace is ~1.2. */
133
+ speed: z2.number().min(0).max(4).default(1.2),
134
+ /** Stride length as a fraction of height — how far one step carries. */
135
+ stride: z2.number().min(0.1).max(1).default(0.42),
136
+ /** Arm swing, 0..1. Drop it toward 0 for hands-in-pockets stillness. */
137
+ swing: z2.number().min(0).max(1).default(1),
138
+ /** Silhouette color. Near-black by default: it should read as an absence, not an object. */
139
+ color: z2.string().default("#0a0a0c"),
140
+ /**
141
+ * How the figure takes light.
142
+ *
143
+ * `silhouette` is the flat unlit shape this mode was built around: the
144
+ * nave is lit from behind, and a shape that reads as an absence never
145
+ * competes with the paper for attention.
146
+ *
147
+ * `shaded` hands the figure to the rig instead — its own materials, lit
148
+ * by the key and the studio light, so a backlit hall gives it a rim down
149
+ * one edge and the room fills the other. It costs nothing extra and it is
150
+ * the reason to bring a good model: at `silhouette` any two rigs with the
151
+ * same outline are the same picture. Ignored by the capsule figure, which
152
+ * has no materials worth lighting.
153
+ */
154
+ finish: z2.enum(["silhouette", "shaded"]).default("shaded"),
155
+ /**
156
+ * Walk or run. `'auto'` decides from `speed` and leg length, at the point
157
+ * people actually break into a run — see `isRunning`.
158
+ */
159
+ gait: z2.enum(["auto", "walk", "run"]).default("auto"),
160
+ /**
161
+ * URL of a rigged glTF/GLB to use instead of the capsules. Serializes as a
162
+ * string, so a `.paper` carrying one stays a `.paper` — but the asset is
163
+ * NOT part of the library and never ships in the npm tarball; the app hosts
164
+ * it. Anything that fails to load falls back to the capsule figure rather
165
+ * than emptying the stage.
166
+ */
167
+ model: z2.string().optional()
168
+ });
169
+ var PROPORTIONS = {
170
+ hip: 0.53,
171
+ shoulder: 0.82,
172
+ headRadius: 0.045,
173
+ headCenter: 0.935,
174
+ thigh: 0.245,
175
+ shin: 0.235,
176
+ upperArm: 0.185,
177
+ foreArm: 0.165,
178
+ torsoWidth: 0.19,
179
+ hipWidth: 0.095,
180
+ limbRadius: 0.028
181
+ };
182
+ var WALK = {
183
+ thigh: 0.42,
184
+ arm: 0.5,
185
+ knee: 1.1,
186
+ elbow: 0.38,
187
+ pelvis: 0.07,
188
+ chest: 0.14,
189
+ sway: 0.05,
190
+ hipDrop: 0.07,
191
+ lean: 0.045
192
+ };
193
+ var RUN = {
194
+ thigh: 0.7,
195
+ arm: 0.95,
196
+ knee: 1.9,
197
+ elbow: 1.5,
198
+ pelvis: 0.14,
199
+ chest: 0.26,
200
+ sway: 0.08,
201
+ hipDrop: 0.1,
202
+ lean: 0.16
203
+ };
204
+ var BOB = 0.016;
205
+ var RUN_COMPRESS = 0.035;
206
+ var RUN_LIFT = 0.03;
207
+ var RUN_STRIDE = 1.7;
208
+ var LEAN_FULL_SPEED = 1.2;
209
+ var GRAVITY = 9.81;
210
+ var FROUDE_RUN = 0.5;
211
+ var TAU = Math.PI * 2;
212
+ function isRunning(o) {
213
+ if (o.gait !== "auto") return o.gait === "run";
214
+ const legLength = PROPORTIONS.hip * o.height;
215
+ if (legLength <= 0) return false;
216
+ return o.speed * o.speed / (GRAVITY * legLength) > FROUDE_RUN;
217
+ }
218
+ function cycleLength(o) {
219
+ const stride = o.stride * (isRunning(o) ? RUN_STRIDE : 1);
220
+ return stride * o.height * 2;
221
+ }
222
+ function figureGait(distance, o) {
223
+ const running = isRunning(o);
224
+ const a = running ? RUN : WALK;
225
+ const cycle = cycleLength(o);
226
+ const phase = cycle > 0 ? (distance / cycle % 1 + 1) % 1 : 0;
227
+ const w = phase * TAU;
228
+ const leftThigh = a.thigh * Math.sin(w);
229
+ const rightThigh = a.thigh * Math.sin(w + Math.PI);
230
+ const flex = (at) => -a.knee * Math.max(0, Math.cos(w - at)) ** 1.5;
231
+ const leftKnee = flex(7 * Math.PI / 4);
232
+ const rightKnee = flex(3 * Math.PI / 4);
233
+ const leftArm = -a.arm * o.swing * Math.sin(w);
234
+ const rightArm = -a.arm * o.swing * Math.sin(w + Math.PI);
235
+ const bend = (forwardness) => a.elbow * o.swing * (0.7 + 0.3 * Math.max(0, forwardness));
236
+ const leftElbow = bend(-Math.sin(w));
237
+ const rightElbow = bend(-Math.sin(w + Math.PI));
238
+ const pelvis = a.pelvis * Math.sin(w);
239
+ const chest = -a.chest * Math.sin(w);
240
+ const sway = -a.sway * Math.cos(w);
241
+ const hipDrop = a.hipDrop * Math.cos(w);
242
+ const midstance = Math.abs(Math.cos(w));
243
+ const bob = running ? o.height * (RUN_LIFT * (1 - midstance) - RUN_COMPRESS * midstance) : -BOB * o.height * (1 - midstance);
244
+ return {
245
+ phase,
246
+ running,
247
+ bob,
248
+ lean: running ? a.lean : a.lean * Math.min(o.speed / LEAN_FULL_SPEED, 1),
249
+ pelvis,
250
+ chest,
251
+ sway,
252
+ hipDrop,
253
+ leftThigh,
254
+ rightThigh,
255
+ leftKnee,
256
+ rightKnee,
257
+ leftArm,
258
+ rightArm,
259
+ leftElbow,
260
+ rightElbow
261
+ };
262
+ }
263
+ function pickClip(names, running) {
264
+ return matchClip(names, running ? RUN_CLIP : WALK_CLIP, running ? WALK_CLIP : RUN_CLIP);
265
+ }
266
+ function pickStillClip(names) {
267
+ return matchClip(names, IDLE_CLIP, WALK_CLIP);
268
+ }
269
+ var WALK_CLIP = /walk/i;
270
+ var RUN_CLIP = /run|jog|sprint/i;
271
+ var IDLE_CLIP = /idle|stand/i;
272
+ function matchClip(names, wanted, fallback) {
273
+ const best = (re) => names.filter((n) => re.test(n)).sort((a, b) => a.length - b.length)[0];
274
+ return best(wanted) ?? best(fallback) ?? names[0];
275
+ }
276
+ function clipTimeFor(distance, o, clipDuration) {
277
+ if (!(clipDuration > 0)) return 0;
278
+ const cycle = cycleLength(o);
279
+ if (!(cycle > 0)) return 0;
280
+ const phase = (distance / cycle % 1 + 1) % 1;
281
+ return phase * clipDuration;
282
+ }
283
+ function placeFigure(path, distance, o) {
284
+ const raw = path.length > 0 ? distance / path.length : 0;
285
+ const s = path.closed ? (raw % 1 + 1) % 1 : Math.min(Math.max(raw, 0), 1);
286
+ const [x, z6] = path.pointAt(s);
287
+ const [tx, tz] = path.tangentAt(s);
288
+ const travelled = path.closed ? distance : Math.min(distance, path.length);
289
+ return {
290
+ position: [x, 0, z6],
291
+ yaw: Math.atan2(tx, tz),
292
+ pose: figureGait(travelled, o),
293
+ s
294
+ };
295
+ }
296
+
297
+ // src/stage/RiggedFigure.tsx
298
+ import { jsx } from "react/jsx-runtime";
299
+ var ModelBoundary = class extends Component {
300
+ state = { failed: false };
301
+ static getDerivedStateFromError() {
302
+ return { failed: true };
303
+ }
304
+ componentDidCatch(error) {
305
+ console.warn("[paperlab] figure.model failed to load \u2014 using the capsule figure.", error);
306
+ }
307
+ render() {
308
+ return this.state.failed ? this.props.fallback : this.props.children;
309
+ }
310
+ };
311
+ function Rigged({ url, options, distance, frozen }) {
312
+ const gltf = useGLTF(url);
313
+ const scene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
314
+ const silhouette = options.finish === "silhouette";
315
+ const material = useMemo(
316
+ () => silhouette ? new THREE.MeshBasicMaterial({ color: options.color, toneMapped: false }) : null,
317
+ [silhouette, options.color]
318
+ );
319
+ useEffect(() => () => material?.dispose(), [material]);
320
+ const scale = useMemo(() => {
321
+ scene.traverse((child) => {
322
+ const mesh = child;
323
+ if (!mesh.isMesh) return;
324
+ mesh.userData.plAuthored ??= mesh.material;
325
+ mesh.material = material ?? mesh.userData.plAuthored;
326
+ mesh.castShadow = true;
327
+ mesh.receiveShadow = !silhouette;
328
+ });
329
+ scene.updateMatrixWorld(true);
330
+ const box = new THREE.Box3().setFromObject(scene);
331
+ const height = box.max.y - box.min.y;
332
+ return height > 0 ? options.height / height : 1;
333
+ }, [scene, material, silhouette, options.height]);
334
+ const mixer = useMemo(() => new THREE.AnimationMixer(scene), [scene]);
335
+ const clip = useMemo(() => {
336
+ const names = gltf.animations.map((a) => a.name);
337
+ const wanted = frozen ? pickStillClip(names) : pickClip(names, isRunning(options));
338
+ return gltf.animations.find((a) => a.name === wanted) ?? gltf.animations[0];
339
+ }, [gltf.animations, options, frozen]);
340
+ useEffect(() => {
341
+ if (!clip) return;
342
+ mixer.clipAction(clip).play();
343
+ return () => {
344
+ mixer.stopAllAction();
345
+ mixer.uncacheClip(clip);
346
+ };
347
+ }, [mixer, clip]);
348
+ useFrame(() => {
349
+ if (!clip) return;
350
+ mixer.setTime(frozen ? 0 : clipTimeFor(distance.current, options, clip.duration));
351
+ });
352
+ return /* @__PURE__ */ jsx("primitive", { object: scene, scale });
353
+ }
354
+ function RiggedFigure({ fallback, ...props }) {
355
+ return /* @__PURE__ */ jsx(ModelBoundary, { fallback, children: /* @__PURE__ */ jsx(Rigged, { ...props }) });
356
+ }
357
+
358
+ // src/stage/Figure.tsx
359
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
360
+ function Segment({ length, radius, material }) {
361
+ const shaft = Math.max(length - radius * 2, 1e-3);
362
+ return /* @__PURE__ */ jsx2("mesh", { position: [0, -length / 2, 0], material, castShadow: true, children: /* @__PURE__ */ jsx2("capsuleGeometry", { args: [radius, shaft, 4, 10] }) });
363
+ }
364
+ function Figure({ path, figure, distance, distanceRef, walkLength, frozen }) {
365
+ const reducedMotion = usePrefersReducedMotion();
366
+ const still = frozen ?? reducedMotion;
367
+ const options = useMemo2(() => figureSchema.parse(figure ?? {}), [figure]);
368
+ const walk = useMemo2(() => getWalkPath(walkPathSchema.parse(path ?? {})), [path]);
369
+ const root = useRef(null);
370
+ const walkedRef = useRef(0);
371
+ const hips = useRef(null);
372
+ const chest = useRef(null);
373
+ const legL = useRef(null);
374
+ const legR = useRef(null);
375
+ const kneeL = useRef(null);
376
+ const kneeR = useRef(null);
377
+ const armL = useRef(null);
378
+ const armR = useRef(null);
379
+ const elbowL = useRef(null);
380
+ const elbowR = useRef(null);
381
+ const material = useMemo2(
382
+ () => new THREE2.MeshBasicMaterial({ color: options.color, toneMapped: false }),
383
+ [options.color]
384
+ );
385
+ useEffect2(() => () => material.dispose(), [material]);
386
+ const h = options.height;
387
+ const p = PROPORTIONS;
388
+ const torso = (p.shoulder - p.hip) * h;
389
+ useFrame2((state) => {
390
+ const walked = distanceRef !== void 0 ? distanceRef.current * (walkLength ?? walk.length) : distance ?? (still ? 0 : state.clock.elapsedTime * options.speed);
391
+ walkedRef.current = walked;
392
+ const { position, yaw, pose } = placeFigure(walk, walked, options);
393
+ root.current?.position.set(position[0], position[1], position[2]);
394
+ if (root.current) root.current.rotation.y = yaw;
395
+ if (hips.current) {
396
+ hips.current.position.y = p.hip * h + (still ? 0 : pose.bob);
397
+ hips.current.rotation.y = still ? 0 : pose.pelvis;
398
+ hips.current.rotation.z = still ? 0 : pose.hipDrop;
399
+ }
400
+ if (chest.current) {
401
+ chest.current.rotation.x = pose.lean;
402
+ chest.current.rotation.y = still ? 0 : pose.chest - pose.pelvis;
403
+ chest.current.rotation.z = still ? 0 : pose.sway - pose.hipDrop;
404
+ }
405
+ if (legL.current) legL.current.rotation.x = still ? 0 : -pose.leftThigh;
406
+ if (legR.current) legR.current.rotation.x = still ? 0 : -pose.rightThigh;
407
+ if (kneeL.current) kneeL.current.rotation.x = still ? 0 : -pose.leftKnee;
408
+ if (kneeR.current) kneeR.current.rotation.x = still ? 0 : -pose.rightKnee;
409
+ if (armL.current) armL.current.rotation.x = still ? 0 : -pose.leftArm;
410
+ if (armR.current) armR.current.rotation.x = still ? 0 : -pose.rightArm;
411
+ if (elbowL.current) elbowL.current.rotation.x = still ? 0 : -pose.leftElbow;
412
+ if (elbowR.current) elbowR.current.rotation.x = still ? 0 : -pose.rightElbow;
413
+ });
414
+ const capsules = /* @__PURE__ */ jsxs("group", { ref: hips, children: [
415
+ [-1, 1].map((side) => {
416
+ const leg = side < 0 ? legL : legR;
417
+ const knee = side < 0 ? kneeL : kneeR;
418
+ return /* @__PURE__ */ jsxs("group", { ref: leg, position: [side * p.hipWidth * h / 2, 0, 0], children: [
419
+ /* @__PURE__ */ jsx2(Segment, { length: p.thigh * h, radius: p.limbRadius * h, material }),
420
+ /* @__PURE__ */ jsx2("group", { ref: knee, position: [0, -p.thigh * h, 0], children: /* @__PURE__ */ jsx2(Segment, { length: p.shin * h, radius: p.limbRadius * h * 0.9, material }) })
421
+ ] }, `leg${side}`);
422
+ }),
423
+ /* @__PURE__ */ jsxs("group", { ref: chest, children: [
424
+ /* @__PURE__ */ jsx2("mesh", { position: [0, torso / 2, 0], material, castShadow: true, children: /* @__PURE__ */ jsx2("capsuleGeometry", { args: [p.torsoWidth * h / 2, torso * 0.72, 4, 12] }) }),
425
+ /* @__PURE__ */ jsx2("mesh", { position: [0, (p.headCenter - p.hip) * h, 0], material, castShadow: true, children: /* @__PURE__ */ jsx2("sphereGeometry", { args: [p.headRadius * h, 14, 12] }) }),
426
+ [-1, 1].map((side) => /* @__PURE__ */ jsxs(
427
+ "group",
428
+ {
429
+ ref: side < 0 ? armL : armR,
430
+ position: [side * p.torsoWidth * h / 2, torso, 0],
431
+ children: [
432
+ /* @__PURE__ */ jsx2(Segment, { length: p.upperArm * h, radius: p.limbRadius * h * 0.8, material }),
433
+ /* @__PURE__ */ jsx2("group", { ref: side < 0 ? elbowL : elbowR, position: [0, -p.upperArm * h, 0], children: /* @__PURE__ */ jsx2(Segment, { length: p.foreArm * h, radius: p.limbRadius * h * 0.72, material }) })
434
+ ]
435
+ },
436
+ `arm${side}`
437
+ ))
438
+ ] })
439
+ ] });
440
+ return /* @__PURE__ */ jsx2("group", { ref: root, children: options.model ? (
441
+ // The capsules are both the fallback and the thing being replaced, so
442
+ // a model that is still downloading shows a walking figure rather than
443
+ // a hole, and one that never arrives leaves the stage as it was.
444
+ /* @__PURE__ */ jsx2(Suspense, { fallback: capsules, children: /* @__PURE__ */ jsx2(
445
+ RiggedFigure,
446
+ {
447
+ url: options.model,
448
+ options,
449
+ distance: walkedRef,
450
+ frozen: still,
451
+ fallback: capsules
452
+ }
453
+ ) })
454
+ ) : capsules });
455
+ }
456
+
457
+ // src/stage/Surround.tsx
458
+ import * as THREE3 from "three";
459
+ import { useEffect as useEffect3, useMemo as useMemo3 } from "react";
460
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
461
+ function makeSkyTexture(sky) {
462
+ const canvas = document.createElement("canvas");
463
+ canvas.width = 4;
464
+ canvas.height = 256;
465
+ const ctx = canvas.getContext("2d");
466
+ const grade = ctx.createLinearGradient(0, 0, 0, canvas.height);
467
+ const zenith = cssColorOr(sky.zenith, "#241c17");
468
+ const horizon = cssColorOr(sky.horizon, "#fff4e2");
469
+ const ground = cssColorOr(sky.ground, "#141210");
470
+ grade.addColorStop(0, zenith);
471
+ grade.addColorStop(0.3, zenith);
472
+ grade.addColorStop(0.62, horizon);
473
+ grade.addColorStop(0.7, horizon);
474
+ grade.addColorStop(1, ground);
475
+ ctx.fillStyle = grade;
476
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
477
+ const texture = new THREE3.CanvasTexture(canvas);
478
+ texture.colorSpace = THREE3.SRGBColorSpace;
479
+ return texture;
480
+ }
481
+ function makeGlowTexture(color) {
482
+ const size = 256;
483
+ const canvas = document.createElement("canvas");
484
+ canvas.width = canvas.height = size;
485
+ const ctx = canvas.getContext("2d");
486
+ const glow = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
487
+ const c = new THREE3.Color(color);
488
+ const rgb = `${c.r * 255 | 0}, ${c.g * 255 | 0}, ${c.b * 255 | 0}`;
489
+ for (const [stop, alpha] of [
490
+ [0, 1],
491
+ [0.5, 1],
492
+ [0.62, 0.66],
493
+ [0.74, 0.34],
494
+ [0.86, 0.11],
495
+ [0.94, 0.03],
496
+ [1, 0]
497
+ ]) {
498
+ glow.addColorStop(stop, `rgba(${rgb}, ${alpha})`);
499
+ }
500
+ ctx.fillStyle = glow;
501
+ ctx.fillRect(0, 0, size, size);
502
+ const texture = new THREE3.CanvasTexture(canvas);
503
+ texture.colorSpace = THREE3.SRGBColorSpace;
504
+ return texture;
505
+ }
506
+ var SOURCE_INTENSITY = 3.4;
507
+ function Source({
508
+ size,
509
+ position,
510
+ yaw,
511
+ color,
512
+ intensity = SOURCE_INTENSITY
513
+ }) {
514
+ const texture = useMemo3(() => makeGlowTexture(color), [color]);
515
+ useEffect3(() => () => texture.dispose(), [texture]);
516
+ return /* @__PURE__ */ jsxs2("mesh", { position, rotation: [0, yaw, 0], children: [
517
+ /* @__PURE__ */ jsx3("planeGeometry", { args: [size * 2.4, size * 1.8] }),
518
+ /* @__PURE__ */ jsx3(
519
+ "meshBasicMaterial",
520
+ {
521
+ map: texture,
522
+ transparent: true,
523
+ color: new THREE3.Color(intensity, intensity, intensity),
524
+ depthWrite: false,
525
+ fog: false
526
+ }
527
+ )
528
+ ] });
529
+ }
530
+ function Surround({ radius, sky }) {
531
+ const { zenith, horizon, ground } = sky;
532
+ const texture = useMemo3(() => makeSkyTexture({ zenith, horizon, ground }), [zenith, horizon, ground]);
533
+ useEffect3(() => () => texture.dispose(), [texture]);
534
+ return /* @__PURE__ */ jsxs2("mesh", { children: [
535
+ /* @__PURE__ */ jsx3("sphereGeometry", { args: [radius, 32, 24] }),
536
+ /* @__PURE__ */ jsx3("meshBasicMaterial", { map: texture, side: THREE3.BackSide, fog: false })
537
+ ] });
538
+ }
539
+
540
+ // src/stage/Room.tsx
541
+ import * as THREE4 from "three";
542
+ import { useEffect as useEffect4, useMemo as useMemo4, useRef as useRef2 } from "react";
543
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
544
+ function makeFloorTexture(color, repeats) {
545
+ const size = 256;
546
+ const canvas = document.createElement("canvas");
547
+ canvas.width = canvas.height = size;
548
+ const ctx = canvas.getContext("2d");
549
+ ctx.fillStyle = color;
550
+ ctx.fillRect(0, 0, size, size);
551
+ const base = new THREE4.Color(color);
552
+ const dark = base.clone().multiplyScalar(0.55);
553
+ const softer = base.clone().multiplyScalar(0.78);
554
+ const css = (c) => `rgb(${c.r * 255 | 0}, ${c.g * 255 | 0}, ${c.b * 255 | 0})`;
555
+ ctx.strokeStyle = css(softer);
556
+ ctx.lineWidth = 5;
557
+ ctx.strokeRect(0, 0, size, size);
558
+ ctx.strokeStyle = css(dark);
559
+ ctx.lineWidth = 1.5;
560
+ ctx.strokeRect(0, 0, size, size);
561
+ const texture = new THREE4.CanvasTexture(canvas);
562
+ texture.colorSpace = THREE4.SRGBColorSpace;
563
+ texture.wrapS = texture.wrapT = THREE4.RepeatWrapping;
564
+ texture.repeat.set(repeats, repeats);
565
+ texture.anisotropy = 8;
566
+ return texture;
567
+ }
568
+ function Floor({
569
+ size,
570
+ color,
571
+ slab
572
+ }) {
573
+ const repeats = slab > 0 ? Math.max(1, Math.round(size / slab)) : 0;
574
+ const texture = useMemo4(() => repeats > 0 ? makeFloorTexture(color, repeats) : null, [color, repeats]);
575
+ useEffect4(() => () => texture?.dispose(), [texture]);
576
+ return /* @__PURE__ */ jsxs3("mesh", { rotation: [-Math.PI / 2, 0, 0], receiveShadow: true, children: [
577
+ /* @__PURE__ */ jsx4("planeGeometry", { args: [size, size] }),
578
+ /* @__PURE__ */ jsx4("meshStandardMaterial", { map: texture, color: texture ? "#ffffff" : color, roughness: 1 })
579
+ ] });
580
+ }
581
+ function Ceiling({ size, height, color }) {
582
+ return /* @__PURE__ */ jsxs3("mesh", { position: [0, height, 0], rotation: [Math.PI / 2, 0, 0], receiveShadow: true, children: [
583
+ /* @__PURE__ */ jsx4("planeGeometry", { args: [size, size] }),
584
+ /* @__PURE__ */ jsx4("meshStandardMaterial", { color, roughness: 1, side: THREE4.FrontSide })
585
+ ] });
586
+ }
587
+ function Columns({
588
+ path,
589
+ ceiling,
590
+ spacing,
591
+ width,
592
+ offset,
593
+ color
594
+ }) {
595
+ const placements = useMemo4(() => {
596
+ if (!(spacing > 0) || !(path.length > 0)) return [];
597
+ const bays = Math.max(1, Math.round(path.length / spacing));
598
+ const out = [];
599
+ for (let i = 0; i <= bays; i++) {
600
+ const s = i / bays;
601
+ const [px, pz] = path.pointAt(s);
602
+ const [nx, nz] = path.normalAt(s);
603
+ const [tx, tz] = path.tangentAt(s);
604
+ const yaw = Math.atan2(tx, tz);
605
+ for (const side of [-1, 1]) {
606
+ out.push({ position: [px + nx * side * offset, 0, pz + nz * side * offset], yaw });
607
+ }
608
+ }
609
+ return out;
610
+ }, [path, spacing, offset]);
611
+ const shaft = useRef2(null);
612
+ const base = useRef2(null);
613
+ const capital = useRef2(null);
614
+ const plate = width * 1.45;
615
+ const plateHeight = width * 0.24;
616
+ useEffect4(() => {
617
+ const m = new THREE4.Matrix4();
618
+ const q = new THREE4.Quaternion();
619
+ const scale = new THREE4.Vector3(1, 1, 1);
620
+ const put = (mesh, y) => {
621
+ if (!mesh) return;
622
+ placements.forEach((p, i) => {
623
+ q.setFromEuler(new THREE4.Euler(0, p.yaw, 0));
624
+ m.compose(new THREE4.Vector3(p.position[0], y, p.position[2]), q, scale);
625
+ mesh.setMatrixAt(i, m);
626
+ });
627
+ mesh.instanceMatrix.needsUpdate = true;
628
+ mesh.count = placements.length;
629
+ };
630
+ put(shaft.current, ceiling / 2);
631
+ put(base.current, plateHeight / 2);
632
+ put(capital.current, ceiling - plateHeight / 2);
633
+ }, [placements, ceiling, plateHeight]);
634
+ if (placements.length === 0) return null;
635
+ const n = placements.length;
636
+ return /* @__PURE__ */ jsxs3("group", { children: [
637
+ /* @__PURE__ */ jsxs3("instancedMesh", { ref: shaft, args: [void 0, void 0, n], castShadow: true, receiveShadow: true, children: [
638
+ /* @__PURE__ */ jsx4("boxGeometry", { args: [width, ceiling, width] }),
639
+ /* @__PURE__ */ jsx4("meshStandardMaterial", { color, roughness: 0.92 })
640
+ ] }),
641
+ /* @__PURE__ */ jsxs3("instancedMesh", { ref: base, args: [void 0, void 0, n], castShadow: true, receiveShadow: true, children: [
642
+ /* @__PURE__ */ jsx4("boxGeometry", { args: [plate, plateHeight, plate] }),
643
+ /* @__PURE__ */ jsx4("meshStandardMaterial", { color, roughness: 0.92 })
644
+ ] }),
645
+ /* @__PURE__ */ jsxs3("instancedMesh", { ref: capital, args: [void 0, void 0, n], castShadow: true, receiveShadow: true, children: [
646
+ /* @__PURE__ */ jsx4("boxGeometry", { args: [plate, plateHeight, plate] }),
647
+ /* @__PURE__ */ jsx4("meshStandardMaterial", { color, roughness: 0.92 })
648
+ ] })
649
+ ] });
650
+ }
651
+ var NUDGE = 0.08;
652
+ function Doorway({
653
+ position,
654
+ yaw,
655
+ size,
656
+ opening,
657
+ color,
658
+ extent
659
+ }) {
660
+ const geometry = useMemo4(() => {
661
+ const w = size * 2.4 * opening;
662
+ const h = size * 1.8 * opening;
663
+ const shape = new THREE4.Shape();
664
+ shape.moveTo(-extent, -extent);
665
+ shape.lineTo(extent, -extent);
666
+ shape.lineTo(extent, extent);
667
+ shape.lineTo(-extent, extent);
668
+ shape.closePath();
669
+ const hole = new THREE4.Path();
670
+ hole.moveTo(-w / 2, -h / 2);
671
+ hole.lineTo(w / 2, -h / 2);
672
+ hole.lineTo(w / 2, h / 2);
673
+ hole.lineTo(-w / 2, h / 2);
674
+ hole.closePath();
675
+ shape.holes.push(hole);
676
+ return new THREE4.ShapeGeometry(shape);
677
+ }, [size, opening, extent]);
678
+ useEffect4(() => () => geometry.dispose(), [geometry]);
679
+ const stood = useMemo4(
680
+ () => [position[0] + Math.sin(yaw) * NUDGE, position[1], position[2] + Math.cos(yaw) * NUDGE],
681
+ [position, yaw]
682
+ );
683
+ return /* @__PURE__ */ jsx4("mesh", { geometry, position: stood, rotation: [0, yaw, 0], receiveShadow: true, children: /* @__PURE__ */ jsx4("meshStandardMaterial", { color, roughness: 1, side: THREE4.DoubleSide }) });
684
+ }
685
+
686
+ // src/stage/Suspension.tsx
687
+ import * as THREE5 from "three";
688
+ import { useEffect as useEffect5, useMemo as useMemo5, useRef as useRef3 } from "react";
689
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
690
+ var euler = (pose) => new THREE5.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
691
+ function rodLength(sheet, pose) {
692
+ return sheet.width * pose.scale * 1.22;
693
+ }
694
+ function topOfSheet(pose, paperHeight) {
695
+ const half = paperHeight * pose.scale / 2;
696
+ const up = new THREE5.Vector3(0, half, 0).applyEuler(
697
+ new THREE5.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2])
698
+ );
699
+ return new THREE5.Vector3(...pose.position).add(up);
700
+ }
701
+ function Suspension({
702
+ layout,
703
+ layoutOptions,
704
+ count,
705
+ sheet,
706
+ paperHeight,
707
+ ceiling,
708
+ color,
709
+ type,
710
+ hardware
711
+ }) {
712
+ const poses = useMemo5(() => {
713
+ const entry = getLayout(layout);
714
+ if (!entry) return [];
715
+ const options = entry.optionsSchema.parse(layoutOptions);
716
+ return Array.from({ length: count }, (_, i) => entry.pose(i, count, options, 0, sheet));
717
+ }, [layout, layoutOptions, count, sheet]);
718
+ const geometry = useMemo5(() => {
719
+ const points = [];
720
+ const end = new THREE5.Vector3();
721
+ for (const pose of poses) {
722
+ const top = topOfSheet(pose, paperHeight);
723
+ if (top.y >= ceiling) continue;
724
+ if (type === "rod") {
725
+ const half = rodLength(sheet, pose) / 2;
726
+ for (const side of [-1, 1]) {
727
+ end.set(side * half, 0, 0).applyEuler(euler(pose)).add(top);
728
+ points.push(end.x, ceiling, end.z, end.x, end.y, end.z);
729
+ }
730
+ } else {
731
+ points.push(top.x, ceiling, top.z, top.x, top.y, top.z);
732
+ }
733
+ }
734
+ const g = new THREE5.BufferGeometry();
735
+ g.setAttribute("position", new THREE5.Float32BufferAttribute(points, 3));
736
+ return g;
737
+ }, [poses, paperHeight, ceiling, type, sheet]);
738
+ useEffect5(() => () => geometry.dispose(), [geometry]);
739
+ const clipRef = useRef3(null);
740
+ const rodRef = useRef3(null);
741
+ const rodGeometry = useMemo5(() => {
742
+ const r = sheet.width * 0.018;
743
+ const g = new THREE5.CylinderGeometry(r, r, sheet.width * 1.22, 8);
744
+ g.rotateZ(Math.PI / 2);
745
+ return g;
746
+ }, [sheet.width]);
747
+ useEffect5(() => () => rodGeometry.dispose(), [rodGeometry]);
748
+ useEffect5(() => {
749
+ const m = new THREE5.Matrix4();
750
+ const q = new THREE5.Quaternion();
751
+ const at = new THREE5.Vector3();
752
+ const size = new THREE5.Vector3();
753
+ const place = (mesh, lift) => {
754
+ if (!mesh) return;
755
+ poses.forEach((pose, i) => {
756
+ const top = topOfSheet(pose, paperHeight);
757
+ q.setFromEuler(euler(pose));
758
+ at.set(top.x, top.y + lift, top.z);
759
+ size.setScalar(pose.scale);
760
+ m.compose(at, q, size);
761
+ mesh.setMatrixAt(i, m);
762
+ });
763
+ mesh.instanceMatrix.needsUpdate = true;
764
+ mesh.count = poses.length;
765
+ };
766
+ place(clipRef.current, 0);
767
+ place(rodRef.current, sheet.height * 4e-3);
768
+ }, [poses, paperHeight, sheet.height]);
769
+ if (poses.length === 0) return null;
770
+ return /* @__PURE__ */ jsxs4("group", { children: [
771
+ /* @__PURE__ */ jsx5("lineSegments", { geometry, children: /* @__PURE__ */ jsx5("lineBasicMaterial", { color, transparent: true, opacity: 0.42 }) }),
772
+ type === "rod" && /* @__PURE__ */ jsxs4("instancedMesh", { ref: rodRef, args: [void 0, void 0, Math.max(poses.length, 1)], castShadow: true, children: [
773
+ /* @__PURE__ */ jsx5("primitive", { object: rodGeometry, attach: "geometry" }),
774
+ /* @__PURE__ */ jsx5("meshStandardMaterial", { color, roughness: 0.7, metalness: 0.15 })
775
+ ] }),
776
+ hardware !== "none" && /* @__PURE__ */ jsxs4("instancedMesh", { ref: clipRef, args: [void 0, void 0, Math.max(poses.length, 1)], castShadow: true, children: [
777
+ hardware === "peg" ? /* @__PURE__ */ jsx5("boxGeometry", { args: [sheet.width * 0.055, sheet.height * 0.038, sheet.width * 0.05] }) : /* @__PURE__ */ jsx5("boxGeometry", { args: [sheet.width * 0.13, sheet.height * 0.016, sheet.width * 0.05] }),
778
+ /* @__PURE__ */ jsx5(
779
+ "meshStandardMaterial",
780
+ {
781
+ color,
782
+ roughness: hardware === "peg" ? 0.85 : 0.45,
783
+ metalness: hardware === "peg" ? 0 : 0.6
784
+ }
785
+ )
786
+ ] })
787
+ ] });
788
+ }
789
+
790
+ // src/stage/Grade.tsx
791
+ import {
792
+ EffectComposer,
793
+ Bloom,
794
+ DepthOfField,
795
+ Vignette,
796
+ Noise,
797
+ ToneMapping
798
+ } from "@react-three/postprocessing";
799
+ import { BlendFunction, ToneMappingMode } from "postprocessing";
800
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
801
+ var toneMappingModes = {
802
+ agx: ToneMappingMode.AGX,
803
+ neutral: ToneMappingMode.NEUTRAL,
804
+ filmic: ToneMappingMode.ACES_FILMIC
805
+ };
806
+ function Grade({ grade, film }) {
807
+ const bloom = grade.bloom > 0;
808
+ const depth = grade.depth > 0;
809
+ const vignette = grade.vignette > 0;
810
+ const grain = grade.grain > 0;
811
+ if (!bloom && !depth && !vignette && !grain) return null;
812
+ return /* @__PURE__ */ jsxs5(EffectComposer, { children: [
813
+ bloom ? /* @__PURE__ */ jsx6(
814
+ Bloom,
815
+ {
816
+ intensity: grade.bloom,
817
+ luminanceThreshold: grade.threshold,
818
+ luminanceSmoothing: 0.22,
819
+ mipmapBlur: true
820
+ }
821
+ ) : null,
822
+ depth ? /* @__PURE__ */ jsx6(
823
+ DepthOfField,
824
+ {
825
+ focusDistance: 8e-3,
826
+ focalLength: 0.02 + grade.depth * 0.04,
827
+ bokehScale: grade.depth * 2.5
828
+ }
829
+ ) : null,
830
+ /* @__PURE__ */ jsx6(ToneMapping, { mode: toneMappingModes[film] }),
831
+ vignette ? /* @__PURE__ */ jsx6(Vignette, { offset: 0.32, darkness: grade.vignette }) : null,
832
+ grain ? /* @__PURE__ */ jsx6(Noise, { opacity: grade.grain, blendFunction: BlendFunction.OVERLAY }) : null
833
+ ] });
834
+ }
835
+
836
+ // src/stage/schema.ts
837
+ import { z as z3 } from "zod";
838
+ var stageSourceSchema = z3.object({
839
+ /** The bright void the walk resolves toward. Without it the vanishing point is a hole. */
840
+ enabled: z3.boolean().default(true),
841
+ color: z3.string().default("#fff4e2"),
842
+ /** How far past the end of the walk it stands, world units. */
843
+ beyond: z3.number().min(0).max(80).default(10),
844
+ /**
845
+ * A cyclorama around the whole stage, graded from the source colour at the
846
+ * horizon to near-dark overhead. The source plane only faces down the walk,
847
+ * so without this every shot that isn't axial — `wide` especially — looks
848
+ * out at a black void where the room should be.
849
+ */
850
+ surround: z3.boolean().default(true),
851
+ /** Colour overhead. The horizon takes the source's own colour. */
852
+ zenith: z3.string().default("#241c17"),
853
+ /**
854
+ * Size, as a multiple of the PAPER height.
855
+ *
856
+ * It is an OPENING, not a wall. At 5 the plane was 100 units across and
857
+ * filled the entire frame behind the colonnade, so the hall had no dark
858
+ * end to resolve toward and the whole picture sat at one value. Sized to
859
+ * roughly the height of the paper it stands behind, it reads as the way
860
+ * out — which is what the figure is walking toward.
861
+ */
862
+ spread: z3.number().min(0.2).max(60).default(2)
863
+ });
864
+ var stageGroundSchema = z3.object({
865
+ /** The floor. Without something to catch the shadows there is no ground and no scale. */
866
+ enabled: z3.boolean().default(true),
867
+ /**
868
+ * Lifted off near-black (`#0e0b09`). A floor dark enough to disappear
869
+ * cannot show its own seams, and the seams are the scale cue — the hall
870
+ * kept its contrast against the source and gained a surface you can read
871
+ * the size of the room from.
872
+ */
873
+ color: z3.string().default("#241e19"),
874
+ /**
875
+ * Width of one poured slab, in world units. 0 leaves the floor unseamed.
876
+ *
877
+ * The cheapest scale cue there is, and the one this scene most lacked. A
878
+ * concrete floor is poured in bays of roughly two and a half metres, and a
879
+ * viewer knows that without being told — so a floor with seams in it
880
+ * states the size of the room, while a floor without them is a gradient
881
+ * that happens to be horizontal.
882
+ */
883
+ slab: z3.number().min(0).max(20).default(2.4)
884
+ });
885
+ var stageSuspensionSchema = z3.object({
886
+ /**
887
+ * What carries the load.
888
+ *
889
+ * `thread` is monofilament to the ceiling — one straight line per sheet.
890
+ * `rod` is a dowel across each sheet's top edge, hung from the ceiling at
891
+ * both ends, which is a different image entirely: a rank on threads reads
892
+ * as sheets floating in a row, and a rank on rods reads as sheets that
893
+ * were HUNG, by someone, on something. `none` is for a stage where the
894
+ * paper is meant to be impossible.
895
+ */
896
+ type: z3.enum(["none", "thread", "rod"]).default("thread"),
897
+ color: z3.string().default("#9c948a"),
898
+ /**
899
+ * What grips the sheet.
900
+ *
901
+ * A clip is wide and shallow — the bulldog clip of a gallery. A peg is
902
+ * narrow and deep, and grips DOWN the face of the sheet rather than
903
+ * across its edge: the domestic one, a line of paper on a washing line.
904
+ * They are told apart by silhouette at any distance, which is the only
905
+ * thing that survives being one instanced box at the top of an
906
+ * eight-metre banner.
907
+ *
908
+ * This replaced a `clips: boolean`. Two of the four pieces of hardware the
909
+ * plan named — pegs, and a rod — had no way to be asked for, and a boolean
910
+ * cannot grow a third answer.
911
+ */
912
+ hardware: z3.enum(["none", "clip", "peg"]).default("clip")
913
+ });
914
+ var stageColumnsSchema = z3.object({
915
+ enabled: z3.boolean().default(false),
916
+ /** Centres this far apart along the walk. Roughly a bay. */
917
+ spacing: z3.number().min(1).max(24).default(7),
918
+ /** Shaft width. The number doing the work — a column is a known size. */
919
+ width: z3.number().min(0.1).max(3).default(0.44),
920
+ /**
921
+ * How far off the walk's centreline each rank stands.
922
+ *
923
+ * Outside the banners, always. Columns are the room; the paper is the
924
+ * subject, and a column standing between the viewer and a banner has
925
+ * swapped the two over. Default clears a `colonnade`'s widest sensible
926
+ * aisle with room to spare.
927
+ */
928
+ offset: z3.number().min(0.5).max(24).default(6.6),
929
+ /**
930
+ * Stone, and darker than paper on purpose.
931
+ *
932
+ * The brightest thing in any of these frames has to be the light, and the
933
+ * second brightest has to be the paper. A column the same value as a
934
+ * banner does not read as architecture behind the subject; it reads as
935
+ * more banners, and the eye stops being able to tell what the room is made
936
+ * of from what is hanging in it.
937
+ */
938
+ color: z3.string().default("#5c554d")
939
+ });
940
+ var stageDoorwaySchema = z3.object({
941
+ enabled: z3.boolean().default(false),
942
+ /** Opening size, as a multiple of the source's own. 1 frames it exactly. */
943
+ opening: z3.number().min(0.2).max(3).default(1.05),
944
+ color: z3.string().default("#171310")
945
+ });
946
+ var stageRoomSchema = z3.object({
947
+ enabled: z3.boolean().default(true),
948
+ /**
949
+ * Ceiling height, as a multiple of the paper's own height.
950
+ *
951
+ * Relative rather than absolute because the banners ARE the architecture
952
+ * here: a hall whose ceiling sits just above its hangings reads as built
953
+ * for them, and one at a fixed world height reads as whatever the paper
954
+ * happened to be scaled to that day.
955
+ */
956
+ height: z3.number().min(1).max(6).default(2.2),
957
+ color: z3.string().default("#171310"),
958
+ /** Columns flanking the walk — see `stageColumnsSchema`. */
959
+ columns: stageColumnsSchema.default({}),
960
+ /** A wall at the end of the walk with the source in it. */
961
+ doorway: stageDoorwaySchema.default({})
962
+ });
963
+ var stageGradeSchema = z3.object({
964
+ /**
965
+ * How far light bleeds past what is emitting it.
966
+ *
967
+ * This is the one that matters most in a backlit hall, because the source
968
+ * plane is drawn with `toneMapped: false` — it is light, not an object, so
969
+ * no tone curve ever rolls it off. Bloom is the only thing that gives it
970
+ * an edge that behaves like light instead of like a lit rectangle.
971
+ */
972
+ bloom: z3.number().min(0).max(3).default(0.45),
973
+ /**
974
+ * How bright a pixel has to be before it blooms at all, in LINEAR light.
975
+ *
976
+ * Above 1.0 is not only legal, it is the useful range — and that is the
977
+ * whole reason the bound is 4 rather than 1. Bloom reads the scene before
978
+ * the tone curve, while values are still unbounded, so "1.0" means "as
979
+ * bright as white" rather than "as bright as the brightest pixel on
980
+ * screen". Lit near-white paper sits close to 1.0 all by itself; the
981
+ * source burns at `SOURCE_INTENSITY`, several times that. A threshold
982
+ * under 1 therefore blooms the PAPER, which fogs the hall and costs the
983
+ * sheets their edges — the exact failure this default is set to avoid.
984
+ */
985
+ threshold: z3.number().min(0).max(4).default(1.6),
986
+ /**
987
+ * Depth falloff — how much the near and far ends of the walk go soft.
988
+ *
989
+ * **Defaults to 0, and that is a considered default rather than a stub.**
990
+ * Depth in this scene is already staged by haze, which is how a real hall
991
+ * does it and which costs one fragment instruction; optical blur is a
992
+ * second full-screen pass with a circle-of-confusion buffer behind it, and
993
+ * it is the effect most likely to read as a video game rather than as a
994
+ * photograph. Every paper installation worth copying is shot deep — an
995
+ * f/11 room where the sheets at the far end are as sharp as the ones you
996
+ * can touch.
997
+ *
998
+ * It is here because a shallow frame is a legitimate look and the schema
999
+ * is the only place a look is allowed to live. Turn it up for a close shot
1000
+ * on one banner; leave it alone for a hall.
1001
+ */
1002
+ depth: z3.number().min(0).max(1).default(0),
1003
+ /** How far the corners fall off. A frame with no edge reads as a viewport rather than a photograph. */
1004
+ vignette: z3.number().min(0).max(1).default(0.34),
1005
+ /**
1006
+ * Film grain.
1007
+ *
1008
+ * Worth more here than in most scenes: grain is the one texture shared
1009
+ * between the render and the thing being rendered. Keep it under ~0.05 —
1010
+ * past that it stops reading as stock and starts reading as noise.
1011
+ */
1012
+ grain: z3.number().min(0).max(0.5).default(0.022)
1013
+ });
1014
+ var stageSchema = z3.object({
1015
+ path: walkPathSchema.default({}),
1016
+ shot: shotSchema.default({}),
1017
+ figure: figureSchema.default({}),
1018
+ /** Stage mode is built for `nave`; the others are all front-lit. */
1019
+ lighting: z3.enum(lightingNames).default("nave"),
1020
+ /**
1021
+ * The light, by hand: exposure, key, direction, height, ambient, studio,
1022
+ * haze. Overrides on `lighting` rather than a replacement for it, so a
1023
+ * shared stage carries the sliders that were moved and nothing else.
1024
+ */
1025
+ light: lightSchema.default({}),
1026
+ /**
1027
+ * OFF by default now.
1028
+ *
1029
+ * The figure existed to say "this is a room at gallery scale", which is a
1030
+ * real job and the right instinct. A rendered human is simply the most
1031
+ * expensive and least reliable way to do it: it is the one thing in frame
1032
+ * every viewer appraises, and a low-polygon one reads as an asset-store
1033
+ * placeholder no matter how good the hall around it is.
1034
+ *
1035
+ * `stageRoomSchema` does the job instead, with objects whose size the
1036
+ * viewer already knows. And the deciding argument is that the stage is
1037
+ * NAVIGABLE — drag, wheel, arrow-step, click-to-approach — so there is
1038
+ * already a person in the hall and it is the viewer. A second one walking
1039
+ * the same aisle on its own clock competes for that role.
1040
+ *
1041
+ * Still one flag away for anyone who wants it.
1042
+ */
1043
+ showFigure: z3.boolean().default(false),
1044
+ source: stageSourceSchema.default({}),
1045
+ ground: stageGroundSchema.default({}),
1046
+ /** Ceiling and the architecture around the walk — see `stageRoomSchema`. */
1047
+ room: stageRoomSchema.default({}),
1048
+ /** Thread and clips — see `stageSuspensionSchema`. */
1049
+ suspension: stageSuspensionSchema.default({}),
1050
+ /**
1051
+ * The print — bloom, vignette, grain.
1052
+ *
1053
+ * Needs `@react-three/postprocessing` and `postprocessing`. They are
1054
+ * declared OPTIONAL peers, which means `<Paper>` never pulls them in and a
1055
+ * bundle that only imports `<Paper>` never contains them — not that a
1056
+ * stage renders without them. A bundler asked to resolve `<PaperStage>`
1057
+ * without them installed fails at build time, and that is the intended
1058
+ * behaviour: a stage silently losing its grade would be worse than a
1059
+ * missing-module error that names the package.
1060
+ */
1061
+ grade: stageGradeSchema.default({})
1062
+ });
1063
+
1064
+ // src/stage/navigate.ts
1065
+ import { z as z4 } from "zod";
1066
+ var stageMotionSchema = z4.object({
1067
+ /**
1068
+ * Who drives the walk. Same three names as a field's, and they mean the
1069
+ * same things — a stage and a field are the same contract seen from two
1070
+ * distances.
1071
+ *
1072
+ * - `drag` — the viewer. Pointer, wheel, arrow keys, or a click on a paper.
1073
+ * It DRIFTS on the clock until the first time they touch it, and then it
1074
+ * is theirs for good. That is one behaviour rather than two drivers, and
1075
+ * it is the default because the alternatives are each half wrong: a stage
1076
+ * that only autoplays cannot be touched, and one that only waits opens as
1077
+ * a still photograph of itself.
1078
+ * - `autoplay` — the clock, and only the clock. It never hands over.
1079
+ * - `none` — nothing. The walk stands wherever it was left.
1080
+ *
1081
+ * An explicit `progress` prop outranks all three: a stage bound to page
1082
+ * scroll is a controlled component, and a driver fighting the page for the
1083
+ * same number is the bug you would spend an afternoon on.
1084
+ */
1085
+ driver: z4.enum(["autoplay", "drag", "none"]).default("drag"),
1086
+ /** Multiplier on the pace: the figure's walking speed for `autoplay`, the hand for `drag`. */
1087
+ speed: z4.number().min(0).max(6).default(1),
1088
+ /**
1089
+ * Whether the walk takes the WHEEL and the TOUCH away from the page.
1090
+ *
1091
+ * True for a stage that fills the screen — it is the page, so there is
1092
+ * nothing to take it from. False for one sitting in a column of prose,
1093
+ * where capturing them means a reader who scrolls past it has their scroll
1094
+ * eaten and a reader on a phone has their finger trapped. Dragging with a
1095
+ * mouse and stepping with the arrow keys work either way, because neither
1096
+ * is a gesture the page also wants.
1097
+ *
1098
+ * Even when captured, the wheel is handed BACK at the ends of an open
1099
+ * walk: scrolling past the last banner should carry on down the page
1100
+ * rather than press silently into a wall.
1101
+ */
1102
+ capture: z4.boolean().default(true)
1103
+ });
1104
+ var WALK_PER_PIXEL = 0.2 / 800;
1105
+ var WALK_PER_WHEEL = 0.2 / 1400;
1106
+ var COAST_TAU = 0.32;
1107
+ var COAST_FLOOR = 15e-4;
1108
+ function dragWalk(dy, speed) {
1109
+ return -dy * WALK_PER_PIXEL * speed;
1110
+ }
1111
+ function wheelWalk(deltaY, speed) {
1112
+ return deltaY * WALK_PER_WHEEL * speed;
1113
+ }
1114
+ function coast(velocity, dt) {
1115
+ const next = velocity * Math.exp(-dt / COAST_TAU);
1116
+ return Math.abs(next) < COAST_FLOOR ? 0 : next;
1117
+ }
1118
+ function holdOnWalk(walk, closed) {
1119
+ if (!closed) return Math.min(1, Math.max(0, walk));
1120
+ return (walk % 1 + 1) % 1;
1121
+ }
1122
+ function nextStop(stops, from, direction, closed = false) {
1123
+ if (stops.length === 0) return from;
1124
+ const sorted = [...stops].sort((a, b) => a - b);
1125
+ const EPS = 1e-4;
1126
+ const found = direction > 0 ? sorted.find((s) => s > from + EPS) : [...sorted].reverse().find((s) => s < from - EPS);
1127
+ if (found !== void 0) return found;
1128
+ if (closed) return direction > 0 ? sorted[0] : sorted[sorted.length - 1];
1129
+ return direction > 0 ? sorted[sorted.length - 1] : sorted[0];
1130
+ }
1131
+ var TRAVEL_SECONDS = 0.75;
1132
+ function travelEase(t) {
1133
+ const x = Math.min(1, Math.max(0, t));
1134
+ return x * x * x * (x * (x * 6 - 15) + 10);
1135
+ }
1136
+ function travelBetween(from, to, t, closed) {
1137
+ let delta = to - from;
1138
+ if (closed) {
1139
+ if (delta > 0.5) delta -= 1;
1140
+ if (delta < -0.5) delta += 1;
1141
+ }
1142
+ return holdOnWalk(from + delta * t, closed);
1143
+ }
1144
+
1145
+ // src/stage/useWalk.ts
1146
+ import { useCallback, useEffect as useEffect6, useMemo as useMemo6, useRef as useRef4 } from "react";
1147
+ import { useFrame as useFrame3, useThree } from "@react-three/fiber";
1148
+ function useWalk({
1149
+ path,
1150
+ motion,
1151
+ progress,
1152
+ figureSpeed,
1153
+ stops,
1154
+ reduced,
1155
+ onProgress
1156
+ }) {
1157
+ const gl = useThree((s) => s.gl);
1158
+ const walk = useRef4(progress ?? 0);
1159
+ const velocity = useRef4(0);
1160
+ const dragged = useRef4(false);
1161
+ const travel = useRef4(null);
1162
+ const controlled = progress !== void 0;
1163
+ const interactive = !controlled && motion.driver === "drag";
1164
+ const engaged = useRef4(false);
1165
+ const travelTo = useCallback(
1166
+ (target) => {
1167
+ engaged.current = true;
1168
+ velocity.current = 0;
1169
+ const to = holdOnWalk(target, path.closed);
1170
+ if (reduced) {
1171
+ walk.current = to;
1172
+ travel.current = null;
1173
+ return;
1174
+ }
1175
+ travel.current = { from: walk.current, to, t: 0 };
1176
+ },
1177
+ [path.closed, reduced]
1178
+ );
1179
+ const step = useCallback(
1180
+ (direction) => {
1181
+ const from = travel.current?.to ?? walk.current;
1182
+ travelTo(nextStop(stops, from, direction, path.closed));
1183
+ },
1184
+ [stops, path.closed, travelTo]
1185
+ );
1186
+ useEffect6(() => {
1187
+ if (controlled) {
1188
+ walk.current = progress;
1189
+ travel.current = null;
1190
+ velocity.current = 0;
1191
+ }
1192
+ }, [controlled, progress]);
1193
+ const canvas = gl.domElement;
1194
+ useEffect6(() => {
1195
+ if (!interactive) return;
1196
+ const hadTabIndex = canvas.hasAttribute("tabindex");
1197
+ if (!hadTabIndex) canvas.tabIndex = 0;
1198
+ const hadRole = canvas.getAttribute("role");
1199
+ const hadLabel = canvas.getAttribute("aria-label");
1200
+ if (!hadRole) canvas.setAttribute("role", "application");
1201
+ if (!hadLabel) {
1202
+ canvas.setAttribute(
1203
+ "aria-label",
1204
+ "A walk through hanging paper. Drag or use the arrow keys to move along it."
1205
+ );
1206
+ }
1207
+ const hadTouch = canvas.style.touchAction;
1208
+ if (motion.capture) canvas.style.touchAction = "none";
1209
+ let pointer = null;
1210
+ let lastY = 0;
1211
+ let lastAt = 0;
1212
+ let startY = 0;
1213
+ const SLOP = 5;
1214
+ const down = (event) => {
1215
+ if (!event.isPrimary) return;
1216
+ engaged.current = true;
1217
+ pointer = event.pointerId;
1218
+ dragged.current = false;
1219
+ startY = event.clientY;
1220
+ lastY = event.clientY;
1221
+ lastAt = event.timeStamp;
1222
+ velocity.current = 0;
1223
+ travel.current = null;
1224
+ canvas.setPointerCapture(event.pointerId);
1225
+ canvas.style.cursor = "grabbing";
1226
+ };
1227
+ const move = (event) => {
1228
+ if (pointer !== event.pointerId) return;
1229
+ const dy = event.clientY - lastY;
1230
+ if (Math.abs(event.clientY - startY) > SLOP) dragged.current = true;
1231
+ const dt = Math.max((event.timeStamp - lastAt) / 1e3, 1 / 240);
1232
+ const moved = dragWalk(dy, motion.speed);
1233
+ walk.current = holdOnWalk(walk.current + moved, path.closed);
1234
+ velocity.current = moved / dt;
1235
+ lastY = event.clientY;
1236
+ lastAt = event.timeStamp;
1237
+ };
1238
+ const up = (event) => {
1239
+ if (pointer !== event.pointerId) return;
1240
+ pointer = null;
1241
+ canvas.style.cursor = "grab";
1242
+ if (event.timeStamp - lastAt > 90) velocity.current = 0;
1243
+ };
1244
+ const wheel = (event) => {
1245
+ const lines = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1;
1246
+ const moved = wheelWalk(event.deltaY * lines, motion.speed);
1247
+ if (!path.closed && (walk.current >= 1 && moved > 0 || walk.current <= 0 && moved < 0)) return;
1248
+ engaged.current = true;
1249
+ travel.current = null;
1250
+ velocity.current = 0;
1251
+ walk.current = holdOnWalk(walk.current + moved, path.closed);
1252
+ event.preventDefault();
1253
+ };
1254
+ const key = (event) => {
1255
+ const forward = event.key === "ArrowRight" || event.key === "ArrowDown" || event.key === "PageDown";
1256
+ const back = event.key === "ArrowLeft" || event.key === "ArrowUp" || event.key === "PageUp";
1257
+ if (forward || back) step(forward ? 1 : -1);
1258
+ else if (event.key === "Home") travelTo(stops[0] ?? 0);
1259
+ else if (event.key === "End") travelTo(stops[stops.length - 1] ?? 1);
1260
+ else return;
1261
+ engaged.current = true;
1262
+ event.preventDefault();
1263
+ };
1264
+ canvas.style.cursor = "grab";
1265
+ canvas.addEventListener("pointerdown", down);
1266
+ canvas.addEventListener("pointermove", move);
1267
+ canvas.addEventListener("pointerup", up);
1268
+ canvas.addEventListener("pointercancel", up);
1269
+ if (motion.capture) canvas.addEventListener("wheel", wheel, { passive: false });
1270
+ canvas.addEventListener("keydown", key);
1271
+ return () => {
1272
+ canvas.removeEventListener("pointerdown", down);
1273
+ canvas.removeEventListener("pointermove", move);
1274
+ canvas.removeEventListener("pointerup", up);
1275
+ canvas.removeEventListener("pointercancel", up);
1276
+ canvas.removeEventListener("wheel", wheel);
1277
+ canvas.removeEventListener("keydown", key);
1278
+ canvas.style.cursor = "";
1279
+ canvas.style.touchAction = hadTouch;
1280
+ if (!hadTabIndex) canvas.removeAttribute("tabindex");
1281
+ if (!hadRole) canvas.removeAttribute("role");
1282
+ if (!hadLabel) canvas.removeAttribute("aria-label");
1283
+ };
1284
+ }, [canvas, interactive, motion.speed, motion.capture, path.closed, step, travelTo, stops]);
1285
+ const reported = useRef4(-1);
1286
+ useFrame3((_, delta) => {
1287
+ if (onProgress && Math.abs(walk.current - reported.current) > 1e-4) {
1288
+ reported.current = walk.current;
1289
+ onProgress(walk.current);
1290
+ }
1291
+ if (controlled) return;
1292
+ const dt = Math.min(delta, 0.1);
1293
+ if (travel.current) {
1294
+ travel.current.t += dt / TRAVEL_SECONDS;
1295
+ const { from, to, t } = travel.current;
1296
+ walk.current = travelBetween(from, to, travelEase(t), path.closed);
1297
+ if (t >= 1) travel.current = null;
1298
+ return;
1299
+ }
1300
+ const drifting = motion.driver === "autoplay" || interactive && !engaged.current;
1301
+ if (drifting && !reduced) {
1302
+ const perSecond = path.length > 0 ? figureSpeed * motion.speed / path.length : 0;
1303
+ walk.current = ((walk.current + perSecond * dt) % 1 + 1) % 1;
1304
+ return;
1305
+ }
1306
+ if (velocity.current !== 0) {
1307
+ walk.current = holdOnWalk(walk.current + velocity.current * dt, path.closed);
1308
+ velocity.current = coast(velocity.current, dt);
1309
+ if (!path.closed && (walk.current <= 0 || walk.current >= 1)) velocity.current = 0;
1310
+ }
1311
+ });
1312
+ return useMemo6(() => ({ walk, travelTo, step, dragged }), [travelTo, step]);
1313
+ }
1314
+
1315
+ // src/stage/quality.ts
1316
+ var qualityNames = ["auto", "low", "medium", "high"];
1317
+ var qualityTiers = {
1318
+ /**
1319
+ * Anything with a GPU — and measured to mean it, since `auto` only arrives
1320
+ * here after holding 55 fps. `segments: 128` is where the banners' folds
1321
+ * actually resolve: the drape asks for 133 across and spent every previous
1322
+ * version of this file getting 72, which is the difference between paper
1323
+ * that bends and paper with facets. Free on hardware — an M4 Pro holds 120
1324
+ * banners at 16 megapixels on the panel's own clock — and unreachable on
1325
+ * anything that cannot, because the ladder never promotes a machine there.
1326
+ */
1327
+ high: {
1328
+ dpr: 2,
1329
+ shadowMapSize: 2048,
1330
+ segments: 128,
1331
+ surround: true,
1332
+ contactShadow: true,
1333
+ environment: true,
1334
+ grade: true
1335
+ },
1336
+ /** The default worth aiming at: an integrated laptop GPU from the last few years. */
1337
+ medium: {
1338
+ dpr: 1.5,
1339
+ shadowMapSize: 1024,
1340
+ segments: 48,
1341
+ surround: true,
1342
+ contactShadow: false,
1343
+ environment: true,
1344
+ grade: false
1345
+ },
1346
+ /**
1347
+ * Old integrated graphics, a throttled phone, a software rasterizer. The
1348
+ * scene still READS — banners, figure, backlight, walk — it just stops
1349
+ * paying for the parts nobody would miss at this framerate.
1350
+ */
1351
+ low: {
1352
+ dpr: 1,
1353
+ shadowMapSize: 0,
1354
+ segments: 28,
1355
+ surround: true,
1356
+ contactShadow: false,
1357
+ environment: false,
1358
+ grade: false
1359
+ }
1360
+ };
1361
+ var INITIAL_TIER = "medium";
1362
+ var FIRST_WINDOW = 20;
1363
+ var STEADY_WINDOW = 60;
1364
+ var SETTLE_FRAMES = 45;
1365
+ var TIER_ORDER = ["low", "medium", "high"];
1366
+ function qualityFor(name) {
1367
+ return qualityTiers[name === "auto" ? INITIAL_TIER : name];
1368
+ }
1369
+ function tierUp(tier) {
1370
+ return TIER_ORDER[Math.min(TIER_ORDER.indexOf(tier) + 1, TIER_ORDER.length - 1)];
1371
+ }
1372
+ function tierDown(tier) {
1373
+ return TIER_ORDER[Math.max(TIER_ORDER.indexOf(tier) - 1, 0)];
1374
+ }
1375
+ var FLOOR_FPS = 26;
1376
+ var CEILING_FPS = 55;
1377
+ function settleTier(tier, fps, failed) {
1378
+ if (fps < FLOOR_FPS) {
1379
+ const next = tierDown(tier);
1380
+ return next === tier ? { tier, failed } : { tier: next, failed: tier };
1381
+ }
1382
+ if (fps > CEILING_FPS) {
1383
+ const next = tierUp(tier);
1384
+ if (next !== tier && next !== failed) return { tier: next, failed };
1385
+ }
1386
+ return { tier, failed };
1387
+ }
1388
+
1389
+ // src/stage/PaperStage.tsx
1390
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1391
+ var BANNER = {
1392
+ sheet: { width: 1.5, height: 8.5, segments: "auto" },
1393
+ stock: "vellum",
1394
+ surface: { grain: 0.22 },
1395
+ deformers: [{ type: "drape", options: { amplitude: 0.16, folds: 3, falloff: 1.7, gather: 0.28 } }]
1396
+ };
1397
+ function splitAcrossBanners(text, banners) {
1398
+ const words = text.split(/\s+/).filter(Boolean);
1399
+ if (words.length === 0 || banners <= 0) return [];
1400
+ const each = Math.floor(words.length / banners);
1401
+ const extra = words.length % banners;
1402
+ const out = [];
1403
+ let at = 0;
1404
+ for (let i = 0; i < banners && at < words.length; i++) {
1405
+ const take = each + (i < extra ? 1 : 0);
1406
+ if (take === 0) break;
1407
+ out.push(words.slice(at, at + take).join("\n"));
1408
+ at += take;
1409
+ }
1410
+ return out;
1411
+ }
1412
+ function letterColumn(word) {
1413
+ return [...word].join("\n");
1414
+ }
1415
+ function bannerTextSize(lines, longestWord = 0, measure = Number.POSITIVE_INFINITY) {
1416
+ const byDrop = 720 / Math.max(lines, 1);
1417
+ const byMeasure = longestWord > 0 ? measure / (longestWord * 0.62) : Number.POSITIVE_INFINITY;
1418
+ return Math.floor(Math.min(150, Math.max(26, Math.min(byDrop, byMeasure))));
1419
+ }
1420
+ var PADDING = 0.06;
1421
+ function bannerMeasure(sheet, padding = PADDING) {
1422
+ const long = Math.max(sheet.width, sheet.height);
1423
+ if (!(long > 0)) return Number.POSITIVE_INFINITY;
1424
+ return sheet.width / long * 1024 * (1 - padding * 2);
1425
+ }
1426
+ function ShotRig({
1427
+ stage,
1428
+ paperHeight,
1429
+ walk
1430
+ }) {
1431
+ const camera = useThree2((s) => s.camera);
1432
+ const path = useMemo7(() => getWalkPath(stage.path), [stage.path]);
1433
+ const scale = useMemo7(
1434
+ () => ({ figure: stage.figure.height, paper: paperHeight }),
1435
+ [stage.figure.height, paperHeight]
1436
+ );
1437
+ useFrame4(() => {
1438
+ const { position, target } = stageCamera(path, walk.current * path.length, scale, stage.shot);
1439
+ camera.position.set(position[0], position[1], position[2]);
1440
+ camera.lookAt(target[0], target[1], target[2]);
1441
+ });
1442
+ return null;
1443
+ }
1444
+ function QualityWatch({ tier, onChange }) {
1445
+ const samples = useRef5([]);
1446
+ const settle = useRef5(SETTLE_FRAMES);
1447
+ const window = useRef5(FIRST_WINDOW);
1448
+ const failed = useRef5(null);
1449
+ const settled = useCallback2((next) => {
1450
+ samples.current = [];
1451
+ settle.current = SETTLE_FRAMES;
1452
+ window.current = STEADY_WINDOW;
1453
+ return next;
1454
+ }, []);
1455
+ useFrame4((_, delta) => {
1456
+ if (settle.current > 0) {
1457
+ settle.current -= 1;
1458
+ return;
1459
+ }
1460
+ if (delta > 0.5) return;
1461
+ samples.current.push(delta);
1462
+ if (samples.current.length < window.current) return;
1463
+ const sorted = [...samples.current].sort((a, b) => a - b);
1464
+ const median = sorted[Math.floor(sorted.length / 2)];
1465
+ const fps = 1 / median;
1466
+ samples.current = [];
1467
+ window.current = STEADY_WINDOW;
1468
+ const verdict = settleTier(tier, fps, failed.current);
1469
+ failed.current = verdict.failed;
1470
+ if (verdict.tier !== tier) onChange(settled(verdict.tier));
1471
+ });
1472
+ return null;
1473
+ }
1474
+ function PaperStageScene({
1475
+ stage: stageInput,
1476
+ quality = "auto",
1477
+ onQualityChange,
1478
+ layout = "colonnade",
1479
+ layoutOptions,
1480
+ papers,
1481
+ images,
1482
+ text,
1483
+ preset,
1484
+ count = 22,
1485
+ progress,
1486
+ motion,
1487
+ onVisit,
1488
+ onProgress,
1489
+ reducedMotion
1490
+ }) {
1491
+ const still = usePrefersReducedMotion(reducedMotion);
1492
+ const [tier, setTier] = useState(quality === "auto" ? INITIAL_TIER : quality);
1493
+ useEffect7(() => {
1494
+ if (quality !== "auto") setTier(quality);
1495
+ }, [quality]);
1496
+ const reportQuality = useRef5(onQualityChange);
1497
+ useEffect7(() => {
1498
+ reportQuality.current = onQualityChange;
1499
+ });
1500
+ useEffect7(() => {
1501
+ reportQuality.current?.(tier);
1502
+ }, [tier]);
1503
+ const settings = quality === "auto" ? qualityTiers[tier] : qualityFor(quality);
1504
+ const stageKey = JSON.stringify(stageInput ?? {});
1505
+ const stage = useMemo7(() => stageSchema.parse(stageInput ?? {}), [stageKey]);
1506
+ const path = useMemo7(() => getWalkPath(stage.path), [stage.path]);
1507
+ const rig = useMemo7(() => {
1508
+ const resolved = resolveLighting(stage.lighting, stage.light);
1509
+ return {
1510
+ ...resolved,
1511
+ sky: { zenith: stage.source.zenith, horizon: stage.source.color, ground: stage.ground.color }
1512
+ };
1513
+ }, [stage.lighting, stage.light, stage.source.zenith, stage.source.color, stage.ground.color]);
1514
+ const sheetDims = useMemo7(() => {
1515
+ const { width, height } = resolveConfig({ preset: preset ?? BANNER }).sheet;
1516
+ return { width, height };
1517
+ }, [preset]);
1518
+ const paperHeight = sheetDims.height;
1519
+ const paperWidth = sheetDims.width;
1520
+ const paper = preset ?? BANNER;
1521
+ const resolvedLayoutOptions = useMemo7(() => {
1522
+ const schema = getLayout(layout).optionsSchema;
1523
+ const takesPath = schema instanceof z5.ZodObject && "path" in schema.shape;
1524
+ return takesPath ? { ...layoutOptions, path: stage.path } : layoutOptions;
1525
+ }, [layout, layoutOptions, stage.path]);
1526
+ const slots = useMemo7(() => {
1527
+ if (papers) return papers;
1528
+ if (images) return void 0;
1529
+ if (text !== void 0) {
1530
+ const split = Array.isArray(text) ? text : splitAcrossBanners(text, count);
1531
+ const vertical = split.every((c) => !c.includes("\n"));
1532
+ const columns = vertical ? split.map(letterColumn) : split;
1533
+ const longest = columns.reduce((n, c) => Math.max(n, c.split("\n").length), 1);
1534
+ const longestWord = columns.reduce(
1535
+ (n, c) => c.split("\n").reduce((m, w) => Math.max(m, w.length), n),
1536
+ 1
1537
+ );
1538
+ const size = bannerTextSize(longest, longestWord, bannerMeasure(sheetDims, PADDING));
1539
+ return columns.map((column) => ({
1540
+ content: {
1541
+ type: "text",
1542
+ text: column,
1543
+ size,
1544
+ align: "center",
1545
+ // Centred down the drop as well as across it. One size is shared
1546
+ // by the whole rank — that is what makes it read as set rather
1547
+ // than scaled — so a short word necessarily leaves slack, and the
1548
+ // slack belongs at both ends. Hung from the top instead, "the"
1549
+ // reads as a caption that ran out while "remembers" fills its
1550
+ // banner, and the rank looks broken rather than composed.
1551
+ valign: "center",
1552
+ color: "#241f1a",
1553
+ lineHeight: 1.25,
1554
+ font: 'Georgia, "Times New Roman", serif',
1555
+ weight: 400,
1556
+ padding: PADDING
1557
+ }
1558
+ }));
1559
+ }
1560
+ return Array.from({ length: count }, () => ({}));
1561
+ }, [papers, images, text, count, sheetDims]);
1562
+ const drive = stageMotionSchema.parse(motion ?? {});
1563
+ const slotCount = slots?.length ?? images?.length ?? count;
1564
+ const stops = useMemo7(() => {
1565
+ const spec = getLayout(layout);
1566
+ const placed = spec.walkStops?.(slotCount, spec.optionsSchema.parse(resolvedLayoutOptions ?? {}));
1567
+ if (placed && placed.length > 0) return placed;
1568
+ return Array.from({ length: slotCount }, (_, i) => slotCount > 1 ? i / (slotCount - 1) : 0.5);
1569
+ }, [layout, resolvedLayoutOptions, slotCount]);
1570
+ const walk = useWalk({
1571
+ path,
1572
+ motion: drive,
1573
+ progress,
1574
+ figureSpeed: stage.figure.speed,
1575
+ stops,
1576
+ reduced: still,
1577
+ onProgress
1578
+ });
1579
+ const surroundRadius = useMemo7(
1580
+ () => Math.max(path.length * 1.6, paperHeight * 9),
1581
+ [path.length, paperHeight]
1582
+ );
1583
+ const source = useMemo7(() => {
1584
+ const [x, z6] = walkPoint(path, path.length + stage.source.beyond);
1585
+ const [tx, tz] = path.tangentAt(1);
1586
+ const size = paperHeight * stage.source.spread;
1587
+ return { position: [x, size * 0.35, z6], yaw: Math.atan2(-tx, -tz), size };
1588
+ }, [path, stage.source.beyond, stage.source.spread, paperHeight]);
1589
+ return /* @__PURE__ */ jsxs6(LightRig, { rig, children: [
1590
+ /* @__PURE__ */ jsx7(ShotRig, { stage, paperHeight, walk: walk.walk }),
1591
+ /* @__PURE__ */ jsx7(
1592
+ PaperLighting,
1593
+ {
1594
+ rig,
1595
+ floor: 0,
1596
+ scale: 60,
1597
+ reducedMotion,
1598
+ shadowMapSize: settings.shadowMapSize,
1599
+ contactShadow: settings.contactShadow,
1600
+ environment: settings.environment
1601
+ }
1602
+ ),
1603
+ quality === "auto" && /* @__PURE__ */ jsx7(QualityWatch, { tier, onChange: setTier }),
1604
+ stage.source.surround && settings.surround && /* @__PURE__ */ jsx7(Surround, { radius: surroundRadius, sky: rig.sky }),
1605
+ stage.source.enabled && /* @__PURE__ */ jsx7(Source, { size: source.size, position: source.position, yaw: source.yaw, color: rig.sky.horizon }),
1606
+ stage.ground.enabled && /* @__PURE__ */ jsx7(Floor, { size: surroundRadius * 1.3, color: stage.ground.color, slab: stage.ground.slab }),
1607
+ stage.room.enabled && stage.room.doorway.enabled && stage.source.enabled && /* @__PURE__ */ jsx7(
1608
+ Doorway,
1609
+ {
1610
+ position: source.position,
1611
+ yaw: source.yaw,
1612
+ size: source.size,
1613
+ opening: stage.room.doorway.opening,
1614
+ color: stage.room.doorway.color,
1615
+ extent: surroundRadius * 0.9
1616
+ }
1617
+ ),
1618
+ stage.room.enabled && stage.room.columns.enabled && /* @__PURE__ */ jsx7(
1619
+ Columns,
1620
+ {
1621
+ path,
1622
+ ceiling: paperHeight * stage.room.height,
1623
+ spacing: stage.room.columns.spacing,
1624
+ width: stage.room.columns.width,
1625
+ offset: stage.room.columns.offset,
1626
+ color: stage.room.columns.color
1627
+ }
1628
+ ),
1629
+ stage.suspension.type !== "none" && /* @__PURE__ */ jsx7(
1630
+ Suspension,
1631
+ {
1632
+ layout,
1633
+ layoutOptions: resolvedLayoutOptions ?? {},
1634
+ count: slots?.length ?? count,
1635
+ sheet: { width: paperWidth, height: paperHeight },
1636
+ paperHeight,
1637
+ ceiling: paperHeight * stage.room.height,
1638
+ color: stage.suspension.color,
1639
+ type: stage.suspension.type,
1640
+ hardware: stage.suspension.hardware
1641
+ }
1642
+ ),
1643
+ stage.room.enabled && /* @__PURE__ */ jsx7(
1644
+ Ceiling,
1645
+ {
1646
+ size: surroundRadius * 1.3,
1647
+ height: paperHeight * stage.room.height,
1648
+ color: stage.room.color
1649
+ }
1650
+ ),
1651
+ /* @__PURE__ */ jsx7(
1652
+ PaperFieldMesh,
1653
+ {
1654
+ preset: paper,
1655
+ segmentCeiling: settings.segments,
1656
+ papers: slots,
1657
+ images,
1658
+ layout,
1659
+ layoutOptions: resolvedLayoutOptions,
1660
+ motion: { driver: "none" },
1661
+ entrance: { type: "none" },
1662
+ reducedMotion,
1663
+ onSelect: drive.driver === "drag" && progress === void 0 ? (paperIndex) => {
1664
+ if (walk.dragged.current) return;
1665
+ walk.travelTo(stops[paperIndex] ?? 0);
1666
+ onVisit?.(paperIndex);
1667
+ } : void 0
1668
+ }
1669
+ ),
1670
+ stage.showFigure && /* @__PURE__ */ jsx7(
1671
+ Figure,
1672
+ {
1673
+ path: stage.path,
1674
+ figure: stage.figure,
1675
+ distanceRef: walk.walk,
1676
+ walkLength: path.length,
1677
+ frozen: reducedMotion
1678
+ }
1679
+ ),
1680
+ settings.grade && /* @__PURE__ */ jsx7(Grade, { grade: stage.grade, film: rig.film })
1681
+ ] });
1682
+ }
1683
+ function PaperStage({ children, className, style, ...sceneProps }) {
1684
+ const dpr = qualityFor(sceneProps.quality ?? "auto").dpr;
1685
+ return /* @__PURE__ */ jsx7("div", { className, style: { width: "100%", height: "100%", ...style }, children: /* @__PURE__ */ jsxs6(
1686
+ Canvas,
1687
+ {
1688
+ shadows: true,
1689
+ dpr: [1, dpr],
1690
+ camera: { fov: 38, near: 0.05, far: 400 },
1691
+ onCreated: ({ scene }) => {
1692
+ scene.background = new THREE6.Color("#0c0a0b");
1693
+ },
1694
+ children: [
1695
+ /* @__PURE__ */ jsx7(PaperStageScene, { ...sceneProps }),
1696
+ children
1697
+ ]
1698
+ }
1699
+ ) });
1700
+ }
1701
+
1702
+ // src/stage/walks.ts
1703
+ var walkNames = ["straight", "bend", "ess", "ring", "spiral"];
1704
+ var walks = {
1705
+ /** Straight down the nave, away from the camera. The reference shot. */
1706
+ straight: {
1707
+ points: [
1708
+ [0, 16],
1709
+ [0, -20]
1710
+ ],
1711
+ closed: false
1712
+ },
1713
+ /** One long curve, so the far end of the colonnade stays hidden until you reach it. */
1714
+ bend: {
1715
+ points: [
1716
+ [-2, 16],
1717
+ [0, 6],
1718
+ [5, -3],
1719
+ [12, -10]
1720
+ ],
1721
+ closed: false
1722
+ },
1723
+ /** Two opposed curves — the walk turns twice and the banners turn with it. */
1724
+ ess: {
1725
+ points: [
1726
+ [6, 17],
1727
+ [-3, 7],
1728
+ [3, -5],
1729
+ [-6, -17]
1730
+ ],
1731
+ closed: false
1732
+ },
1733
+ /** A closed loop: the only walk `phase` can slide, and the only endless one. */
1734
+ ring: {
1735
+ points: [
1736
+ [11, 0],
1737
+ [0, 11],
1738
+ [-11, 0],
1739
+ [0, -11]
1740
+ ],
1741
+ closed: true
1742
+ },
1743
+ /** Inward and tightening — the space closes as the figure goes deeper. */
1744
+ spiral: {
1745
+ points: [
1746
+ [14, 2],
1747
+ [2, 13],
1748
+ [-11, 1],
1749
+ [-1, -9],
1750
+ [7, -2],
1751
+ [1, 4]
1752
+ ],
1753
+ closed: false
1754
+ }
1755
+ };
1756
+ function getWalk(name) {
1757
+ return walks[name];
1758
+ }
1759
+
1760
+ // src/stage/presets.ts
1761
+ var banner = (width, height, drape = {}) => ({
1762
+ sheet: { width, height, segments: "auto" },
1763
+ stock: "vellum",
1764
+ surface: { grain: 0.22 },
1765
+ deformers: [
1766
+ { type: "drape", options: { amplitude: 0.16, folds: 3, falloff: 1.7, gather: 0.28, ...drape } }
1767
+ ]
1768
+ });
1769
+ var stagePresets = {
1770
+ nave: {
1771
+ id: "nave",
1772
+ label: "Nave",
1773
+ description: "A straight aisle of hanging banners, lit from the far end.",
1774
+ stage: {
1775
+ path: walks.straight,
1776
+ shot: { shot: "follow", distance: 5, lookAhead: 12, offset: 1.5 },
1777
+ lighting: "nave",
1778
+ // The hall this stage is named for. Columns give it the one thing a
1779
+ // ceiling and floor seams cannot: an object of known size standing IN
1780
+ // the room rather than bounding it.
1781
+ room: { columns: { enabled: true } }
1782
+ },
1783
+ layout: "colonnade",
1784
+ layoutOptions: { aisle: 2.6, twist: 22, drape: 0.6, rise: 0.3 },
1785
+ paper: banner(1.5, 8.5),
1786
+ count: 18,
1787
+ text: "the paper remembers every hand that folded it and every room it was carried through"
1788
+ },
1789
+ procession: {
1790
+ id: "procession",
1791
+ label: "Procession",
1792
+ description: "The walk turns twice, so the far end stays hidden until you reach it.",
1793
+ stage: {
1794
+ path: walks.ess,
1795
+ shot: { shot: "low", distance: 4, lookAhead: 9, offset: 1.1 },
1796
+ lighting: "nave",
1797
+ figure: { speed: 1.05 }
1798
+ },
1799
+ layout: "colonnade",
1800
+ layoutOptions: { aisle: 2.2, twist: 34, breathe: 0.45, drape: 0.7 },
1801
+ paper: banner(1.3, 9.5, { folds: 4, amplitude: 0.2 }),
1802
+ count: 28,
1803
+ text: "every letter you did not send is still folded somewhere in the dark waiting to be read aloud"
1804
+ },
1805
+ cloister: {
1806
+ id: "cloister",
1807
+ label: "Cloister",
1808
+ description: "A closed loop. The figure walks it forever and the banners drift past.",
1809
+ stage: {
1810
+ path: walks.ring,
1811
+ shot: { shot: "follow", distance: 4.5, lookAhead: 8, offset: 1.2 },
1812
+ lighting: "nave",
1813
+ // Pegs, not clips. A cloister is a walk you repeat, and the sheets are
1814
+ // the same words coming back — hung the way you hang washing, not the
1815
+ // way a gallery mounts a print. The silhouette is the whole difference
1816
+ // at this distance: a peg grips DOWN the face where a clip grips
1817
+ // across the edge.
1818
+ suspension: { hardware: "peg" }
1819
+ },
1820
+ layout: "colonnade",
1821
+ layoutOptions: { aisle: 2.4, twist: 18, rise: 0.22 },
1822
+ paper: banner(1.6, 7.5),
1823
+ count: 24,
1824
+ text: "around and around and the same words come back changed"
1825
+ },
1826
+ threshold: {
1827
+ id: "threshold",
1828
+ label: "Threshold",
1829
+ description: "A few enormous sheets, wide enough apart to walk between and read.",
1830
+ stage: {
1831
+ // Its own short walk. A colonnade spreads over the WHOLE path whatever
1832
+ // it is populating, so ten banners on the default 36-unit walk stand
1833
+ // seven apart and the shot looks down an empty corridor.
1834
+ path: {
1835
+ points: [
1836
+ [0, 9],
1837
+ [0, -11]
1838
+ ],
1839
+ closed: false
1840
+ },
1841
+ // The aisle has to stay inside the frustum at the distance the shot
1842
+ // stands: paper half a frame-width off the walk line is paper you
1843
+ // never see. `lead` fails here for the same reason and worse.
1844
+ shot: { shot: "follow", distance: 6.5, lookAhead: 9, offset: 0.9 },
1845
+ lighting: "nave",
1846
+ figure: { speed: 0.85 },
1847
+ /**
1848
+ * The one room in the set with a COLOUR in it.
1849
+ *
1850
+ * Every other stage is a warm neutral corridor, and white paper against
1851
+ * warm neutral is white paper against nothing — the sheets and the room
1852
+ * sit at the same temperature and the picture flattens. Against a
1853
+ * saturated ground the paper sings, which is why the installations
1854
+ * worth copying are shot in rooms painted terracotta and washed with
1855
+ * gels rather than in white boxes.
1856
+ *
1857
+ * `source.color` is the horizon and `ground.color` the floor of the
1858
+ * same three-stop sky that builds the environment map, so the light
1859
+ * bouncing onto the sheets is the room's own colour and cannot
1860
+ * disagree with the walls the viewer can see.
1861
+ */
1862
+ source: { spread: 1.1, color: "#ffd7a8", zenith: "#3d1c12" },
1863
+ ground: { color: "#6b2f1d" },
1864
+ // The stage named for a doorway now has one. Without the wall the
1865
+ // source is a bright rectangle floating in a coloured void — it reads
1866
+ // as light, but not as light coming from anywhere. With it, the walk
1867
+ // resolves toward an opening in a surface, and the room gets the
1868
+ // corner it never had. The wall takes the terracotta so the opening is
1869
+ // the only bright thing in the frame that is not paper.
1870
+ room: { doorway: { enabled: true, color: "#4a2013" } }
1871
+ },
1872
+ layout: "colonnade",
1873
+ layoutOptions: { aisle: 2.4, twist: 14, breathe: 0.18, margin: 0.12, rise: 0.2 },
1874
+ paper: banner(2.6, 10, { folds: 2, amplitude: 0.24, falloff: 2 }),
1875
+ count: 10,
1876
+ text: "stand closer and read what it cost to write this down"
1877
+ },
1878
+ /**
1879
+ * The one stage that is not a colonnade of banners.
1880
+ *
1881
+ * A ribbon reaches the floor and keeps going, and everything built in the
1882
+ * last four phases exists so that this reads: a room with a ceiling to
1883
+ * hang from, hardware to hang by, type that can be set down a length
1884
+ * without looking like a caption, and a `roll` that begins at the floor
1885
+ * line rather than at the sheet's centre.
1886
+ */
1887
+ ribbon: {
1888
+ id: "ribbon",
1889
+ label: "Ribbon",
1890
+ description: "Printed strips falling the full drop of the room, pooling where they land.",
1891
+ stage: {
1892
+ path: walks.straight,
1893
+ // Close. Ribbons are a curtain you part rather than a hall you walk
1894
+ // down, so the camera stands nearer and looks less far ahead than any
1895
+ // other stage in the set.
1896
+ // Lower than the other stages. Pooled paper lies FLAT, so from
1897
+ // standing height it foreshortens to a sliver; the shot has to get
1898
+ // down toward the floor for the thing this stage is about to read.
1899
+ shot: { shot: "follow", distance: 4.6, height: 2.3, lookAhead: 3.6, offset: 0.34 },
1900
+ lighting: "nave",
1901
+ // A low ceiling: the strips ARE the height of the room, so a lid far
1902
+ // above them would leave metres of empty air and make the drop read as
1903
+ // short. This is the stage the room proportion matters most on.
1904
+ room: { height: 1.12 },
1905
+ source: { spread: 1.3 },
1906
+ suspension: { hardware: "clip" }
1907
+ },
1908
+ layout: "colonnade",
1909
+ // Packed tighter than the banner stages, barely twisted, and hung at a
1910
+ // steady height — a rank of strips reads by its rhythm, and jitter that
1911
+ // flatters a colonnade of banners just makes this look untidy.
1912
+ // `hover` is NEGATIVE by exactly the pool fraction, and that is the whole
1913
+ // trick. A colonnade hangs a sheet with its BOTTOM edge on the floor, but
1914
+ // a ribbon's crease sits a pool-length above its bottom edge — so at
1915
+ // hover 0 the pooled length lies flat in mid-air, parallel to a ground it
1916
+ // never touches. Dropping the strip by the same fraction puts the crease
1917
+ // on the floor and the pool ON it.
1918
+ layoutOptions: {
1919
+ aisle: 1.75,
1920
+ twist: 5,
1921
+ breathe: 0.1,
1922
+ margin: 0.06,
1923
+ rise: 0.06,
1924
+ drape: 0.2,
1925
+ hover: -0.22
1926
+ },
1927
+ paper: {
1928
+ sheet: { width: 1.05, height: 9, segments: "auto" },
1929
+ stock: "printer",
1930
+ surface: { grain: 0.2 },
1931
+ behavior: { type: "ribbon", pool: 0.22, curl: 0.34, drape: 0.6 }
1932
+ },
1933
+ // Eight, not twelve, and the reason is the type rather than the room.
1934
+ // A strip 1.05 wide holds about 105px of measure, which caps the type at
1935
+ // 26px, which means a column needs roughly twenty-six words to reach the
1936
+ // bottom of a nine-metre drop. Twelve strips wanted three hundred words;
1937
+ // eight want two hundred, which is a passage rather than an essay. Fewer
1938
+ // and longer is also what the reference installations look like.
1939
+ count: 8,
1940
+ // Long, because the whole point of this stage is type running the length
1941
+ // of the paper. It shipped with twenty words across twelve banners — two
1942
+ // words a strip — which set as a caption at the top of nine metres of
1943
+ // blank paper. Every word here is kept to seven letters or fewer: the
1944
+ // measure is narrow, and one long word shrinks the type on every banner
1945
+ // in the room, because a rank of banners is set at one size or it reads
1946
+ // as a mistake.
1947
+ text: "the paper kept going long after the floor ran out from under it and nobody moved to pick it up we let it lie there the way you let a letter lie it had come down from a height no one could name and it held the shape of the fall in its folds someone inked it once and you can still read the last of it where the light gets in a room is only a room until you hang a thing in it then it is a place you walk across slowly the strips move when the door opens and settle again before you reach them paper holds what it was rolled around it holds being flat too and it will go back to flat if you leave it alone long enough but not today today it lies in a curve at the foot of the wall and the curve is the whole point the floor was never meant to hold this much so the paper takes over where the floor gives up it pools the way water would if water could be inked we came to look at the light we stayed for the paper on the ground"
1948
+ },
1949
+ archive: {
1950
+ id: "archive",
1951
+ label: "Archive",
1952
+ description: "Narrow strips packed tight \u2014 a corridor of records you edge through.",
1953
+ stage: {
1954
+ path: walks.bend,
1955
+ // Far enough back that the figure reads as small; a `low` camera
1956
+ // three units behind a body is all body.
1957
+ shot: { shot: "low", distance: 8, lookAhead: 13, offset: 0.5 },
1958
+ lighting: "nave",
1959
+ ground: { color: "#0b0908" },
1960
+ // Its banners are eleven units tall and `spread` is a multiple of that,
1961
+ // so the default opening would be fifty units across — a wall, on a
1962
+ // walk whose whole point is that it is narrow.
1963
+ source: { spread: 1.1 },
1964
+ // Records on a rail. Forty-four strips each on their own invisible
1965
+ // thread read as forty-four accidents; on rods they read as a system
1966
+ // somebody filed them into, which is what the stage is called.
1967
+ suspension: { type: "rod" }
1968
+ },
1969
+ layout: "colonnade",
1970
+ layoutOptions: { aisle: 1.7, twist: 44, breathe: 0.5, drape: 0.75, rise: 0.4 },
1971
+ paper: banner(0.85, 11, { folds: 2, amplitude: 0.12 }),
1972
+ count: 44,
1973
+ text: "catalogued indexed cross referenced filed and never once opened by anyone at all"
1974
+ }
1975
+ };
1976
+ function getStagePreset(id) {
1977
+ const preset = stagePresets[id];
1978
+ if (!preset) {
1979
+ throw new Error(
1980
+ `[paperlab] Unknown stage preset "${id}". Available: ${Object.keys(stagePresets).join(", ")}`
1981
+ );
1982
+ }
1983
+ return preset;
1984
+ }
1985
+ function listStagePresets() {
1986
+ return Object.keys(stagePresets);
1987
+ }
1988
+
1989
+ // src/stage/export.ts
1990
+ var SCROLL_HEIGHTS = 4;
1991
+ function walkNameFor(path) {
1992
+ const key = JSON.stringify({ points: path.points, closed: path.closed });
1993
+ return walkNames.find(
1994
+ (name) => JSON.stringify({ points: walks[name].points, closed: walks[name].closed }) === key
1995
+ );
1996
+ }
1997
+ function stripDefaults(value, defaults) {
1998
+ if (Array.isArray(value) || Array.isArray(defaults)) {
1999
+ return JSON.stringify(value) === JSON.stringify(defaults) ? void 0 : value;
2000
+ }
2001
+ if (value && defaults && typeof value === "object" && typeof defaults === "object") {
2002
+ const out = {};
2003
+ for (const [key, child] of Object.entries(value)) {
2004
+ const kept = stripDefaults(child, defaults[key]);
2005
+ if (kept !== void 0) out[key] = kept;
2006
+ }
2007
+ return Object.keys(out).length > 0 ? out : void 0;
2008
+ }
2009
+ return value === defaults ? void 0 : value;
2010
+ }
2011
+ function diffStage(stage) {
2012
+ const resolved = stageSchema.parse(stage);
2013
+ const defaults = stageSchema.parse({});
2014
+ const diff = stripDefaults(resolved, defaults) ?? {};
2015
+ if (diff.path !== void 0) diff.path = resolved.path;
2016
+ return diff;
2017
+ }
2018
+ function stringifyStage(value, indent = 0) {
2019
+ const pad = " ".repeat(indent);
2020
+ const inner = " ".repeat(indent + 1);
2021
+ if (Array.isArray(value)) {
2022
+ if (value.length === 0) return "[]";
2023
+ if (value.every((v) => typeof v === "number")) return `[${value.join(", ")}]`;
2024
+ const items = value.map((v) => `${inner}${stringifyStage(v, indent + 1)}`);
2025
+ return `[
2026
+ ${items.join(",\n")}
2027
+ ${pad}]`;
2028
+ }
2029
+ if (value && typeof value === "object") {
2030
+ const entries = Object.entries(value);
2031
+ if (entries.length === 0) return "{}";
2032
+ const items = entries.map(([k, v]) => `${inner}${JSON.stringify(k)}: ${stringifyStage(v, indent + 1)}`);
2033
+ return `{
2034
+ ${items.join(",\n")}
2035
+ ${pad}}`;
2036
+ }
2037
+ return JSON.stringify(value);
2038
+ }
2039
+ var SHOT_PHRASES = {
2040
+ follow: "from behind and a little above them, looking up the walk",
2041
+ lead: "from in front, walking backward as they come on",
2042
+ low: "from down at floor level, looking up the banners",
2043
+ wide: "from off to one side, level with them"
2044
+ };
2045
+ function describeStage(input) {
2046
+ const stage = stageSchema.parse(input.stage);
2047
+ const count = input.count ?? 22;
2048
+ const walk = walkNameFor(stage.path);
2049
+ const parts = [];
2050
+ 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`;
2051
+ parts.push(`${count} tall paper banners standing along ${shape}`);
2052
+ if (input.text?.trim()) {
2053
+ parts.push("each printed with a column of your text running down it");
2054
+ }
2055
+ parts.push(`seen ${SHOT_PHRASES[stage.shot.shot]}`);
2056
+ if (stage.showFigure) {
2057
+ parts.push("a small dark figure walking between them");
2058
+ }
2059
+ if (stage.room.enabled) {
2060
+ parts.push("a ceiling overhead and seams in the poured floor, so the hall has a knowable size");
2061
+ }
2062
+ parts.push(
2063
+ 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`
2064
+ );
2065
+ const moved = Object.entries(stage.light).filter(([, value]) => value !== void 0).map(([key]) => key);
2066
+ if (moved.length > 0) parts.push(`with its ${moved.join(", ")} set by hand`);
2067
+ if (input.scroll) parts.push("and scrolling the page walks the figure deeper into it");
2068
+ return parts.join(", ");
2069
+ }
2070
+ function propLines(input, indent) {
2071
+ const lines = [];
2072
+ if (input.paper) lines.push(`${indent}preset={banner}`);
2073
+ if (input.text?.trim()) lines.push(`${indent}text={text}`);
2074
+ if (input.count !== void 0) lines.push(`${indent}count={${input.count}}`);
2075
+ if (input.layout !== "colonnade") lines.push(`${indent}layout="${input.layout}"`);
2076
+ const layoutOptions = input.layoutOptions ?? {};
2077
+ const layoutDefaults = getLayout(input.layout).defaults;
2078
+ const changed = {};
2079
+ for (const [key, value] of Object.entries(layoutOptions)) {
2080
+ if (JSON.stringify(value) !== JSON.stringify(layoutDefaults[key])) changed[key] = value;
2081
+ }
2082
+ if (Object.keys(changed).length > 0) {
2083
+ lines.push(`${indent}layoutOptions={${stringifyStage(changed).replace(/\n\s*/g, " ")}}`);
2084
+ }
2085
+ lines.push(`${indent}stage={stage}`);
2086
+ return lines.join("\n");
2087
+ }
2088
+ function buildStageComponentSource(input) {
2089
+ const name = input.componentName ?? "PaperNave";
2090
+ const stage = diffStage(input.stage);
2091
+ const stageConst = `const stage = ${stringifyStage(stage)} satisfies StageConfigInput`;
2092
+ const bannerConst = input.paper ? `
2093
+
2094
+ const banner = ${stringifyStage(diffConfig(paperConfigSchema.parse(input.paper)))} satisfies PaperConfigInput` : "";
2095
+ const textConst = input.text?.trim() ? `
2096
+
2097
+ const text = ${JSON.stringify(input.text)}` : "";
2098
+ if (!input.scroll) {
2099
+ return `import { PaperStage, type StageConfigInput } from 'paperlab/stage'${input.paper ? "\nimport type { PaperConfigInput } from 'paperlab'" : ""}
2100
+
2101
+ ${stageConst}${bannerConst}${textConst}
2102
+
2103
+ export function ${name}() {
2104
+ return (
2105
+ <PaperStage
2106
+ ${propLines(input, " ")}
2107
+ />
2108
+ )
2109
+ }`;
2110
+ }
2111
+ return `import { useEffect, useRef, useState } from 'react'
2112
+ import { PaperStage, type StageConfigInput } from 'paperlab/stage'${input.paper ? "\nimport type { PaperConfigInput } from 'paperlab'" : ""}
2113
+
2114
+ ${stageConst}${bannerConst}${textConst}
2115
+
2116
+ export function ${name}() {
2117
+ const ref = useRef<HTMLDivElement>(null)
2118
+ const [progress, setProgress] = useState(0)
2119
+
2120
+ // Scroll the section, walk the figure. The stage is pinned for the height
2121
+ // of the section, so the page scrolling past it IS the walk.
2122
+ useEffect(() => {
2123
+ const el = ref.current
2124
+ if (!el) return
2125
+ const onScroll = () => {
2126
+ const { top, height } = el.getBoundingClientRect()
2127
+ const travel = Math.max(height - window.innerHeight, 1)
2128
+ setProgress(Math.min(Math.max(-top / travel, 0), 1))
2129
+ }
2130
+ onScroll()
2131
+ window.addEventListener('scroll', onScroll, { passive: true })
2132
+ window.addEventListener('resize', onScroll)
2133
+ return () => {
2134
+ window.removeEventListener('scroll', onScroll)
2135
+ window.removeEventListener('resize', onScroll)
2136
+ }
2137
+ }, [])
2138
+
2139
+ return (
2140
+ <div ref={ref} style={{ height: '${SCROLL_HEIGHTS * 100}vh' }}>
2141
+ <div style={{ position: 'sticky', top: 0, height: '100vh' }}>
2142
+ <PaperStage
2143
+ ${propLines(input, " ")}
2144
+ progress={progress}
2145
+ />
2146
+ </div>
2147
+ </div>
2148
+ )
2149
+ }`;
2150
+ }
2151
+ function buildStageAgentPayload(input) {
2152
+ const name = input.componentName ?? "PaperNave";
2153
+ const sizing = input.scroll ? `4. Sizing: the component brings its own height \u2014 it reserves ${SCROLL_HEIGHTS} viewport
2154
+ heights of scroll and pins the canvas inside that. Drop it into the page
2155
+ 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;
2156
+ give the parent an explicit height.`;
2157
+ 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})
2158
+
2159
+ 1. Install the dependencies:
2160
+
2161
+ npm i paperlab three @react-three/fiber gsap @react-three/postprocessing postprocessing
2162
+
2163
+ The last two are only needed by stage mode \u2014 <Paper> and <PaperField> do
2164
+ not use them \u2014 but <PaperStage> imports the print pass (bloom, tone curve,
2165
+ vignette, grain), so a stage will not build without them.
2166
+
2167
+ 2. Create the component below as \`components/${name}.tsx\` (or the project's
2168
+ component convention). It is self-contained \u2014 it owns its own <Canvas>,
2169
+ its own camera and its own lighting:
2170
+
2171
+ \`\`\`tsx
2172
+ ${buildStageComponentSource(input)}
2173
+ \`\`\`
2174
+
2175
+ 3. Placement: this is a full-bleed scene, not an inline element. Give it the
2176
+ full width of the viewport.
2177
+
2178
+ ${sizing}
2179
+
2180
+ 5. Verify: run the dev server. You should see ${describeStage(input)}.
2181
+ If the canvas is blank, the container almost certainly has no height \u2014 give
2182
+ it one (this is the classic React Three Fiber integration bug, not a
2183
+ paperlab bug).
2184
+
2185
+ Constraints: don't modify the stage values; the camera is driven by the
2186
+ stage's own shot, so don't add OrbitControls; three >= 0.160 and React 19 are
2187
+ required; the component needs no props.`;
2188
+ }
2189
+ export {
2190
+ PaperStage,
2191
+ PaperStageScene,
2192
+ SOURCE_INTENSITY,
2193
+ buildStageAgentPayload,
2194
+ buildStageComponentSource,
2195
+ createWalkPath,
2196
+ describeStage,
2197
+ diffStage,
2198
+ getStagePreset,
2199
+ getWalk,
2200
+ listStagePresets,
2201
+ qualityNames,
2202
+ shotNames,
2203
+ stageGradeSchema,
2204
+ stageMotionSchema,
2205
+ stagePresets,
2206
+ stageRoomSchema,
2207
+ stageSchema,
2208
+ stageSuspensionSchema,
2209
+ stringifyStage,
2210
+ walkNameFor,
2211
+ walkNames,
2212
+ walks
2213
+ };
2214
+ //# sourceMappingURL=stage.js.map