paperlab 0.3.1 → 0.4.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.
@@ -67,6 +67,12 @@ function segmentsForSine(span, amplitude, wavelength, tol = SAG_TOL) {
67
67
  return segmentsForArc(span, 1 / peakCurvature, tol);
68
68
  }
69
69
 
70
+ // src/config/schema.ts
71
+ import { z as z14 } from "zod";
72
+
73
+ // src/behaviors/peel.ts
74
+ import { z as z2 } from "zod";
75
+
70
76
  // src/deformers/curl.ts
71
77
  import { z } from "zod";
72
78
  var cornerNames = ["top-left", "top-right", "bottom-left", "bottom-right"];
@@ -170,7 +176,6 @@ void FN(inout vec3 p, vec2 uv, float t) {
170
176
  };
171
177
 
172
178
  // src/behaviors/peel.ts
173
- import { z as z2 } from "zod";
174
179
  var peelOptionsSchema = z2.object({
175
180
  progress: z2.number().min(0).max(1).default(0.35),
176
181
  /** 'auto' resolves per slot in a `sheet` field (outward-facing corner); standalone it means bottom-right. */
@@ -544,6 +549,9 @@ var carry = {
544
549
  }
545
550
  };
546
551
 
552
+ // src/behaviors/flight.ts
553
+ import { z as z10 } from "zod";
554
+
547
555
  // src/physics/aero.ts
548
556
  function dampTo(state, target, smoothing, dt) {
549
557
  const omega = 2 / Math.max(smoothing, 1e-4);
@@ -584,7 +592,6 @@ function carryDrive(speed) {
584
592
  }
585
593
 
586
594
  // src/behaviors/flight.ts
587
- import { z as z10 } from "zod";
588
595
  var flightOptionsSchema = z10.object({
589
596
  /** Directional wind vector — paper travels ACROSS the scene, not just down. */
590
597
  wind: z10.tuple([z10.number().min(-2).max(2), z10.number().min(-2).max(2), z10.number().min(-2).max(2)]).default([0.6, 0.08, 0]),
@@ -868,7 +875,6 @@ var ribbon = {
868
875
  };
869
876
 
870
877
  // src/config/schema.ts
871
- import { z as z14 } from "zod";
872
878
  var sheetSchema = z14.object({
873
879
  /** World units. A letter sheet is ~1 × 1.4, a receipt ~1 × 2.6. */
874
880
  width: z14.number().positive().max(20).default(1),
@@ -1195,30 +1201,6 @@ function serializePreset(config) {
1195
1201
  return JSON.stringify(config, null, 2);
1196
1202
  }
1197
1203
 
1198
- // src/core/sheet.ts
1199
- import * as THREE from "three";
1200
- function resolveSegments(sheet2, minSegments = 2, autoSegments = LEGACY_FLAT_SEGMENTS) {
1201
- const [minX, minY] = typeof minSegments === "number" ? [minSegments, minSegments] : minSegments;
1202
- if (sheet2.segments !== "auto") {
1203
- return [Math.max(sheet2.segments, minX, 2), Math.max(sheet2.segments, minY, 2)];
1204
- }
1205
- const [wantX, wantY] = typeof autoSegments === "number" ? spreadByAspect(sheet2, quantizeSegments(Math.max(autoSegments, FLAT_SEGMENTS))) : [
1206
- quantizeSegments(Math.max(wantOrFlat(autoSegments[0]), FLAT_SEGMENTS)),
1207
- quantizeSegments(Math.max(wantOrFlat(autoSegments[1]), FLAT_SEGMENTS))
1208
- ];
1209
- return [Math.max(wantX, minX, 2), Math.max(wantY, minY, 2)];
1210
- }
1211
- var wantOrFlat = (n) => Number.isFinite(n) ? n : FLAT_SEGMENTS;
1212
- function spreadByAspect(sheet2, target) {
1213
- const long = Math.max(sheet2.width, sheet2.height);
1214
- if (!(long > 0)) return [target, target];
1215
- return [Math.round(sheet2.width / long * target), Math.round(sheet2.height / long * target)];
1216
- }
1217
- function createSheetGeometry(sheet2, minSegments = 2, autoSegments = LEGACY_FLAT_SEGMENTS) {
1218
- const [sx, sy] = resolveSegments(sheet2, minSegments, autoSegments);
1219
- return new THREE.PlaneGeometry(sheet2.width, sheet2.height, sx, sy);
1220
- }
1221
-
1222
1204
  // src/core/stock.ts
1223
1205
  var stocks = {
1224
1206
  printer: {
@@ -1581,92 +1563,6 @@ function uniquePresetName(base, taken) {
1581
1563
  return name;
1582
1564
  }
1583
1565
 
1584
- // src/content/receipt.ts
1585
- function receiptTotals(content) {
1586
- const subtotal = content.items.reduce((sum, item) => sum + item.price, 0);
1587
- const tax = subtotal * content.taxRate;
1588
- return { subtotal, tax, total: subtotal + tax };
1589
- }
1590
- function barcodeBars(seed) {
1591
- let h = 2166136261;
1592
- for (let i = 0; i < seed.length; i++) {
1593
- h ^= seed.charCodeAt(i);
1594
- h = Math.imul(h, 16777619);
1595
- }
1596
- const bars = [2, 1, 1, 4];
1597
- for (let i = 0; i < 30; i++) {
1598
- h = Math.imul(h ^ h >>> 15, 2246822519);
1599
- bars.push(1 + Math.abs(h) % 4);
1600
- }
1601
- bars.push(2, 3, 3, 1, 1, 2);
1602
- return bars;
1603
- }
1604
- var money = (v) => v.toFixed(2);
1605
- function paintReceipt(ctx, w, h, content, stock) {
1606
- const ink = stock.inkColor;
1607
- const pad = w * 0.09;
1608
- const colWidth = w - pad * 2;
1609
- const base = Math.round(w / 15);
1610
- const mono = (size, weight = 400) => `${weight} ${size}px ui-monospace, Menlo, Consolas, monospace`;
1611
- let y = h * 0.045;
1612
- const line = (step = 1.6) => y += base * step;
1613
- const center = (text, size = base, weight = 400) => {
1614
- ctx.font = mono(size, weight);
1615
- ctx.textAlign = "center";
1616
- ctx.fillText(text, w / 2, y);
1617
- };
1618
- const row = (left, right, size = base) => {
1619
- ctx.font = mono(size);
1620
- ctx.textAlign = "left";
1621
- ctx.fillText(left, pad, y);
1622
- ctx.textAlign = "right";
1623
- ctx.fillText(right, w - pad, y);
1624
- };
1625
- const divider = () => {
1626
- ctx.font = mono(base);
1627
- ctx.textAlign = "center";
1628
- ctx.fillText("- ".repeat(Math.floor(colWidth / (base * 1.1))).trim(), w / 2, y);
1629
- };
1630
- ctx.fillStyle = ink;
1631
- ctx.textBaseline = "top";
1632
- center(content.store.toUpperCase(), base * 1.5, 700);
1633
- line(2.4);
1634
- center(content.address.toUpperCase());
1635
- line(1.8);
1636
- divider();
1637
- line(1.8);
1638
- for (const item of content.items) {
1639
- row(item.name.toUpperCase(), money(item.price));
1640
- line();
1641
- }
1642
- line(0.4);
1643
- divider();
1644
- line(1.8);
1645
- const totals = receiptTotals(content);
1646
- row("SUBTOTAL", money(totals.subtotal));
1647
- line();
1648
- row(`TAX ${(content.taxRate * 100).toFixed(0)}%`, money(totals.tax));
1649
- line();
1650
- row("TOTAL", money(totals.total), base * 1.15);
1651
- line(2);
1652
- center(content.timestamp ?? (/* @__PURE__ */ new Date()).toLocaleString("en-GB"), base * 0.9);
1653
- line(2.2);
1654
- if (content.barcode) {
1655
- const bars = barcodeBars(content.store);
1656
- const modules = bars.reduce((a, b) => a + b, 0);
1657
- const module = colWidth * 0.85 / modules;
1658
- const barH = base * 3.2;
1659
- let x = (w - modules * module) / 2;
1660
- bars.forEach((width, i) => {
1661
- if (i % 2 === 0) ctx.fillRect(x, y, width * module, barH);
1662
- x += width * module;
1663
- });
1664
- y += barH;
1665
- line(1.8);
1666
- }
1667
- center(content.footer.toUpperCase(), base * 0.9);
1668
- }
1669
-
1670
1566
  // src/content/type.ts
1671
1567
  function wrapLines(ctx, text, maxWidth, font) {
1672
1568
  const previous = ctx.font;
@@ -1710,6 +1606,9 @@ async function ensureFont(font, size) {
1710
1606
  }
1711
1607
  }
1712
1608
 
1609
+ // src/deformers/registry.ts
1610
+ import { z as z21 } from "zod";
1611
+
1713
1612
  // src/deformers/roll.ts
1714
1613
  import { z as z15 } from "zod";
1715
1614
  var rollOptionsSchema = z15.object({
@@ -2233,7 +2132,6 @@ void FN(inout vec3 p, vec2 uv, float t) {
2233
2132
  };
2234
2133
 
2235
2134
  // src/deformers/registry.ts
2236
- import { z as z21 } from "zod";
2237
2135
  var registry = /* @__PURE__ */ new Map();
2238
2136
  function registerDeformer(deformer) {
2239
2137
  registry.set(deformer.id, deformer);
@@ -2277,134 +2175,6 @@ function stackIsAnimated(stack) {
2277
2175
  return stack.some((i) => i.enabled !== false && registry.get(i.type)?.animated);
2278
2176
  }
2279
2177
 
2280
- // src/deformers/compose.ts
2281
- import * as THREE2 from "three";
2282
-
2283
- // src/core/normals.ts
2284
- function computeSheetNormals(geometry) {
2285
- const index = geometry.index;
2286
- const normalAttr = geometry.attributes.normal;
2287
- const positionAttr = geometry.attributes.position;
2288
- if (!index || !normalAttr || !positionAttr) {
2289
- geometry.computeVertexNormals();
2290
- return;
2291
- }
2292
- const pos = positionAttr.array;
2293
- const nrm = normalAttr.array;
2294
- const idx = index.array;
2295
- nrm.fill(0);
2296
- for (let i = 0, l = idx.length; i < l; i += 3) {
2297
- const a = idx[i] * 3;
2298
- const b = idx[i + 1] * 3;
2299
- const c = idx[i + 2] * 3;
2300
- const bx = pos[b];
2301
- const by = pos[b + 1];
2302
- const bz = pos[b + 2];
2303
- const cbx = pos[c] - bx;
2304
- const cby = pos[c + 1] - by;
2305
- const cbz = pos[c + 2] - bz;
2306
- const abx = pos[a] - bx;
2307
- const aby = pos[a + 1] - by;
2308
- const abz = pos[a + 2] - bz;
2309
- const nx = cby * abz - cbz * aby;
2310
- const ny = cbz * abx - cbx * abz;
2311
- const nz = cbx * aby - cby * abx;
2312
- nrm[a] = nrm[a] + nx;
2313
- nrm[a + 1] = nrm[a + 1] + ny;
2314
- nrm[a + 2] = nrm[a + 2] + nz;
2315
- nrm[b] = nrm[b] + nx;
2316
- nrm[b + 1] = nrm[b + 1] + ny;
2317
- nrm[b + 2] = nrm[b + 2] + nz;
2318
- nrm[c] = nrm[c] + nx;
2319
- nrm[c + 1] = nrm[c + 1] + ny;
2320
- nrm[c + 2] = nrm[c + 2] + nz;
2321
- }
2322
- for (let i = 0, l = nrm.length; i < l; i += 3) {
2323
- const x = nrm[i];
2324
- const y = nrm[i + 1];
2325
- const z26 = nrm[i + 2];
2326
- const len = Math.sqrt(x * x + y * y + z26 * z26) || 1;
2327
- nrm[i] = x / len;
2328
- nrm[i + 1] = y / len;
2329
- nrm[i + 2] = z26 / len;
2330
- }
2331
- normalAttr.needsUpdate = true;
2332
- }
2333
-
2334
- // src/deformers/compose.ts
2335
- var scratchPos = new THREE2.Vector3();
2336
- var scratchUv = new THREE2.Vector2();
2337
- var activeFns = [];
2338
- var activeOptions = [];
2339
- function applyDeformerStack(geometry, basePositions, stack, ctx) {
2340
- const position = geometry.attributes.position;
2341
- const uv = geometry.attributes.uv;
2342
- const array = position.array;
2343
- const uvArray = uv.array;
2344
- const count = position.count;
2345
- activeFns.length = 0;
2346
- activeOptions.length = 0;
2347
- for (const instance of stack) {
2348
- if (instance.enabled === false) continue;
2349
- activeFns.push(getDeformer(instance.type).displace);
2350
- activeOptions.push(instance.options);
2351
- }
2352
- array.set(basePositions);
2353
- for (let k = 0; k < activeFns.length; k++) {
2354
- const displace = activeFns[k];
2355
- const options = activeOptions[k];
2356
- for (let v = 0; v < count; v++) {
2357
- const i3 = v * 3;
2358
- const i2 = v * 2;
2359
- scratchPos.set(array[i3], array[i3 + 1], array[i3 + 2]);
2360
- scratchUv.set(uvArray[i2], uvArray[i2 + 1]);
2361
- displace(scratchPos, scratchUv, options, ctx);
2362
- array[i3] = scratchPos.x;
2363
- array[i3 + 1] = scratchPos.y;
2364
- array[i3 + 2] = scratchPos.z;
2365
- }
2366
- }
2367
- position.needsUpdate = true;
2368
- computeSheetNormals(geometry);
2369
- }
2370
- function displacePoint(point, uvX, uvY, stack, ctx) {
2371
- scratchUv.set(uvX, uvY);
2372
- for (const instance of stack) {
2373
- if (instance.enabled === false) continue;
2374
- getDeformer(instance.type).displace(point, scratchUv, instance.options, ctx);
2375
- }
2376
- return point;
2377
- }
2378
- function stackMinSegments(stack, sheet2) {
2379
- const out = [2, 2];
2380
- for (const instance of stack) {
2381
- const deformer = getDeformer(instance.type);
2382
- const floor = deformer.geometry?.minSegments;
2383
- if (!floor) continue;
2384
- take(out, deformer, instance.options, sheet2, floor);
2385
- }
2386
- return out;
2387
- }
2388
- function stackAutoSegments(stack, sheet2) {
2389
- const out = [0, 0];
2390
- for (const instance of stack) {
2391
- if (instance.enabled === false) continue;
2392
- const deformer = getDeformer(instance.type);
2393
- const geometry = deformer.geometry;
2394
- if (!geometry) continue;
2395
- const want = geometry.autoSegments ? geometry.autoSegments(instance.options, sheet2) : geometry.minSegments ?? 0;
2396
- take(out, deformer, instance.options, sheet2, want);
2397
- }
2398
- return out;
2399
- }
2400
- function take(out, deformer, options, sheet2, demand) {
2401
- const declared = deformer.geometry?.axis?.(options, sheet2);
2402
- const angle = typeof declared === "number" && Number.isFinite(declared) ? declared : null;
2403
- const [x, y] = axialSegments(sheet2, angle, demand);
2404
- if (x > out[0]) out[0] = x;
2405
- if (y > out[1]) out[1] = y;
2406
- }
2407
-
2408
2178
  // src/behaviors/registry.ts
2409
2179
  var registry2 = /* @__PURE__ */ new Map();
2410
2180
  function registerBehavior(behavior) {
@@ -2495,209 +2265,6 @@ function getIdlePreset(name) {
2495
2265
  return idlePresets[name];
2496
2266
  }
2497
2267
 
2498
- // src/physics/cloth.ts
2499
- var FIXED_DT = 1 / 120;
2500
- var SOLVER_ITERATIONS = 5;
2501
- var SLEEP_EPSILON = 1e-6;
2502
- var SLEEP_FRAMES = 45;
2503
- var ClothSim = class {
2504
- cols;
2505
- rows;
2506
- count;
2507
- positions;
2508
- prev;
2509
- pinned;
2510
- pinTargets;
2511
- constraints = [];
2512
- params;
2513
- time = 0;
2514
- accumulator = 0;
2515
- stillFrames = 0;
2516
- grabbedIndex = -1;
2517
- /** True when the sim has settled and steps are skipped. */
2518
- asleep = false;
2519
- constructor(cols, rows, width, height, pins, params) {
2520
- this.cols = cols;
2521
- this.rows = rows;
2522
- this.count = cols * rows;
2523
- this.params = { ...params };
2524
- this.positions = new Float32Array(this.count * 3);
2525
- this.prev = new Float32Array(this.count * 3);
2526
- this.pinned = new Uint8Array(this.count);
2527
- this.pinTargets = new Float32Array(this.count * 3);
2528
- for (let r = 0; r < rows; r++) {
2529
- for (let c = 0; c < cols; c++) {
2530
- const i3 = (r * cols + c) * 3;
2531
- this.positions[i3] = (c / (cols - 1) - 0.5) * width;
2532
- this.positions[i3 + 1] = (0.5 - r / (rows - 1)) * height;
2533
- this.positions[i3 + 2] = 0;
2534
- }
2535
- }
2536
- this.prev.set(this.positions);
2537
- const idx = (r, c) => r * cols + c;
2538
- const link = (a, b, kind) => {
2539
- const dx = this.positions[a * 3] - this.positions[b * 3];
2540
- const dy = this.positions[a * 3 + 1] - this.positions[b * 3 + 1];
2541
- this.constraints.push({ a, b, rest: Math.hypot(dx, dy), kind });
2542
- };
2543
- for (let r = 0; r < rows; r++) {
2544
- for (let c = 0; c < cols; c++) {
2545
- if (c + 1 < cols) link(idx(r, c), idx(r, c + 1), 0);
2546
- if (r + 1 < rows) link(idx(r, c), idx(r + 1, c), 0);
2547
- if (c + 1 < cols && r + 1 < rows) {
2548
- link(idx(r, c), idx(r + 1, c + 1), 1);
2549
- link(idx(r, c + 1), idx(r + 1, c), 1);
2550
- }
2551
- if (c + 2 < cols) link(idx(r, c), idx(r, c + 2), 2);
2552
- if (r + 2 < rows) link(idx(r, c), idx(r + 2, c), 2);
2553
- }
2554
- }
2555
- const pin = (r, c) => {
2556
- const i = idx(r, c);
2557
- this.pinned[i] = 1;
2558
- this.pinTargets.set(this.positions.subarray(i * 3, i * 3 + 3), i * 3);
2559
- };
2560
- if (pins === "top-edge") for (let c = 0; c < cols; c++) pin(0, c);
2561
- if (pins === "top-corners") {
2562
- pin(0, 0);
2563
- pin(0, cols - 1);
2564
- }
2565
- if (pins === "corner") pin(0, 0);
2566
- }
2567
- setParams(params) {
2568
- let changed = false;
2569
- for (const key of ["stiffness", "gravity", "wind", "floor"]) {
2570
- const value = params[key];
2571
- if (value !== void 0 && value !== this.params[key]) {
2572
- this.params[key] = value;
2573
- changed = true;
2574
- }
2575
- }
2576
- if (changed) this.wake();
2577
- }
2578
- wake() {
2579
- this.asleep = false;
2580
- this.stillFrames = 0;
2581
- }
2582
- /** Nearest particle to a local-space point — the grab interface. */
2583
- grabNearest(x, y, z26) {
2584
- let best = -1;
2585
- let bestDist = Infinity;
2586
- for (let i = 0; i < this.count; i++) {
2587
- const dx = this.positions[i * 3] - x;
2588
- const dy = this.positions[i * 3 + 1] - y;
2589
- const dz = this.positions[i * 3 + 2] - z26;
2590
- const d = dx * dx + dy * dy + dz * dz;
2591
- if (d < bestDist) {
2592
- bestDist = d;
2593
- best = i;
2594
- }
2595
- }
2596
- this.grabbedIndex = best;
2597
- this.wake();
2598
- return best;
2599
- }
2600
- moveGrab(x, y, z26) {
2601
- if (this.grabbedIndex < 0) return;
2602
- const i3 = this.grabbedIndex * 3;
2603
- this.positions[i3] = x;
2604
- this.positions[i3 + 1] = y;
2605
- this.positions[i3 + 2] = z26;
2606
- this.prev[i3] = x;
2607
- this.prev[i3 + 1] = y;
2608
- this.prev[i3 + 2] = z26;
2609
- this.wake();
2610
- }
2611
- release() {
2612
- this.grabbedIndex = -1;
2613
- }
2614
- step(delta) {
2615
- if (this.asleep) return;
2616
- this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
2617
- while (this.accumulator >= FIXED_DT) {
2618
- this.substep(FIXED_DT);
2619
- this.accumulator -= FIXED_DT;
2620
- }
2621
- }
2622
- substep(dt) {
2623
- const { gravity, wind, stiffness, floor } = this.params;
2624
- const p = this.positions;
2625
- const damping = 0.985;
2626
- const dt2 = dt * dt;
2627
- this.time += dt;
2628
- let maxTravel = 0;
2629
- for (let i = 0; i < this.count; i++) {
2630
- const i3 = i * 3;
2631
- if (this.pinned[i] || i === this.grabbedIndex) {
2632
- if (this.pinned[i]) {
2633
- p[i3] = this.pinTargets[i3];
2634
- p[i3 + 1] = this.pinTargets[i3 + 1];
2635
- p[i3 + 2] = this.pinTargets[i3 + 2];
2636
- }
2637
- this.prev[i3] = p[i3];
2638
- this.prev[i3 + 1] = p[i3 + 1];
2639
- this.prev[i3 + 2] = p[i3 + 2];
2640
- continue;
2641
- }
2642
- const x = p[i3];
2643
- const y = p[i3 + 1];
2644
- const z26 = p[i3 + 2];
2645
- const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
2646
- const ax = gust2 * 0.25;
2647
- const az = gust2;
2648
- const vx = (x - this.prev[i3]) * damping;
2649
- const vy = (y - this.prev[i3 + 1]) * damping;
2650
- const vz = (z26 - this.prev[i3 + 2]) * damping;
2651
- this.prev[i3] = x;
2652
- this.prev[i3 + 1] = y;
2653
- this.prev[i3 + 2] = z26;
2654
- p[i3] = x + vx + ax * dt2;
2655
- p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
2656
- p[i3 + 2] = z26 + vz + az * dt2;
2657
- maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
2658
- }
2659
- for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
2660
- for (const c of this.constraints) {
2661
- const k = c.kind === 2 ? 0.25 + stiffness * 0.7 : c.kind === 1 ? 0.85 : 1;
2662
- const a3 = c.a * 3;
2663
- const b3 = c.b * 3;
2664
- const dx = p[b3] - p[a3];
2665
- const dy = p[b3 + 1] - p[a3 + 1];
2666
- const dz = p[b3 + 2] - p[a3 + 2];
2667
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
2668
- if (dist === 0) continue;
2669
- const diff = (dist - c.rest) / dist * 0.5 * k;
2670
- const aPinned = this.pinned[c.a] || c.a === this.grabbedIndex;
2671
- const bPinned = this.pinned[c.b] || c.b === this.grabbedIndex;
2672
- if (aPinned && bPinned) continue;
2673
- const aw = aPinned ? 0 : bPinned ? 2 : 1;
2674
- const bw = bPinned ? 0 : aPinned ? 2 : 1;
2675
- p[a3] = p[a3] + dx * diff * aw;
2676
- p[a3 + 1] = p[a3 + 1] + dy * diff * aw;
2677
- p[a3 + 2] = p[a3 + 2] + dz * diff * aw;
2678
- p[b3] = p[b3] - dx * diff * bw;
2679
- p[b3 + 1] = p[b3 + 1] - dy * diff * bw;
2680
- p[b3 + 2] = p[b3 + 2] - dz * diff * bw;
2681
- }
2682
- }
2683
- for (let i = 0; i < this.count; i++) {
2684
- const i3 = i * 3;
2685
- if (p[i3 + 1] < floor) {
2686
- p[i3 + 1] = floor;
2687
- this.prev[i3] = this.prev[i3] + (p[i3] - this.prev[i3]) * 0.5;
2688
- this.prev[i3 + 2] = this.prev[i3 + 2] + (p[i3 + 2] - this.prev[i3 + 2]) * 0.5;
2689
- }
2690
- }
2691
- if (wind === 0 && this.grabbedIndex < 0) {
2692
- if (maxTravel < SLEEP_EPSILON) {
2693
- if (++this.stillFrames > SLEEP_FRAMES) this.asleep = true;
2694
- } else {
2695
- this.stillFrames = 0;
2696
- }
2697
- }
2698
- }
2699
- };
2700
-
2701
2268
  // src/scene/lighting.ts
2702
2269
  import { z as z22 } from "zod";
2703
2270
  var lightingPresets = {
@@ -2943,1060 +2510,1505 @@ function resolveFog(fog, haze) {
2943
2510
  return { color: fog.color, near: fog.near / haze, far: fog.far / haze };
2944
2511
  }
2945
2512
 
2946
- // src/surface/translucency.ts
2947
- import * as THREE3 from "three";
2948
- var TRANSLUCENCY_VARYINGS = (
2949
- /* glsl */
2950
- `
2951
- varying vec3 vPlWorldNormal;
2952
- varying vec3 vPlViewDir;
2953
- `
2954
- );
2955
- function translucencyVertexChunk(slots) {
2956
- return (
2957
- /* glsl */
2958
- `
2959
- {
2960
- mat4 plModel = ${slots.model};
2961
- vec4 plWorld = plModel * vec4(${slots.position}, 1.0);
2962
- // Uniform scale only \u2014 layouts scale sheets evenly, so the plain 3\xD73 is
2963
- // the correct normal matrix here and skips an inverse-transpose.
2964
- vPlWorldNormal = normalize(mat3(plModel) * ${slots.normal});
2965
- vPlViewDir = cameraPosition - plWorld.xyz;
2966
- }
2967
- `
2968
- );
2513
+ // src/scene/rig.tsx
2514
+ import { createContext, useContext } from "react";
2515
+ import { jsx } from "react/jsx-runtime";
2516
+ var LightRigContext = createContext(null);
2517
+ function LightRig({ rig, children }) {
2518
+ return /* @__PURE__ */ jsx(LightRigContext.Provider, { value: rig, children });
2969
2519
  }
2970
- var TRANSMISSION_GAIN = 0.5;
2971
- var TRANSLUCENCY_FRAGMENT = (
2972
- /* glsl */
2973
- `
2974
- uniform float uTranslucency;
2975
- uniform vec3 uBackLightDir;
2976
- uniform vec3 uBackLightColor;
2977
- uniform float uAmbientTransmission;
2978
- ${TRANSLUCENCY_VARYINGS}
2979
-
2980
- vec3 plTransmission(vec3 inkFilter) {
2981
- if (uTranslucency <= 0.0) return vec3(0.0);
2982
- vec3 n = normalize(vPlWorldNormal);
2983
- // Sheets render double-sided; the back face needs the normal it actually shows.
2984
- if (!gl_FrontFacing) n = -n;
2985
- // The lamp is BEHIND this sheet when the face we are looking at points away
2986
- // from it \u2014 that is the whole test.
2987
- float behind = clamp(-dot(n, uBackLightDir), 0.0, 1.0);
2988
- // A grazing view looks through more paper, and more paper passes less light.
2989
- float thickness = abs(dot(n, normalize(vPlViewDir)));
2990
- // Paper in a lit room glows whatever way it is turned \u2014 a sheet standing
2991
- // edge-on to the only lamp is not black. Without this floor, a banner
2992
- // whose face runs parallel to the key light gets neither diffuse nor
2993
- // transmission and drops out of the picture entirely.
2994
- vec3 arriving = uBackLightColor * behind + uAmbientTransmission;
2995
- return arriving * uTranslucency * mix(0.25, 1.0, thickness) * inkFilter;
2520
+ function useLightRig(own) {
2521
+ return useContext(LightRigContext) ?? getLightingPreset(own);
2996
2522
  }
2997
- `
2998
- );
2999
- function translucencyValues(translucency, lighting) {
3000
- const preset = typeof lighting === "string" ? getLightingPreset(lighting) : lighting;
3001
- const [x, y, z26] = preset.key.position;
3002
- const direction = new THREE3.Vector3(x, y, z26);
3003
- if (direction.lengthSq() < 1e-12) direction.set(0, 1, 0);
3004
- direction.normalize();
3005
- const color = new THREE3.Color(preset.key.color).multiplyScalar(preset.key.intensity * TRANSMISSION_GAIN);
3006
- return { translucency, direction, color, ambient: preset.ambient * TRANSMISSION_GAIN };
3007
- }
3008
- function translucencyUniforms(translucency, lighting) {
3009
- const values = translucencyValues(translucency, lighting);
3010
- return {
3011
- uTranslucency: { value: values.translucency },
3012
- uBackLightDir: { value: values.direction },
3013
- uBackLightColor: { value: values.color },
3014
- uAmbientTransmission: { value: values.ambient }
3015
- };
3016
- }
3017
-
3018
- // src/surface/compose.ts
3019
- import * as THREE4 from "three";
3020
- var VERTEX = (
3021
- /* glsl */
3022
- `
3023
- varying vec2 vPaperUv;
3024
- ${TRANSLUCENCY_VARYINGS}
3025
- void main() {
3026
- vPaperUv = uv;
3027
- ${translucencyVertexChunk({ model: "modelMatrix", position: "position", normal: "normal" })}
3028
- }
3029
- `
3030
- );
3031
- var HELPERS = (
3032
- /* glsl */
3033
- `
3034
- varying vec2 vPaperUv;
3035
- uniform float uBackDarken;
3036
2523
 
3037
- float plHash(vec2 p) {
3038
- return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
3039
- }
2524
+ // src/a11y/index.tsx
2525
+ import { useEffect, useState } from "react";
3040
2526
 
3041
- float plNoise(vec2 p) {
3042
- vec2 i = floor(p);
3043
- vec2 f = fract(p);
3044
- vec2 u = f * f * (3.0 - 2.0 * f);
3045
- return mix(
3046
- mix(plHash(i), plHash(i + vec2(1.0, 0.0)), u.x),
3047
- mix(plHash(i + vec2(0.0, 1.0)), plHash(i + vec2(1.0, 1.0)), u.x),
3048
- u.y
3049
- );
2527
+ // src/content/receipt.ts
2528
+ function receiptTotals(content) {
2529
+ const subtotal = content.items.reduce((sum, item) => sum + item.price, 0);
2530
+ const tax = subtotal * content.taxRate;
2531
+ return { subtotal, tax, total: subtotal + tax };
3050
2532
  }
3051
-
3052
- float plFbm(vec2 p) {
3053
- float v = 0.0;
3054
- float a = 0.5;
3055
- for (int i = 0; i < 4; i++) {
3056
- v += a * plNoise(p);
3057
- p *= 2.03;
3058
- a *= 0.5;
2533
+ function barcodeBars(seed) {
2534
+ let h = 2166136261;
2535
+ for (let i = 0; i < seed.length; i++) {
2536
+ h ^= seed.charCodeAt(i);
2537
+ h = Math.imul(h, 16777619);
3059
2538
  }
3060
- return v;
2539
+ const bars = [2, 1, 1, 4];
2540
+ for (let i = 0; i < 30; i++) {
2541
+ h = Math.imul(h ^ h >>> 15, 2246822519);
2542
+ bars.push(1 + Math.abs(h) % 4);
2543
+ }
2544
+ bars.push(2, 3, 3, 1, 1, 2);
2545
+ return bars;
3061
2546
  }
3062
- `
3063
- );
3064
- var edgeFlags = (edges) => new THREE4.Vector4(
3065
- edges.includes("top") ? 1 : 0,
3066
- edges.includes("right") ? 1 : 0,
3067
- edges.includes("bottom") ? 1 : 0,
3068
- edges.includes("left") ? 1 : 0
3069
- );
3070
- var GRAIN_CHUNK = (
3071
- /* glsl */
3072
- `
3073
- uniform float uGrainAmount;
3074
- uniform float uGrainBanding;
3075
-
3076
- void plGrain(inout vec4 color, inout float rough) {
3077
- float fiber = plFbm(vPaperUv * 240.0);
3078
- float fleck = plNoise(vPaperUv * 900.0);
3079
- float g = mix(0.5, fiber * 0.75 + fleck * 0.25, uGrainAmount);
3080
- color.rgb *= 0.92 + g * 0.16;
3081
- rough = clamp(rough + (g - 0.5) * uGrainAmount * 0.35, 0.0, 1.0);
3082
- // Thermal-printer banding: faint horizontal density stripes.
3083
- if (uGrainBanding > 0.0) {
3084
- float band = sin(vPaperUv.y * 700.0) * 0.5 + 0.5;
3085
- color.rgb *= 1.0 - uGrainBanding * 0.05 * band;
2547
+ var money = (v) => v.toFixed(2);
2548
+ function paintReceipt(ctx, w, h, content, stock) {
2549
+ const ink = stock.inkColor;
2550
+ const pad = w * 0.09;
2551
+ const colWidth = w - pad * 2;
2552
+ const base = Math.round(w / 15);
2553
+ const mono = (size, weight = 400) => `${weight} ${size}px ui-monospace, Menlo, Consolas, monospace`;
2554
+ let y = h * 0.045;
2555
+ const line = (step = 1.6) => y += base * step;
2556
+ const center = (text, size = base, weight = 400) => {
2557
+ ctx.font = mono(size, weight);
2558
+ ctx.textAlign = "center";
2559
+ ctx.fillText(text, w / 2, y);
2560
+ };
2561
+ const row = (left, right, size = base) => {
2562
+ ctx.font = mono(size);
2563
+ ctx.textAlign = "left";
2564
+ ctx.fillText(left, pad, y);
2565
+ ctx.textAlign = "right";
2566
+ ctx.fillText(right, w - pad, y);
2567
+ };
2568
+ const divider = () => {
2569
+ ctx.font = mono(base);
2570
+ ctx.textAlign = "center";
2571
+ ctx.fillText("- ".repeat(Math.floor(colWidth / (base * 1.1))).trim(), w / 2, y);
2572
+ };
2573
+ ctx.fillStyle = ink;
2574
+ ctx.textBaseline = "top";
2575
+ center(content.store.toUpperCase(), base * 1.5, 700);
2576
+ line(2.4);
2577
+ center(content.address.toUpperCase());
2578
+ line(1.8);
2579
+ divider();
2580
+ line(1.8);
2581
+ for (const item of content.items) {
2582
+ row(item.name.toUpperCase(), money(item.price));
2583
+ line();
2584
+ }
2585
+ line(0.4);
2586
+ divider();
2587
+ line(1.8);
2588
+ const totals = receiptTotals(content);
2589
+ row("SUBTOTAL", money(totals.subtotal));
2590
+ line();
2591
+ row(`TAX ${(content.taxRate * 100).toFixed(0)}%`, money(totals.tax));
2592
+ line();
2593
+ row("TOTAL", money(totals.total), base * 1.15);
2594
+ line(2);
2595
+ center(content.timestamp ?? (/* @__PURE__ */ new Date()).toLocaleString("en-GB"), base * 0.9);
2596
+ line(2.2);
2597
+ if (content.barcode) {
2598
+ const bars = barcodeBars(content.store);
2599
+ const modules = bars.reduce((a, b) => a + b, 0);
2600
+ const module = colWidth * 0.85 / modules;
2601
+ const barH = base * 3.2;
2602
+ let x = (w - modules * module) / 2;
2603
+ bars.forEach((width, i) => {
2604
+ if (i % 2 === 0) ctx.fillRect(x, y, width * module, barH);
2605
+ x += width * module;
2606
+ });
2607
+ y += barH;
2608
+ line(1.8);
3086
2609
  }
2610
+ center(content.footer.toUpperCase(), base * 0.9);
3087
2611
  }
3088
- `
3089
- );
3090
- var DECKLE_CHUNK = (
3091
- /* glsl */
3092
- `
3093
- uniform vec4 uDeckleEdges; // top, right, bottom, left
3094
- uniform float uDeckleRoughness;
3095
2612
 
3096
- void plDeckle(inout vec4 color) {
3097
- // Distance to each selected edge, gnawed by low-frequency noise.
3098
- float depth = 0.012 + uDeckleRoughness * 0.05;
3099
- float tear = 1.0;
3100
- float fiberBand = 0.0;
3101
- vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3102
- vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
3103
- for (int e = 0; e < 4; e++) {
3104
- if (uDeckleEdges[e] < 0.5) continue;
3105
- float n = plFbm(vec2(alongs[e] * 26.0, float(e) * 7.31)) - 0.5;
3106
- float boundary = depth * (0.55 + n * 1.6);
3107
- float d = dists[e] - boundary;
3108
- tear = min(tear, step(0.0, d));
3109
- // Lightened fiber band just inside the tear.
3110
- fiberBand = max(fiberBand, smoothstep(depth * 1.4, 0.0, d) * step(0.0, d));
2613
+ // src/a11y/index.tsx
2614
+ import { jsx as jsx2 } from "react/jsx-runtime";
2615
+ function usePrefersReducedMotion(override) {
2616
+ const [system, setSystem] = useState(
2617
+ () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
2618
+ );
2619
+ useEffect(() => {
2620
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
2621
+ if (!query) return;
2622
+ const onChange = () => setSystem(query.matches);
2623
+ query.addEventListener("change", onChange);
2624
+ return () => query.removeEventListener("change", onChange);
2625
+ }, []);
2626
+ return override ?? system;
2627
+ }
2628
+ var webglSupport = null;
2629
+ function supportsWebGL() {
2630
+ if (webglSupport !== null) return webglSupport;
2631
+ try {
2632
+ const canvas = document.createElement("canvas");
2633
+ webglSupport = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
2634
+ } catch {
2635
+ webglSupport = false;
3111
2636
  }
3112
- color.a *= tear;
3113
- color.rgb = mix(color.rgb, vec3(1.0), fiberBand * 0.35);
2637
+ return webglSupport;
3114
2638
  }
3115
- `
3116
- );
3117
- var CREASE_CHUNK = (
3118
- /* glsl */
3119
- `
3120
- uniform float uCreaseAngle;
3121
- uniform float uCreaseStrength;
3122
- uniform float uCreasePositions[4];
3123
- uniform int uCreaseCount;
3124
-
3125
- void plCrease(inout vec4 color, inout float rough) {
3126
- vec2 dir = vec2(cos(uCreaseAngle), sin(uCreaseAngle));
3127
- // Coordinate across the crease lines (0..1 over the sheet).
3128
- float t = dot(vPaperUv - 0.5, vec2(-dir.y, dir.x)) + 0.5;
3129
- for (int i = 0; i < 4; i++) {
3130
- if (i >= uCreaseCount) break;
3131
- float d = abs(t - uCreasePositions[i]);
3132
- float shadow = smoothstep(0.014, 0.0, d);
3133
- float sheen = smoothstep(0.02, 0.006, d) - smoothstep(0.006, 0.0, d);
3134
- color.rgb *= 1.0 - shadow * uCreaseStrength * 0.28;
3135
- color.rgb += sheen * uCreaseStrength * 0.05;
3136
- rough = clamp(rough + shadow * uCreaseStrength * 0.2, 0.0, 1.0);
2639
+ function contentText(config) {
2640
+ const content = config.content;
2641
+ if (content.type === "text") return content.text;
2642
+ if (content.type === "image") return content.alt ?? "An image printed on paper.";
2643
+ if (content.type === "receipt") {
2644
+ const totals = receiptTotals(content);
2645
+ const items = content.items.map((i) => `${i.name} ${i.price.toFixed(2)}`).join(", ");
2646
+ return `Receipt from ${content.store}: ${items}. Total ${totals.total.toFixed(2)}. ${content.footer}`;
3137
2647
  }
2648
+ return "A blank sheet of paper.";
3138
2649
  }
3139
- `
3140
- );
3141
- var PERFORATION_CHUNK = (
3142
- /* glsl */
3143
- `
3144
- uniform vec4 uPerfEdges; // top, right, bottom, left enabled
3145
- uniform vec4 uPerfTorn; // 1 = ripped-through profile, 0 = clean punches
3146
- uniform float uPerfRadius; // world units
3147
- uniform float uPerfSpacing;
3148
- uniform vec2 uSheetSize;
3149
-
3150
- void plPerforation(inout vec4 color) {
3151
- // Per-edge distance/along coordinates, converted from UV to world units so
3152
- // hole size is stable across sheet dimensions.
3153
- vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3154
- vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
3155
- vec4 distScale = vec4(uSheetSize.y, uSheetSize.x, uSheetSize.y, uSheetSize.x);
3156
- vec4 alongScale = vec4(uSheetSize.x, uSheetSize.y, uSheetSize.x, uSheetSize.y);
3157
- float fiber = 0.0;
3158
- for (int e = 0; e < 4; e++) {
3159
- if (uPerfEdges[e] < 0.5) continue;
3160
- float d = dists[e] * distScale[e];
3161
- float a = alongs[e] * alongScale[e];
3162
- // Signed distance along the edge to the nearest hole center.
3163
- float cell = mod(a + uPerfSpacing * 0.5, uPerfSpacing) - uPerfSpacing * 0.5;
3164
- if (uPerfTorn[e] < 0.5) {
3165
- // Intact: clean semicircular punches on the edge line (alphaTest, not
3166
- // blending \u2014 shadow correctness).
3167
- if (length(vec2(cell, d)) < uPerfRadius) color.a = 0.0;
3168
- } else {
3169
- // Torn: ripped profile following the hole rhythm \u2014 alternating tabs and
3170
- // notches, gnawed by noise, with a lightened fiber band along the tear.
3171
- float rhythm = abs(sin(a / uPerfSpacing * 3.14159265));
3172
- float n = plNoise(vec2(a * 40.0, float(e) * 7.31)) - 0.5;
3173
- float cut = uPerfRadius * (0.35 + rhythm * 1.35 + n * 0.9);
3174
- if (d < cut) color.a = 0.0;
3175
- fiber = max(fiber, smoothstep(uPerfRadius * 2.4, 0.0, d - cut) * step(cut, d));
2650
+ var visuallyHidden = {
2651
+ position: "absolute",
2652
+ width: 1,
2653
+ height: 1,
2654
+ padding: 0,
2655
+ margin: -1,
2656
+ overflow: "hidden",
2657
+ clip: "rect(0 0 0 0)",
2658
+ whiteSpace: "nowrap",
2659
+ border: 0
2660
+ };
2661
+ function PaperMirror({ config }) {
2662
+ return /* @__PURE__ */ jsx2("div", { style: visuallyHidden, "aria-hidden": false, children: contentText(config) });
2663
+ }
2664
+ function PaperFallback({ config }) {
2665
+ const stock = getStock(config.stock);
2666
+ const isImage = config.content.type === "image";
2667
+ return /* @__PURE__ */ jsx2(
2668
+ "div",
2669
+ {
2670
+ role: "img",
2671
+ "aria-label": contentText(config),
2672
+ style: {
2673
+ width: "100%",
2674
+ height: "100%",
2675
+ display: "grid",
2676
+ placeItems: "center",
2677
+ background: "transparent"
2678
+ },
2679
+ children: /* @__PURE__ */ jsx2(
2680
+ "div",
2681
+ {
2682
+ style: {
2683
+ aspectRatio: `${config.sheet.width} / ${config.sheet.height}`,
2684
+ maxWidth: "80%",
2685
+ maxHeight: "90%",
2686
+ background: stock.color,
2687
+ color: stock.inkColor,
2688
+ boxShadow: "0 6px 24px rgba(0,0,0,0.25)",
2689
+ padding: "8%",
2690
+ overflow: "hidden",
2691
+ fontFamily: config.content.type === "receipt" ? "ui-monospace, monospace" : "Georgia, serif",
2692
+ whiteSpace: "pre-wrap",
2693
+ fontSize: 14
2694
+ },
2695
+ children: isImage && config.content.type === "image" ? /* @__PURE__ */ jsx2(
2696
+ "img",
2697
+ {
2698
+ src: config.content.src,
2699
+ alt: config.content.alt ?? "",
2700
+ style: { width: "100%", height: "100%", objectFit: "cover", margin: "-8%" }
2701
+ }
2702
+ ) : contentText(config)
2703
+ }
2704
+ )
3176
2705
  }
3177
- }
3178
- color.rgb = mix(color.rgb, vec3(1.0), fiber * 0.4);
2706
+ );
3179
2707
  }
3180
- `
3181
- );
3182
- var AGING_CHUNK = (
3183
- /* glsl */
3184
- `
3185
- uniform float uAgingAmount;
3186
2708
 
3187
- void plAging(inout vec4 color) {
3188
- // Yellowing deepens toward the edges, like light exposure.
3189
- float edge = max(abs(vPaperUv.x - 0.5), abs(vPaperUv.y - 0.5)) * 2.0;
3190
- vec3 yellowed = color.rgb * vec3(1.0, 0.94, 0.78);
3191
- color.rgb = mix(color.rgb, yellowed, uAgingAmount * (0.45 + edge * 0.55));
3192
- // Foxing: sparse rusty blotches.
3193
- float fox = plFbm(vPaperUv * 14.0 + 3.7);
3194
- float spots = smoothstep(0.62, 0.78, fox) * uAgingAmount;
3195
- color.rgb = mix(color.rgb, vec3(0.62, 0.45, 0.26), spots * 0.5);
2709
+ // src/states/machine.ts
2710
+ import { gsap } from "gsap";
2711
+ var stateEventTransitions = {
2712
+ rest: { enter: "hover" },
2713
+ hover: { leave: "rest", down: "pressed" },
2714
+ pressed: { up: "hover", pick: "picked" },
2715
+ picked: { place: "placed", return: "rest" },
2716
+ placed: {}
2717
+ };
2718
+ var DEFAULT_TRANSITION = { duration: 0.35, ease: "power2.out" };
2719
+ function stripStates(config) {
2720
+ if (!config.states) return config;
2721
+ const { states: _states, ...rest } = config;
2722
+ return rest;
3196
2723
  }
3197
- `
3198
- );
3199
- function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
3200
- const grain = surface.grain ?? stock.defaultSurface.grain;
3201
- const aging = surface.aging ?? stock.defaultSurface.aging;
3202
- const deckle = surface.deckle;
3203
- const creases = surface.creaseLines;
3204
- const perforation = surface.perforation;
3205
- const banding = stock.banding;
3206
- const showThrough = stock.adhesive ? 0 : surface.showThrough ?? stock.showThrough;
3207
- const chunks = [];
3208
- const calls = [];
3209
- const uniforms = {
3210
- // Backside darkening: thicker/opaque stock lets less light through.
3211
- // Adhesive backs skip it — the glue layer is its own bright surface.
3212
- uBackDarken: {
3213
- value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
3214
- },
3215
- uStockColor: { value: new THREE4.Color(stock.color) },
3216
- uOpacity: { value: stock.opacity },
3217
- uShowThrough: { value: showThrough },
3218
- // Always compiled in: the shader early-outs at zero translucency, which
3219
- // is cheaper than carrying a second program structure for it.
3220
- ...translucencyUniforms(surface.translucency ?? stock.translucency, lighting)
3221
- };
3222
- if (maps.hasFrontMap) uniforms.uFrontMap = { value: null };
3223
- if (maps.hasBackMap) uniforms.uBackMap = { value: null };
3224
- if (grain !== void 0 || banding > 0) {
3225
- chunks.push(GRAIN_CHUNK);
3226
- calls.push("plGrain(csm_DiffuseColor, csm_Roughness);");
3227
- uniforms.uGrainAmount = { value: grain ?? 0 };
3228
- uniforms.uGrainBanding = { value: banding };
2724
+ function resolveStateConfig(base, state) {
2725
+ const def = base.states?.states[state];
2726
+ const flat = stripStates(base);
2727
+ if (!def || Object.keys(def.overrides).length === 0) return flat;
2728
+ return paperConfigSchema.parse(mergeConfig(flat, def.overrides));
2729
+ }
2730
+ function pruneUndefined(value) {
2731
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
2732
+ const out = {};
2733
+ for (const [key, v] of Object.entries(value)) {
2734
+ if (v === void 0) continue;
2735
+ const pruned = pruneUndefined(v);
2736
+ const isEmptyObject = pruned !== null && typeof pruned === "object" && !Array.isArray(pruned) && Object.keys(pruned).length === 0;
2737
+ if (!isEmptyObject) out[key] = pruned;
3229
2738
  }
3230
- if (deckle) {
3231
- chunks.push(DECKLE_CHUNK);
3232
- calls.push("plDeckle(csm_DiffuseColor);");
3233
- uniforms.uDeckleEdges = { value: edgeFlags(deckle.edges) };
3234
- uniforms.uDeckleRoughness = { value: deckle.roughness };
2739
+ return out;
2740
+ }
2741
+ function recordStateOverride(config, stateName, patch) {
2742
+ const cleaned = pruneUndefined(patch);
2743
+ const states = config.states ?? paperStatesSchema.parse({});
2744
+ const def = states.states[stateName] ?? stateDefSchema.parse({});
2745
+ const overrides = mergeConfig(def.overrides, cleaned);
2746
+ return paperConfigSchema.parse({
2747
+ ...config,
2748
+ states: {
2749
+ ...states,
2750
+ states: { ...states.states, [stateName]: { ...def, overrides } }
2751
+ }
2752
+ });
2753
+ }
2754
+ function flattenNumeric(value, prefix = "", out = {}) {
2755
+ if (typeof value === "number") {
2756
+ if (prefix) out[prefix] = value;
2757
+ return out;
3235
2758
  }
3236
- if (perforation) {
3237
- const edges = perforation.edges === "all" ? [...paperEdges] : perforation.edges;
3238
- chunks.push(PERFORATION_CHUNK);
3239
- calls.push("plPerforation(csm_DiffuseColor);");
3240
- uniforms.uPerfEdges = { value: edgeFlags(edges) };
3241
- uniforms.uPerfTorn = {
3242
- value: new THREE4.Vector4(
3243
- ...paperEdges.map((e) => edges.includes(e) && perforation.state[e] === "torn" ? 1 : 0)
3244
- )
2759
+ if (value !== null && typeof value === "object") {
2760
+ const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
2761
+ for (const [key, v] of entries) {
2762
+ flattenNumeric(v, prefix ? `${prefix}.${key}` : key, out);
2763
+ }
2764
+ }
2765
+ return out;
2766
+ }
2767
+ function setPath(target, path, value) {
2768
+ const keys = path.split(".");
2769
+ let node = target;
2770
+ for (let i = 0; i < keys.length - 1; i++) {
2771
+ const next = node[keys[i]];
2772
+ if (next === null || typeof next !== "object") return;
2773
+ node = next;
2774
+ }
2775
+ node[keys[keys.length - 1]] = value;
2776
+ }
2777
+ function applyFlat(target, flat) {
2778
+ for (const path in flat) setPath(target, path, flat[path]);
2779
+ }
2780
+ var clone = (v) => JSON.parse(JSON.stringify(v));
2781
+ var PaperStateMachine = class {
2782
+ state;
2783
+ base;
2784
+ opts;
2785
+ /** Structural target of the current state; numeric leaves live in `flat`. */
2786
+ structure;
2787
+ /**
2788
+ * The single live tween target — flattened numeric leaves of the config.
2789
+ * STABLE IDENTITY for the machine's lifetime: `goto`/`rebase` mutate it in
2790
+ * place (add/remove/keep keys) and never reassign, so an in-flight GSAP
2791
+ * tween keeps animating the same object across a rebase instead of freezing.
2792
+ */
2793
+ flat = {};
2794
+ /** Mutable working config, polled by the consumer's frame loop via `liveConfig`. */
2795
+ live;
2796
+ tween = null;
2797
+ resolved = /* @__PURE__ */ new Map();
2798
+ constructor(base, opts = {}) {
2799
+ this.base = base;
2800
+ this.opts = opts;
2801
+ this.state = base.states?.initial ?? "rest";
2802
+ const target = this.resolve(this.state);
2803
+ this.structure = clone(target);
2804
+ this.live = clone(target);
2805
+ Object.assign(this.flat, flattenNumeric(target));
2806
+ }
2807
+ /** World-units drag distance that flips pressed → picked. */
2808
+ get pickThreshold() {
2809
+ return this.base.states?.pickThreshold ?? 0.1;
2810
+ }
2811
+ /**
2812
+ * The live config — the mutable working object with the current tween values
2813
+ * applied in place. Cheap (no allocation); meant to be polled every frame.
2814
+ * Never hand this to React; use `structuralConfig()` for an immutable snapshot.
2815
+ */
2816
+ get liveConfig() {
2817
+ applyFlat(this.live, this.flat);
2818
+ return this.live;
2819
+ }
2820
+ /** Back-compat alias for `liveConfig` (tests, imperative reads). */
2821
+ get config() {
2822
+ return this.liveConfig;
2823
+ }
2824
+ /** True while a transition tween is in flight — consumers gate frame work on it. */
2825
+ get transitioning() {
2826
+ return this.tween !== null;
2827
+ }
2828
+ /** Exposed for tests: the in-flight transition tween, if any. */
2829
+ get activeTween() {
2830
+ return this.tween;
2831
+ }
2832
+ /** An immutable structural snapshot for React consumers (never the live object). */
2833
+ structuralConfig() {
2834
+ const out = clone(this.structure);
2835
+ applyFlat(out, this.flat);
2836
+ return out;
2837
+ }
2838
+ /** Fire a built-in trigger; returns the new state or null if it doesn't apply. */
2839
+ send(event) {
2840
+ const next = stateEventTransitions[this.state]?.[event];
2841
+ if (!next || next === this.state) return null;
2842
+ this.goto(next);
2843
+ return next;
2844
+ }
2845
+ /**
2846
+ * Drive to 'picked' through the legal chain (rest→hover→pressed→picked),
2847
+ * each hop instant so EVERY side effect fires (behavior override, backing
2848
+ * silhouette, onChange state reports, placed onEnter chain). This is the
2849
+ * keyboard/a11y entry point — it never produced pointer hover/press events,
2850
+ * so raw `send('pick')` from 'rest' was a no-op. Returns true if it landed.
2851
+ */
2852
+ pickProgrammatic() {
2853
+ for (const event of ["enter", "down", "pick"]) {
2854
+ const next = stateEventTransitions[this.state]?.[event];
2855
+ if (next && next !== this.state) this.goto(next, { instant: true });
2856
+ }
2857
+ return this.state === "picked";
2858
+ }
2859
+ /** Instant, legal place (picked → placed) so onEnter/emit fires. */
2860
+ placeProgrammatic() {
2861
+ return this.driveInstant("place");
2862
+ }
2863
+ /** Instant, legal return (picked → rest). */
2864
+ returnProgrammatic() {
2865
+ return this.driveInstant("return");
2866
+ }
2867
+ driveInstant(event) {
2868
+ const next = stateEventTransitions[this.state]?.[event];
2869
+ if (!next || next === this.state) return false;
2870
+ this.goto(next, { instant: true });
2871
+ return true;
2872
+ }
2873
+ /** Transition to a state (escape hatch for custom states; `send` for triggers). */
2874
+ goto(state, opts) {
2875
+ const def = this.base.states?.states[state];
2876
+ const target = this.resolve(state);
2877
+ this.state = state;
2878
+ const targetFlat = flattenNumeric(target);
2879
+ this.structure = clone(target);
2880
+ this.live = clone(target);
2881
+ const changed = {};
2882
+ for (const path in this.flat) {
2883
+ if (!(path in targetFlat)) delete this.flat[path];
2884
+ }
2885
+ for (const [path, value] of Object.entries(targetFlat)) {
2886
+ const current = this.flat[path];
2887
+ if (current === void 0) this.flat[path] = value;
2888
+ else if (current !== value) changed[path] = value;
2889
+ }
2890
+ const duration = this.opts.instant || opts?.instant ? 0 : def?.transition.duration ?? DEFAULT_TRANSITION.duration;
2891
+ const ease = def?.transition.ease ?? DEFAULT_TRANSITION.ease;
2892
+ this.tween?.kill();
2893
+ this.tween = null;
2894
+ const arrive = () => {
2895
+ for (const [path, value] of Object.entries(changed)) this.flat[path] = value;
2896
+ this.emitStructure();
2897
+ for (const action of def?.onEnter ?? []) {
2898
+ if (action.startsWith("emit:")) this.opts.onAction?.(action.slice(5), state);
2899
+ }
3245
2900
  };
3246
- uniforms.uPerfRadius = { value: perforation.holeRadius };
3247
- uniforms.uPerfSpacing = { value: perforation.spacing };
3248
- uniforms.uSheetSize = { value: new THREE4.Vector2(sheet2.width, sheet2.height) };
2901
+ if (duration === 0 || Object.keys(changed).length === 0) {
2902
+ arrive();
2903
+ return;
2904
+ }
2905
+ this.emitStructure();
2906
+ this.tween = gsap.to(this.flat, {
2907
+ ...changed,
2908
+ duration,
2909
+ ease,
2910
+ onComplete: () => {
2911
+ this.tween = null;
2912
+ arrive();
2913
+ }
2914
+ });
3249
2915
  }
3250
- if (creases) {
3251
- chunks.push(CREASE_CHUNK);
3252
- calls.push("plCrease(csm_DiffuseColor, csm_Roughness);");
3253
- uniforms.uCreaseAngle = { value: creases.angle * Math.PI / 180 };
3254
- uniforms.uCreaseStrength = { value: creases.strength };
3255
- uniforms.uCreasePositions = { value: padPositions(creases.positions) };
3256
- uniforms.uCreaseCount = { value: Math.min(creases.positions.length, 4) };
2916
+ /**
2917
+ * Swap the base config without resetting the machine — parameter edits and
2918
+ * runtime patches (torn perforation on detach) keep the current state and
2919
+ * live values instead of snapping back to `initial`. An in-flight tween is
2920
+ * left running on the SAME `flat` object, so the transition continues
2921
+ * smoothly to its target across the rebase (no freeze, no snap).
2922
+ */
2923
+ rebase(base) {
2924
+ this.base = base;
2925
+ this.resolved.clear();
2926
+ const target = this.resolve(this.state);
2927
+ this.structure = clone(target);
2928
+ this.live = clone(target);
2929
+ const targetFlat = flattenNumeric(target);
2930
+ for (const path in this.flat) {
2931
+ if (!(path in targetFlat)) delete this.flat[path];
2932
+ }
2933
+ for (const [path, value] of Object.entries(targetFlat)) {
2934
+ if (!(path in this.flat)) this.flat[path] = value;
2935
+ }
2936
+ this.emitStructure();
3257
2937
  }
3258
- if (aging !== void 0) {
3259
- chunks.push(AGING_CHUNK);
3260
- calls.push("plAging(csm_DiffuseColor);");
3261
- uniforms.uAgingAmount = { value: aging };
2938
+ dispose() {
2939
+ this.tween?.kill();
2940
+ this.tween = null;
3262
2941
  }
3263
- const frontExpr = maps.hasFrontMap ? "texture2D(uFrontMap, vPaperUv).rgb" : "uStockColor";
3264
- const backBaseExpr = stock.adhesive ? "vec3(0.965, 0.96, 0.945)" : maps.hasBackMap ? "texture2D(uBackMap, vec2(1.0 - vPaperUv.x, vPaperUv.y)).rgb" : "uStockColor";
3265
- const fragmentShader = (
3266
- /* glsl */
3267
- `
3268
- ${HELPERS}
3269
- uniform vec3 uStockColor;
3270
- uniform float uOpacity;
3271
- uniform float uShowThrough;
3272
- ${maps.hasFrontMap ? "uniform sampler2D uFrontMap;" : ""}
3273
- ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
3274
- ${TRANSLUCENCY_FRAGMENT}
3275
- ${chunks.join("\n")}
3276
- void main() {
3277
- vec3 front = ${frontExpr};
3278
- if (gl_FrontFacing) {
3279
- csm_DiffuseColor = vec4(front, uOpacity);
3280
- } else {
3281
- vec3 backBase = ${backBaseExpr};
3282
- csm_DiffuseColor = vec4(backBase * mix(vec3(1.0), front, uShowThrough), uOpacity);
2942
+ resolve(state) {
2943
+ let config = this.resolved.get(state);
2944
+ if (!config) {
2945
+ config = resolveStateConfig(this.base, state);
2946
+ this.resolved.set(state, config);
2947
+ }
2948
+ return config;
3283
2949
  }
3284
- ${calls.join("\n ")}
3285
- if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
3286
- ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
3287
- // What the key light pushes through the sheet, filtered by the ink on it.
3288
- csm_Emissive = plTransmission(front);
3289
- }
3290
- `
2950
+ emitStructure() {
2951
+ this.opts.onChange?.(this.structuralConfig(), this.state);
2952
+ }
2953
+ };
2954
+
2955
+ // src/states/usePaperStates.ts
2956
+ import { useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
2957
+ function usePaperStates(config, enabled, instant, onAction, onStateChange) {
2958
+ const live = enabled && Boolean(config.states);
2959
+ const key = useMemo(() => JSON.stringify(config), [config]);
2960
+ const onActionRef = useRef(onAction);
2961
+ onActionRef.current = onAction;
2962
+ const onStateChangeRef = useRef(onStateChange);
2963
+ onStateChangeRef.current = onStateChange;
2964
+ const machineRef = useRef(null);
2965
+ const lastStateRef = useRef("rest");
2966
+ const [animated, setAnimated] = useState2(null);
2967
+ useEffect2(() => {
2968
+ if (!live) {
2969
+ machineRef.current?.dispose();
2970
+ machineRef.current = null;
2971
+ setAnimated(null);
2972
+ return;
2973
+ }
2974
+ if (machineRef.current) {
2975
+ machineRef.current.rebase(config);
2976
+ return;
2977
+ }
2978
+ const machine = new PaperStateMachine(config, {
2979
+ instant,
2980
+ // Structural boundaries only (not per tick) — safe to route to React.
2981
+ onChange: (c, state) => {
2982
+ if (state !== lastStateRef.current) {
2983
+ lastStateRef.current = state;
2984
+ onStateChangeRef.current?.(state);
2985
+ }
2986
+ setAnimated({ config: c, state });
2987
+ },
2988
+ onAction: (event, state) => onActionRef.current?.(event, state)
2989
+ });
2990
+ machineRef.current = machine;
2991
+ lastStateRef.current = machine.state;
2992
+ setAnimated({ config: machine.structuralConfig(), state: machine.state });
2993
+ }, [key, live, instant]);
2994
+ useEffect2(
2995
+ () => () => {
2996
+ machineRef.current?.dispose();
2997
+ machineRef.current = null;
2998
+ },
2999
+ []
3291
3000
  );
3292
3001
  return {
3293
- structureKey: `${[
3294
- grain !== void 0 || banding > 0 ? "g" : "",
3295
- deckle ? "d" : "",
3296
- creases ? "c" : "",
3297
- aging !== void 0 ? "a" : "",
3298
- perforation ? "p" : "",
3299
- stock.adhesive ? "A" : ""
3300
- ].join("")}:${maps.hasFrontMap ? "F" : ""}${maps.hasBackMap ? "B" : ""}`,
3301
- vertexShader: VERTEX,
3302
- fragmentShader,
3303
- uniforms,
3304
- alphaTest: deckle || perforation ? 0.5 : 0
3002
+ config: live && animated ? animated.config : stripStates(config),
3003
+ state: live && animated ? animated.state : "rest",
3004
+ machine: live ? machineRef.current : null
3305
3005
  };
3306
3006
  }
3307
- function padPositions(positions) {
3308
- const out = positions.slice(0, 4);
3309
- while (out.length < 4) out.push(-1);
3310
- return out;
3007
+
3008
+ // src/PaperMesh.tsx
3009
+ import * as THREE7 from "three";
3010
+ import { gsap as gsap2 } from "gsap";
3011
+ import { useFrame, useThree } from "@react-three/fiber";
3012
+ import { forwardRef, useEffect as useEffect5, useImperativeHandle, useMemo as useMemo3, useRef as useRef3 } from "react";
3013
+
3014
+ // src/core/normals.ts
3015
+ function computeSheetNormals(geometry) {
3016
+ const index = geometry.index;
3017
+ const normalAttr = geometry.attributes.normal;
3018
+ const positionAttr = geometry.attributes.position;
3019
+ if (!index || !normalAttr || !positionAttr) {
3020
+ geometry.computeVertexNormals();
3021
+ return;
3022
+ }
3023
+ const pos = positionAttr.array;
3024
+ const nrm = normalAttr.array;
3025
+ const idx = index.array;
3026
+ nrm.fill(0);
3027
+ for (let i = 0, l = idx.length; i < l; i += 3) {
3028
+ const a = idx[i] * 3;
3029
+ const b = idx[i + 1] * 3;
3030
+ const c = idx[i + 2] * 3;
3031
+ const bx = pos[b];
3032
+ const by = pos[b + 1];
3033
+ const bz = pos[b + 2];
3034
+ const cbx = pos[c] - bx;
3035
+ const cby = pos[c + 1] - by;
3036
+ const cbz = pos[c + 2] - bz;
3037
+ const abx = pos[a] - bx;
3038
+ const aby = pos[a + 1] - by;
3039
+ const abz = pos[a + 2] - bz;
3040
+ const nx = cby * abz - cbz * aby;
3041
+ const ny = cbz * abx - cbx * abz;
3042
+ const nz = cbx * aby - cby * abx;
3043
+ nrm[a] = nrm[a] + nx;
3044
+ nrm[a + 1] = nrm[a + 1] + ny;
3045
+ nrm[a + 2] = nrm[a + 2] + nz;
3046
+ nrm[b] = nrm[b] + nx;
3047
+ nrm[b + 1] = nrm[b + 1] + ny;
3048
+ nrm[b + 2] = nrm[b + 2] + nz;
3049
+ nrm[c] = nrm[c] + nx;
3050
+ nrm[c + 1] = nrm[c + 1] + ny;
3051
+ nrm[c + 2] = nrm[c + 2] + nz;
3052
+ }
3053
+ for (let i = 0, l = nrm.length; i < l; i += 3) {
3054
+ const x = nrm[i];
3055
+ const y = nrm[i + 1];
3056
+ const z26 = nrm[i + 2];
3057
+ const len = Math.sqrt(x * x + y * y + z26 * z26) || 1;
3058
+ nrm[i] = x / len;
3059
+ nrm[i + 1] = y / len;
3060
+ nrm[i + 2] = z26 / len;
3061
+ }
3062
+ normalAttr.needsUpdate = true;
3311
3063
  }
3312
3064
 
3313
- // src/scene/rig.tsx
3314
- import { createContext, useContext } from "react";
3315
- import { jsx } from "react/jsx-runtime";
3316
- var LightRigContext = createContext(null);
3317
- function LightRig({ rig, children }) {
3318
- return /* @__PURE__ */ jsx(LightRigContext.Provider, { value: rig, children });
3065
+ // src/core/stable.ts
3066
+ import { useRef as useRef2 } from "react";
3067
+ function useStable(value) {
3068
+ const held = useRef2(value);
3069
+ if (!deepEqual(held.current, value)) held.current = value;
3070
+ return held.current;
3319
3071
  }
3320
- function useLightRig(own) {
3321
- return useContext(LightRigContext) ?? getLightingPreset(own);
3072
+ function deepEqual(a, b) {
3073
+ if (Object.is(a, b)) return true;
3074
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
3075
+ if (Array.isArray(a) || Array.isArray(b)) {
3076
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
3077
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
3078
+ return true;
3079
+ }
3080
+ const left = a;
3081
+ const right = b;
3082
+ const keys = /* @__PURE__ */ new Set();
3083
+ for (const key of Object.keys(left)) if (left[key] !== void 0) keys.add(key);
3084
+ for (const key of Object.keys(right)) if (right[key] !== void 0) keys.add(key);
3085
+ for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
3086
+ return true;
3322
3087
  }
3323
3088
 
3324
- // src/surface/PaperMaterial.tsx
3325
- import * as THREE5 from "three";
3326
- import { useEffect, useMemo } from "react";
3327
- import CustomShaderMaterial from "three-custom-shader-material";
3328
- import { jsx as jsx2 } from "react/jsx-runtime";
3329
- function PaperMaterial({
3330
- stock,
3331
- texture,
3332
- backTexture,
3333
- surface,
3334
- thickness,
3335
- sheet: sheet2,
3336
- lighting = "studio"
3337
- }) {
3338
- const rig = useLightRig(lighting);
3339
- const composed = composeSurface(
3340
- surface,
3341
- stock,
3342
- thickness,
3343
- {
3344
- hasFrontMap: Boolean(texture),
3345
- hasBackMap: Boolean(backTexture)
3346
- },
3347
- sheet2,
3348
- rig
3349
- );
3350
- const bound = useMemo(() => composed.uniforms, [composed.structureKey]);
3351
- useEffect(() => {
3352
- for (const [key, uniform] of Object.entries(composed.uniforms)) {
3353
- if (!bound[key] || key === "uFrontMap" || key === "uBackMap") continue;
3354
- if (bound[key].value instanceof THREE5.Color && uniform.value instanceof THREE5.Color) {
3355
- ;
3356
- bound[key].value.copy(uniform.value);
3357
- } else {
3358
- bound[key].value = uniform.value;
3359
- }
3360
- }
3361
- });
3362
- useEffect(() => {
3363
- if (bound.uFrontMap) bound.uFrontMap.value = texture;
3364
- if (bound.uBackMap) bound.uBackMap.value = backTexture ?? null;
3365
- }, [bound, texture, backTexture]);
3366
- return /* @__PURE__ */ jsx2(
3367
- CustomShaderMaterial,
3368
- {
3369
- baseMaterial: THREE5.MeshStandardMaterial,
3370
- vertexShader: composed.vertexShader,
3371
- fragmentShader: composed.fragmentShader,
3372
- uniforms: bound,
3373
- color: "#ffffff",
3374
- roughness: stock.roughness,
3375
- metalness: 0,
3376
- transparent: stock.opacity < 1,
3377
- opacity: stock.opacity,
3378
- alphaTest: composed.alphaTest,
3379
- side: THREE5.DoubleSide
3380
- },
3381
- composed.structureKey
3382
- );
3089
+ // src/core/sheet.ts
3090
+ import * as THREE from "three";
3091
+ function resolveSegments(sheet2, minSegments = 2, autoSegments = LEGACY_FLAT_SEGMENTS) {
3092
+ const [minX, minY] = typeof minSegments === "number" ? [minSegments, minSegments] : minSegments;
3093
+ if (sheet2.segments !== "auto") {
3094
+ return [Math.max(sheet2.segments, minX, 2), Math.max(sheet2.segments, minY, 2)];
3095
+ }
3096
+ const [wantX, wantY] = typeof autoSegments === "number" ? spreadByAspect(sheet2, quantizeSegments(Math.max(autoSegments, FLAT_SEGMENTS))) : [
3097
+ quantizeSegments(Math.max(wantOrFlat(autoSegments[0]), FLAT_SEGMENTS)),
3098
+ quantizeSegments(Math.max(wantOrFlat(autoSegments[1]), FLAT_SEGMENTS))
3099
+ ];
3100
+ return [Math.max(wantX, minX, 2), Math.max(wantY, minY, 2)];
3383
3101
  }
3384
-
3385
- // src/a11y/index.tsx
3386
- import { useEffect as useEffect2, useState } from "react";
3387
- import { jsx as jsx3 } from "react/jsx-runtime";
3388
- function usePrefersReducedMotion(override) {
3389
- const [system, setSystem] = useState(
3390
- () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
3391
- );
3392
- useEffect2(() => {
3393
- const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
3394
- if (!query) return;
3395
- const onChange = () => setSystem(query.matches);
3396
- query.addEventListener("change", onChange);
3397
- return () => query.removeEventListener("change", onChange);
3398
- }, []);
3399
- return override ?? system;
3102
+ var wantOrFlat = (n) => Number.isFinite(n) ? n : FLAT_SEGMENTS;
3103
+ function spreadByAspect(sheet2, target) {
3104
+ const long = Math.max(sheet2.width, sheet2.height);
3105
+ if (!(long > 0)) return [target, target];
3106
+ return [Math.round(sheet2.width / long * target), Math.round(sheet2.height / long * target)];
3400
3107
  }
3401
- var webglSupport = null;
3402
- function supportsWebGL() {
3403
- if (webglSupport !== null) return webglSupport;
3404
- try {
3405
- const canvas = document.createElement("canvas");
3406
- webglSupport = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
3407
- } catch {
3408
- webglSupport = false;
3108
+ function createSheetGeometry(sheet2, minSegments = 2, autoSegments = LEGACY_FLAT_SEGMENTS) {
3109
+ const [sx, sy] = resolveSegments(sheet2, minSegments, autoSegments);
3110
+ return new THREE.PlaneGeometry(sheet2.width, sheet2.height, sx, sy);
3111
+ }
3112
+
3113
+ // src/content/texture.ts
3114
+ import * as THREE2 from "three";
3115
+ import { useEffect as useEffect3, useState as useState3 } from "react";
3116
+
3117
+ // src/content/card.ts
3118
+ var TITLE_RATIO = 0.52;
3119
+ var NOTE_RATIO = 0.46;
3120
+ var TITLE_TRACKING = 0.16;
3121
+ var NOTE_TRACKING = 0.06;
3122
+ function paintCard(ctx, w, h, content, stock, dpr) {
3123
+ const ink = content.color === "#2b2620" ? stock.inkColor : content.color;
3124
+ const size = content.size * dpr;
3125
+ const pad = content.padding * Math.min(w, h);
3126
+ const maxWidth = w - pad * 2;
3127
+ const x = content.align === "center" ? w / 2 : pad;
3128
+ ctx.textAlign = content.align === "center" ? "center" : "left";
3129
+ ctx.textBaseline = "alphabetic";
3130
+ const titleSize = size * TITLE_RATIO;
3131
+ const noteSize = size * NOTE_RATIO;
3132
+ const bodyStep = size * 1.35;
3133
+ const bodyLines = content.body ? wrapLines(ctx, content.body, maxWidth, `${size}px ${content.font}`) : [];
3134
+ const titleBlock = content.title ? titleSize * 1.9 : 0;
3135
+ const ruleBlock = content.rule && content.title ? titleSize * 0.9 : 0;
3136
+ const noteBlock = content.note ? noteSize * 2.4 : 0;
3137
+ const bodyBlock = bodyLines.length * bodyStep;
3138
+ const total = titleBlock + ruleBlock + bodyBlock + noteBlock;
3139
+ let y = Math.max(pad, (h - total) / 2) + size * 0.9;
3140
+ if (content.title) {
3141
+ ctx.font = `${titleSize}px ${content.font}`;
3142
+ ctx.letterSpacing = `${TITLE_TRACKING}em`;
3143
+ ctx.fillStyle = ink;
3144
+ ctx.globalAlpha = 0.72;
3145
+ ctx.fillText(content.title.toUpperCase(), x, y - size * 0.5);
3146
+ ctx.globalAlpha = 1;
3147
+ ctx.letterSpacing = "0em";
3148
+ y += titleBlock - size * 0.5;
3149
+ if (content.rule) {
3150
+ ctx.save();
3151
+ ctx.strokeStyle = ink;
3152
+ ctx.globalAlpha = 0.28;
3153
+ ctx.lineWidth = Math.max(1, dpr * 0.75);
3154
+ ctx.beginPath();
3155
+ ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad, y - titleSize * 0.5);
3156
+ ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad + maxWidth, y - titleSize * 0.5);
3157
+ ctx.stroke();
3158
+ ctx.restore();
3159
+ y += ruleBlock;
3160
+ }
3409
3161
  }
3410
- return webglSupport;
3411
- }
3412
- function contentText(config) {
3413
- const content = config.content;
3414
- if (content.type === "text") return content.text;
3415
- if (content.type === "image") return content.alt ?? "An image printed on paper.";
3416
- if (content.type === "receipt") {
3417
- const totals = receiptTotals(content);
3418
- const items = content.items.map((i) => `${i.name} ${i.price.toFixed(2)}`).join(", ");
3419
- return `Receipt from ${content.store}: ${items}. Total ${totals.total.toFixed(2)}. ${content.footer}`;
3162
+ if (content.ruled && bodyLines.length > 0) {
3163
+ ctx.save();
3164
+ ctx.strokeStyle = ink;
3165
+ ctx.globalAlpha = 0.14;
3166
+ ctx.lineWidth = Math.max(1, dpr * 0.6);
3167
+ for (let i = 0; i < bodyLines.length; i++) {
3168
+ const lineY = y + i * bodyStep + size * 0.28;
3169
+ ctx.beginPath();
3170
+ ctx.moveTo(pad, lineY);
3171
+ ctx.lineTo(pad + maxWidth, lineY);
3172
+ ctx.stroke();
3173
+ }
3174
+ ctx.restore();
3175
+ }
3176
+ ctx.font = `${size}px ${content.font}`;
3177
+ ctx.fillStyle = ink;
3178
+ for (const line of bodyLines) {
3179
+ if (y > h - pad) break;
3180
+ ctx.fillText(line, x, y);
3181
+ y += bodyStep;
3182
+ }
3183
+ if (content.note) {
3184
+ ctx.font = `${noteSize}px ${content.font}`;
3185
+ ctx.letterSpacing = `${NOTE_TRACKING}em`;
3186
+ ctx.globalAlpha = 0.6;
3187
+ ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad));
3188
+ ctx.globalAlpha = 1;
3189
+ ctx.letterSpacing = "0em";
3420
3190
  }
3421
- return "A blank sheet of paper.";
3422
3191
  }
3423
- var visuallyHidden = {
3424
- position: "absolute",
3425
- width: 1,
3426
- height: 1,
3427
- padding: 0,
3428
- margin: -1,
3429
- overflow: "hidden",
3430
- clip: "rect(0 0 0 0)",
3431
- whiteSpace: "nowrap",
3432
- border: 0
3433
- };
3434
- function PaperMirror({ config }) {
3435
- return /* @__PURE__ */ jsx3("div", { style: visuallyHidden, "aria-hidden": false, children: contentText(config) });
3192
+
3193
+ // src/content/texture.ts
3194
+ var LONG_EDGE = 1024;
3195
+ var DPR = 2;
3196
+ function contentCanvasSize(sheet2) {
3197
+ const long = Math.max(sheet2.width, sheet2.height);
3198
+ const w = Math.round(sheet2.width / long * LONG_EDGE * DPR);
3199
+ const h = Math.round(sheet2.height / long * LONG_EDGE * DPR);
3200
+ return [w, h];
3436
3201
  }
3437
- function PaperFallback({ config }) {
3438
- const stock = getStock(config.stock);
3439
- const isImage = config.content.type === "image";
3440
- return /* @__PURE__ */ jsx3(
3441
- "div",
3442
- {
3443
- role: "img",
3444
- "aria-label": contentText(config),
3445
- style: {
3446
- width: "100%",
3447
- height: "100%",
3448
- display: "grid",
3449
- placeItems: "center",
3450
- background: "transparent"
3451
- },
3452
- children: /* @__PURE__ */ jsx3(
3453
- "div",
3454
- {
3455
- style: {
3456
- aspectRatio: `${config.sheet.width} / ${config.sheet.height}`,
3457
- maxWidth: "80%",
3458
- maxHeight: "90%",
3459
- background: stock.color,
3460
- color: stock.inkColor,
3461
- boxShadow: "0 6px 24px rgba(0,0,0,0.25)",
3462
- padding: "8%",
3463
- overflow: "hidden",
3464
- fontFamily: config.content.type === "receipt" ? "ui-monospace, monospace" : "Georgia, serif",
3465
- whiteSpace: "pre-wrap",
3466
- fontSize: 14
3467
- },
3468
- children: isImage && config.content.type === "image" ? /* @__PURE__ */ jsx3(
3469
- "img",
3470
- {
3471
- src: config.content.src,
3472
- alt: config.content.alt ?? "",
3473
- style: { width: "100%", height: "100%", objectFit: "cover", margin: "-8%" }
3474
- }
3475
- ) : contentText(config)
3476
- }
3477
- )
3478
- }
3479
- );
3202
+ function paintBackground(ctx, w, h, stock) {
3203
+ ctx.fillStyle = stock.color;
3204
+ ctx.fillRect(0, 0, w, h);
3480
3205
  }
3481
-
3482
- // src/motion/onTwos.ts
3483
- var ON_TWOS_FPS = 12;
3484
- function quantizeTime(t, fps = ON_TWOS_FPS) {
3485
- return Math.floor(t * fps) / fps;
3206
+ function paintImage(ctx, w, h, img, fit) {
3207
+ const scale = fit === "cover" ? Math.max(w / img.width, h / img.height) : Math.min(w / img.width, h / img.height);
3208
+ const dw = img.width * scale;
3209
+ const dh = img.height * scale;
3210
+ ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
3486
3211
  }
3487
- function quantizeProgress(p, duration, fps = ON_TWOS_FPS) {
3488
- const steps = Math.max(1, Math.round(duration * fps));
3489
- return Math.round(p * steps) / steps;
3212
+ function paintText(ctx, w, h, content, stock) {
3213
+ const size = content.size * DPR;
3214
+ const pad = content.padding * Math.min(w, h);
3215
+ const font = `${content.weight} ${size}px ${content.font}`;
3216
+ ctx.font = font;
3217
+ ctx.fillStyle = content.color === "#2b2620" ? stock.inkColor : content.color;
3218
+ ctx.textBaseline = "top";
3219
+ ctx.textAlign = content.align;
3220
+ ctx.letterSpacing = `${content.tracking}em`;
3221
+ const maxWidth = w - pad * 2;
3222
+ const x = content.align === "left" ? pad : content.align === "right" ? w - pad : w / 2;
3223
+ const lineStep = size * content.lineHeight;
3224
+ const lines = wrapLines(ctx, content.text, maxWidth, font);
3225
+ ctx.font = font;
3226
+ ctx.letterSpacing = `${content.tracking}em`;
3227
+ const block = lines.length * lineStep;
3228
+ let y = content.valign === "center" ? Math.max(pad, (h - block) / 2) : pad;
3229
+ for (const line of lines) {
3230
+ if (y > h - pad) break;
3231
+ ctx.fillText(line, x, y);
3232
+ y += lineStep;
3233
+ }
3234
+ ctx.letterSpacing = "0em";
3490
3235
  }
3491
-
3492
- // src/states/machine.ts
3493
- import { gsap } from "gsap";
3494
- var stateEventTransitions = {
3495
- rest: { enter: "hover" },
3496
- hover: { leave: "rest", down: "pressed" },
3497
- pressed: { up: "hover", pick: "picked" },
3498
- picked: { place: "placed", return: "rest" },
3499
- placed: {}
3500
- };
3501
- var DEFAULT_TRANSITION = { duration: 0.35, ease: "power2.out" };
3502
- function stripStates(config) {
3503
- if (!config.states) return config;
3504
- const { states: _states, ...rest } = config;
3505
- return rest;
3236
+ function renderContentToCanvas(content, sheet2, stock, image) {
3237
+ const [w, h] = contentCanvasSize(sheet2);
3238
+ const canvas = document.createElement("canvas");
3239
+ canvas.width = w;
3240
+ canvas.height = h;
3241
+ const ctx = canvas.getContext("2d");
3242
+ paintBackground(ctx, w, h, stock);
3243
+ if (content.type === "image" && image && content.src) paintImage(ctx, w, h, image, content.fit);
3244
+ if (content.type === "text") paintText(ctx, w, h, content, stock);
3245
+ if (content.type === "receipt") paintReceipt(ctx, w, h, content, stock);
3246
+ if (content.type === "card") paintCard(ctx, w, h, content, stock, DPR);
3247
+ return canvas;
3506
3248
  }
3507
- function resolveStateConfig(base, state) {
3508
- const def = base.states?.states[state];
3509
- const flat = stripStates(base);
3510
- if (!def || Object.keys(def.overrides).length === 0) return flat;
3511
- return paperConfigSchema.parse(mergeConfig(flat, def.overrides));
3249
+ function makeTexture(canvas) {
3250
+ const tex = new THREE2.CanvasTexture(canvas);
3251
+ tex.colorSpace = THREE2.SRGBColorSpace;
3252
+ tex.anisotropy = 8;
3253
+ tex.generateMipmaps = true;
3254
+ return tex;
3512
3255
  }
3513
- function pruneUndefined(value) {
3514
- if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
3515
- const out = {};
3516
- for (const [key, v] of Object.entries(value)) {
3517
- if (v === void 0) continue;
3518
- const pruned = pruneUndefined(v);
3519
- const isEmptyObject = pruned !== null && typeof pruned === "object" && !Array.isArray(pruned) && Object.keys(pruned).length === 0;
3520
- if (!isEmptyObject) out[key] = pruned;
3521
- }
3522
- return out;
3256
+ function useContentTexture(content, sheet2, stock) {
3257
+ const [texture, setTexture] = useState3(null);
3258
+ const key = JSON.stringify({ content: content ?? null, w: sheet2.width, h: sheet2.height, stock: stock.id });
3259
+ useEffect3(() => {
3260
+ let disposed = false;
3261
+ let tex = null;
3262
+ if (!content) {
3263
+ setTexture(null);
3264
+ return;
3265
+ }
3266
+ const commit = (canvas) => {
3267
+ if (disposed) return;
3268
+ tex = makeTexture(canvas);
3269
+ setTexture(tex);
3270
+ };
3271
+ if (content.type === "image" && content.src) {
3272
+ const img = new Image();
3273
+ img.crossOrigin = "anonymous";
3274
+ img.onload = () => commit(renderContentToCanvas(content, sheet2, stock, img));
3275
+ img.onerror = () => commit(renderContentToCanvas(content, sheet2, stock));
3276
+ img.src = content.src;
3277
+ } else if (content.type === "text" || content.type === "card") {
3278
+ void ensureFont(content.font, content.size * DPR).then(
3279
+ () => commit(renderContentToCanvas(content, sheet2, stock))
3280
+ );
3281
+ } else if (content.type === "receipt") {
3282
+ document.fonts.ready.then(() => commit(renderContentToCanvas(content, sheet2, stock)));
3283
+ } else {
3284
+ commit(renderContentToCanvas(content, sheet2, stock));
3285
+ }
3286
+ return () => {
3287
+ disposed = true;
3288
+ tex?.dispose();
3289
+ };
3290
+ }, [key]);
3291
+ return texture;
3523
3292
  }
3524
- function recordStateOverride(config, stateName, patch) {
3525
- const cleaned = pruneUndefined(patch);
3526
- const states = config.states ?? paperStatesSchema.parse({});
3527
- const def = states.states[stateName] ?? stateDefSchema.parse({});
3528
- const overrides = mergeConfig(def.overrides, cleaned);
3529
- return paperConfigSchema.parse({
3530
- ...config,
3531
- states: {
3532
- ...states,
3533
- states: { ...states.states, [stateName]: { ...def, overrides } }
3293
+
3294
+ // src/deformers/compose.ts
3295
+ import * as THREE3 from "three";
3296
+ var scratchPos = new THREE3.Vector3();
3297
+ var scratchUv = new THREE3.Vector2();
3298
+ var activeFns = [];
3299
+ var activeOptions = [];
3300
+ function applyDeformerStack(geometry, basePositions, stack, ctx) {
3301
+ const position = geometry.attributes.position;
3302
+ const uv = geometry.attributes.uv;
3303
+ const array = position.array;
3304
+ const uvArray = uv.array;
3305
+ const count = position.count;
3306
+ activeFns.length = 0;
3307
+ activeOptions.length = 0;
3308
+ for (const instance of stack) {
3309
+ if (instance.enabled === false) continue;
3310
+ activeFns.push(getDeformer(instance.type).displace);
3311
+ activeOptions.push(instance.options);
3312
+ }
3313
+ array.set(basePositions);
3314
+ for (let k = 0; k < activeFns.length; k++) {
3315
+ const displace = activeFns[k];
3316
+ const options = activeOptions[k];
3317
+ for (let v = 0; v < count; v++) {
3318
+ const i3 = v * 3;
3319
+ const i2 = v * 2;
3320
+ scratchPos.set(array[i3], array[i3 + 1], array[i3 + 2]);
3321
+ scratchUv.set(uvArray[i2], uvArray[i2 + 1]);
3322
+ displace(scratchPos, scratchUv, options, ctx);
3323
+ array[i3] = scratchPos.x;
3324
+ array[i3 + 1] = scratchPos.y;
3325
+ array[i3 + 2] = scratchPos.z;
3534
3326
  }
3535
- });
3327
+ }
3328
+ position.needsUpdate = true;
3329
+ computeSheetNormals(geometry);
3536
3330
  }
3537
- function flattenNumeric(value, prefix = "", out = {}) {
3538
- if (typeof value === "number") {
3539
- if (prefix) out[prefix] = value;
3540
- return out;
3331
+ function displacePoint(point, uvX, uvY, stack, ctx) {
3332
+ scratchUv.set(uvX, uvY);
3333
+ for (const instance of stack) {
3334
+ if (instance.enabled === false) continue;
3335
+ getDeformer(instance.type).displace(point, scratchUv, instance.options, ctx);
3541
3336
  }
3542
- if (value !== null && typeof value === "object") {
3543
- const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
3544
- for (const [key, v] of entries) {
3545
- flattenNumeric(v, prefix ? `${prefix}.${key}` : key, out);
3546
- }
3337
+ return point;
3338
+ }
3339
+ function stackMinSegments(stack, sheet2) {
3340
+ const out = [2, 2];
3341
+ for (const instance of stack) {
3342
+ const deformer = getDeformer(instance.type);
3343
+ const floor = deformer.geometry?.minSegments;
3344
+ if (!floor) continue;
3345
+ take(out, deformer, instance.options, sheet2, floor);
3547
3346
  }
3548
3347
  return out;
3549
3348
  }
3550
- function setPath(target, path, value) {
3551
- const keys = path.split(".");
3552
- let node = target;
3553
- for (let i = 0; i < keys.length - 1; i++) {
3554
- const next = node[keys[i]];
3555
- if (next === null || typeof next !== "object") return;
3556
- node = next;
3349
+ function stackAutoSegments(stack, sheet2) {
3350
+ const out = [0, 0];
3351
+ for (const instance of stack) {
3352
+ if (instance.enabled === false) continue;
3353
+ const deformer = getDeformer(instance.type);
3354
+ const geometry = deformer.geometry;
3355
+ if (!geometry) continue;
3356
+ const want = geometry.autoSegments ? geometry.autoSegments(instance.options, sheet2) : geometry.minSegments ?? 0;
3357
+ take(out, deformer, instance.options, sheet2, want);
3557
3358
  }
3558
- node[keys[keys.length - 1]] = value;
3359
+ return out;
3559
3360
  }
3560
- function applyFlat(target, flat) {
3561
- for (const path in flat) setPath(target, path, flat[path]);
3361
+ function take(out, deformer, options, sheet2, demand) {
3362
+ const declared = deformer.geometry?.axis?.(options, sheet2);
3363
+ const angle = typeof declared === "number" && Number.isFinite(declared) ? declared : null;
3364
+ const [x, y] = axialSegments(sheet2, angle, demand);
3365
+ if (x > out[0]) out[0] = x;
3366
+ if (y > out[1]) out[1] = y;
3562
3367
  }
3563
- var clone = (v) => JSON.parse(JSON.stringify(v));
3564
- var PaperStateMachine = class {
3565
- state;
3566
- base;
3567
- opts;
3568
- /** Structural target of the current state; numeric leaves live in `flat`. */
3569
- structure;
3570
- /**
3571
- * The single live tween target — flattened numeric leaves of the config.
3572
- * STABLE IDENTITY for the machine's lifetime: `goto`/`rebase` mutate it in
3573
- * place (add/remove/keep keys) and never reassign, so an in-flight GSAP
3574
- * tween keeps animating the same object across a rebase instead of freezing.
3575
- */
3576
- flat = {};
3577
- /** Mutable working config, polled by the consumer's frame loop via `liveConfig`. */
3578
- live;
3579
- tween = null;
3580
- resolved = /* @__PURE__ */ new Map();
3581
- constructor(base, opts = {}) {
3582
- this.base = base;
3583
- this.opts = opts;
3584
- this.state = base.states?.initial ?? "rest";
3585
- const target = this.resolve(this.state);
3586
- this.structure = clone(target);
3587
- this.live = clone(target);
3588
- Object.assign(this.flat, flattenNumeric(target));
3589
- }
3590
- /** World-units drag distance that flips pressed → picked. */
3591
- get pickThreshold() {
3592
- return this.base.states?.pickThreshold ?? 0.1;
3593
- }
3594
- /**
3595
- * The live config the mutable working object with the current tween values
3596
- * applied in place. Cheap (no allocation); meant to be polled every frame.
3597
- * Never hand this to React; use `structuralConfig()` for an immutable snapshot.
3598
- */
3599
- get liveConfig() {
3600
- applyFlat(this.live, this.flat);
3601
- return this.live;
3602
- }
3603
- /** Back-compat alias for `liveConfig` (tests, imperative reads). */
3604
- get config() {
3605
- return this.liveConfig;
3606
- }
3607
- /** True while a transition tween is in flight — consumers gate frame work on it. */
3608
- get transitioning() {
3609
- return this.tween !== null;
3610
- }
3611
- /** Exposed for tests: the in-flight transition tween, if any. */
3612
- get activeTween() {
3613
- return this.tween;
3368
+
3369
+ // src/physics/cloth.ts
3370
+ var FIXED_DT = 1 / 120;
3371
+ var SOLVER_ITERATIONS = 5;
3372
+ var SLEEP_EPSILON = 1e-6;
3373
+ var SLEEP_FRAMES = 45;
3374
+ var ClothSim = class {
3375
+ cols;
3376
+ rows;
3377
+ count;
3378
+ positions;
3379
+ prev;
3380
+ pinned;
3381
+ pinTargets;
3382
+ constraints = [];
3383
+ params;
3384
+ time = 0;
3385
+ accumulator = 0;
3386
+ stillFrames = 0;
3387
+ grabbedIndex = -1;
3388
+ /** True when the sim has settled and steps are skipped. */
3389
+ asleep = false;
3390
+ constructor(cols, rows, width, height, pins, params) {
3391
+ this.cols = cols;
3392
+ this.rows = rows;
3393
+ this.count = cols * rows;
3394
+ this.params = { ...params };
3395
+ this.positions = new Float32Array(this.count * 3);
3396
+ this.prev = new Float32Array(this.count * 3);
3397
+ this.pinned = new Uint8Array(this.count);
3398
+ this.pinTargets = new Float32Array(this.count * 3);
3399
+ for (let r = 0; r < rows; r++) {
3400
+ for (let c = 0; c < cols; c++) {
3401
+ const i3 = (r * cols + c) * 3;
3402
+ this.positions[i3] = (c / (cols - 1) - 0.5) * width;
3403
+ this.positions[i3 + 1] = (0.5 - r / (rows - 1)) * height;
3404
+ this.positions[i3 + 2] = 0;
3405
+ }
3406
+ }
3407
+ this.prev.set(this.positions);
3408
+ const idx = (r, c) => r * cols + c;
3409
+ const link = (a, b, kind) => {
3410
+ const dx = this.positions[a * 3] - this.positions[b * 3];
3411
+ const dy = this.positions[a * 3 + 1] - this.positions[b * 3 + 1];
3412
+ this.constraints.push({ a, b, rest: Math.hypot(dx, dy), kind });
3413
+ };
3414
+ for (let r = 0; r < rows; r++) {
3415
+ for (let c = 0; c < cols; c++) {
3416
+ if (c + 1 < cols) link(idx(r, c), idx(r, c + 1), 0);
3417
+ if (r + 1 < rows) link(idx(r, c), idx(r + 1, c), 0);
3418
+ if (c + 1 < cols && r + 1 < rows) {
3419
+ link(idx(r, c), idx(r + 1, c + 1), 1);
3420
+ link(idx(r, c + 1), idx(r + 1, c), 1);
3421
+ }
3422
+ if (c + 2 < cols) link(idx(r, c), idx(r, c + 2), 2);
3423
+ if (r + 2 < rows) link(idx(r, c), idx(r + 2, c), 2);
3424
+ }
3425
+ }
3426
+ const pin = (r, c) => {
3427
+ const i = idx(r, c);
3428
+ this.pinned[i] = 1;
3429
+ this.pinTargets.set(this.positions.subarray(i * 3, i * 3 + 3), i * 3);
3430
+ };
3431
+ if (pins === "top-edge") for (let c = 0; c < cols; c++) pin(0, c);
3432
+ if (pins === "top-corners") {
3433
+ pin(0, 0);
3434
+ pin(0, cols - 1);
3435
+ }
3436
+ if (pins === "corner") pin(0, 0);
3614
3437
  }
3615
- /** An immutable structural snapshot for React consumers (never the live object). */
3616
- structuralConfig() {
3617
- const out = clone(this.structure);
3618
- applyFlat(out, this.flat);
3619
- return out;
3438
+ setParams(params) {
3439
+ let changed = false;
3440
+ for (const key of ["stiffness", "gravity", "wind", "floor"]) {
3441
+ const value = params[key];
3442
+ if (value !== void 0 && value !== this.params[key]) {
3443
+ this.params[key] = value;
3444
+ changed = true;
3445
+ }
3446
+ }
3447
+ if (changed) this.wake();
3620
3448
  }
3621
- /** Fire a built-in trigger; returns the new state or null if it doesn't apply. */
3622
- send(event) {
3623
- const next = stateEventTransitions[this.state]?.[event];
3624
- if (!next || next === this.state) return null;
3625
- this.goto(next);
3626
- return next;
3449
+ wake() {
3450
+ this.asleep = false;
3451
+ this.stillFrames = 0;
3627
3452
  }
3628
- /**
3629
- * Drive to 'picked' through the legal chain (rest→hover→pressed→picked),
3630
- * each hop instant so EVERY side effect fires (behavior override, backing
3631
- * silhouette, onChange state reports, placed onEnter chain). This is the
3632
- * keyboard/a11y entry point it never produced pointer hover/press events,
3633
- * so raw `send('pick')` from 'rest' was a no-op. Returns true if it landed.
3634
- */
3635
- pickProgrammatic() {
3636
- for (const event of ["enter", "down", "pick"]) {
3637
- const next = stateEventTransitions[this.state]?.[event];
3638
- if (next && next !== this.state) this.goto(next, { instant: true });
3453
+ /** Nearest particle to a local-space point — the grab interface. */
3454
+ grabNearest(x, y, z26) {
3455
+ let best = -1;
3456
+ let bestDist = Infinity;
3457
+ for (let i = 0; i < this.count; i++) {
3458
+ const dx = this.positions[i * 3] - x;
3459
+ const dy = this.positions[i * 3 + 1] - y;
3460
+ const dz = this.positions[i * 3 + 2] - z26;
3461
+ const d = dx * dx + dy * dy + dz * dz;
3462
+ if (d < bestDist) {
3463
+ bestDist = d;
3464
+ best = i;
3465
+ }
3639
3466
  }
3640
- return this.state === "picked";
3641
- }
3642
- /** Instant, legal place (picked → placed) so onEnter/emit fires. */
3643
- placeProgrammatic() {
3644
- return this.driveInstant("place");
3467
+ this.grabbedIndex = best;
3468
+ this.wake();
3469
+ return best;
3645
3470
  }
3646
- /** Instant, legal return (picked → rest). */
3647
- returnProgrammatic() {
3648
- return this.driveInstant("return");
3471
+ moveGrab(x, y, z26) {
3472
+ if (this.grabbedIndex < 0) return;
3473
+ const i3 = this.grabbedIndex * 3;
3474
+ this.positions[i3] = x;
3475
+ this.positions[i3 + 1] = y;
3476
+ this.positions[i3 + 2] = z26;
3477
+ this.prev[i3] = x;
3478
+ this.prev[i3 + 1] = y;
3479
+ this.prev[i3 + 2] = z26;
3480
+ this.wake();
3649
3481
  }
3650
- driveInstant(event) {
3651
- const next = stateEventTransitions[this.state]?.[event];
3652
- if (!next || next === this.state) return false;
3653
- this.goto(next, { instant: true });
3654
- return true;
3482
+ release() {
3483
+ this.grabbedIndex = -1;
3655
3484
  }
3656
- /** Transition to a state (escape hatch for custom states; `send` for triggers). */
3657
- goto(state, opts) {
3658
- const def = this.base.states?.states[state];
3659
- const target = this.resolve(state);
3660
- this.state = state;
3661
- const targetFlat = flattenNumeric(target);
3662
- this.structure = clone(target);
3663
- this.live = clone(target);
3664
- const changed = {};
3665
- for (const path in this.flat) {
3666
- if (!(path in targetFlat)) delete this.flat[path];
3667
- }
3668
- for (const [path, value] of Object.entries(targetFlat)) {
3669
- const current = this.flat[path];
3670
- if (current === void 0) this.flat[path] = value;
3671
- else if (current !== value) changed[path] = value;
3485
+ step(delta) {
3486
+ if (this.asleep) return;
3487
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * 4);
3488
+ while (this.accumulator >= FIXED_DT) {
3489
+ this.substep(FIXED_DT);
3490
+ this.accumulator -= FIXED_DT;
3672
3491
  }
3673
- const duration = this.opts.instant || opts?.instant ? 0 : def?.transition.duration ?? DEFAULT_TRANSITION.duration;
3674
- const ease = def?.transition.ease ?? DEFAULT_TRANSITION.ease;
3675
- this.tween?.kill();
3676
- this.tween = null;
3677
- const arrive = () => {
3678
- for (const [path, value] of Object.entries(changed)) this.flat[path] = value;
3679
- this.emitStructure();
3680
- for (const action of def?.onEnter ?? []) {
3681
- if (action.startsWith("emit:")) this.opts.onAction?.(action.slice(5), state);
3492
+ }
3493
+ substep(dt) {
3494
+ const { gravity, wind, stiffness, floor } = this.params;
3495
+ const p = this.positions;
3496
+ const damping = 0.985;
3497
+ const dt2 = dt * dt;
3498
+ this.time += dt;
3499
+ let maxTravel = 0;
3500
+ for (let i = 0; i < this.count; i++) {
3501
+ const i3 = i * 3;
3502
+ if (this.pinned[i] || i === this.grabbedIndex) {
3503
+ if (this.pinned[i]) {
3504
+ p[i3] = this.pinTargets[i3];
3505
+ p[i3 + 1] = this.pinTargets[i3 + 1];
3506
+ p[i3 + 2] = this.pinTargets[i3 + 2];
3507
+ }
3508
+ this.prev[i3] = p[i3];
3509
+ this.prev[i3 + 1] = p[i3 + 1];
3510
+ this.prev[i3 + 2] = p[i3 + 2];
3511
+ continue;
3682
3512
  }
3683
- };
3684
- if (duration === 0 || Object.keys(changed).length === 0) {
3685
- arrive();
3686
- return;
3513
+ const x = p[i3];
3514
+ const y = p[i3 + 1];
3515
+ const z26 = p[i3 + 2];
3516
+ const gust2 = wind * (0.55 + 0.45 * Math.sin(this.time * 1.7 + x * 2.1 + y * 1.3)) * 0.9;
3517
+ const ax = gust2 * 0.25;
3518
+ const az = gust2;
3519
+ const vx = (x - this.prev[i3]) * damping;
3520
+ const vy = (y - this.prev[i3 + 1]) * damping;
3521
+ const vz = (z26 - this.prev[i3 + 2]) * damping;
3522
+ this.prev[i3] = x;
3523
+ this.prev[i3 + 1] = y;
3524
+ this.prev[i3 + 2] = z26;
3525
+ p[i3] = x + vx + ax * dt2;
3526
+ p[i3 + 1] = y + vy - gravity * 3.2 * dt2;
3527
+ p[i3 + 2] = z26 + vz + az * dt2;
3528
+ maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
3687
3529
  }
3688
- this.emitStructure();
3689
- this.tween = gsap.to(this.flat, {
3690
- ...changed,
3691
- duration,
3692
- ease,
3693
- onComplete: () => {
3694
- this.tween = null;
3695
- arrive();
3530
+ for (let iter = 0; iter < SOLVER_ITERATIONS; iter++) {
3531
+ for (const c of this.constraints) {
3532
+ const k = c.kind === 2 ? 0.25 + stiffness * 0.7 : c.kind === 1 ? 0.85 : 1;
3533
+ const a3 = c.a * 3;
3534
+ const b3 = c.b * 3;
3535
+ const dx = p[b3] - p[a3];
3536
+ const dy = p[b3 + 1] - p[a3 + 1];
3537
+ const dz = p[b3 + 2] - p[a3 + 2];
3538
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
3539
+ if (dist === 0) continue;
3540
+ const diff = (dist - c.rest) / dist * 0.5 * k;
3541
+ const aPinned = this.pinned[c.a] || c.a === this.grabbedIndex;
3542
+ const bPinned = this.pinned[c.b] || c.b === this.grabbedIndex;
3543
+ if (aPinned && bPinned) continue;
3544
+ const aw = aPinned ? 0 : bPinned ? 2 : 1;
3545
+ const bw = bPinned ? 0 : aPinned ? 2 : 1;
3546
+ p[a3] = p[a3] + dx * diff * aw;
3547
+ p[a3 + 1] = p[a3 + 1] + dy * diff * aw;
3548
+ p[a3 + 2] = p[a3 + 2] + dz * diff * aw;
3549
+ p[b3] = p[b3] - dx * diff * bw;
3550
+ p[b3 + 1] = p[b3 + 1] - dy * diff * bw;
3551
+ p[b3 + 2] = p[b3 + 2] - dz * diff * bw;
3696
3552
  }
3697
- });
3698
- }
3699
- /**
3700
- * Swap the base config without resetting the machine — parameter edits and
3701
- * runtime patches (torn perforation on detach) keep the current state and
3702
- * live values instead of snapping back to `initial`. An in-flight tween is
3703
- * left running on the SAME `flat` object, so the transition continues
3704
- * smoothly to its target across the rebase (no freeze, no snap).
3705
- */
3706
- rebase(base) {
3707
- this.base = base;
3708
- this.resolved.clear();
3709
- const target = this.resolve(this.state);
3710
- this.structure = clone(target);
3711
- this.live = clone(target);
3712
- const targetFlat = flattenNumeric(target);
3713
- for (const path in this.flat) {
3714
- if (!(path in targetFlat)) delete this.flat[path];
3715
3553
  }
3716
- for (const [path, value] of Object.entries(targetFlat)) {
3717
- if (!(path in this.flat)) this.flat[path] = value;
3554
+ for (let i = 0; i < this.count; i++) {
3555
+ const i3 = i * 3;
3556
+ if (p[i3 + 1] < floor) {
3557
+ p[i3 + 1] = floor;
3558
+ this.prev[i3] = this.prev[i3] + (p[i3] - this.prev[i3]) * 0.5;
3559
+ this.prev[i3 + 2] = this.prev[i3 + 2] + (p[i3 + 2] - this.prev[i3 + 2]) * 0.5;
3560
+ }
3718
3561
  }
3719
- this.emitStructure();
3720
- }
3721
- dispose() {
3722
- this.tween?.kill();
3723
- this.tween = null;
3724
- }
3725
- resolve(state) {
3726
- let config = this.resolved.get(state);
3727
- if (!config) {
3728
- config = resolveStateConfig(this.base, state);
3729
- this.resolved.set(state, config);
3562
+ if (wind === 0 && this.grabbedIndex < 0) {
3563
+ if (maxTravel < SLEEP_EPSILON) {
3564
+ if (++this.stillFrames > SLEEP_FRAMES) this.asleep = true;
3565
+ } else {
3566
+ this.stillFrames = 0;
3567
+ }
3730
3568
  }
3731
- return config;
3732
- }
3733
- emitStructure() {
3734
- this.opts.onChange?.(this.structuralConfig(), this.state);
3735
3569
  }
3736
3570
  };
3737
3571
 
3738
- // src/states/usePaperStates.ts
3739
- import { useEffect as useEffect3, useMemo as useMemo2, useRef, useState as useState2 } from "react";
3740
- function usePaperStates(config, enabled, instant, onAction, onStateChange) {
3741
- const live = enabled && Boolean(config.states);
3742
- const key = useMemo2(() => JSON.stringify(config), [config]);
3743
- const onActionRef = useRef(onAction);
3744
- onActionRef.current = onAction;
3745
- const onStateChangeRef = useRef(onStateChange);
3746
- onStateChangeRef.current = onStateChange;
3747
- const machineRef = useRef(null);
3748
- const lastStateRef = useRef("rest");
3749
- const [animated, setAnimated] = useState2(null);
3750
- useEffect3(() => {
3751
- if (!live) {
3752
- machineRef.current?.dispose();
3753
- machineRef.current = null;
3754
- setAnimated(null);
3755
- return;
3756
- }
3757
- if (machineRef.current) {
3758
- machineRef.current.rebase(config);
3759
- return;
3760
- }
3761
- const machine = new PaperStateMachine(config, {
3762
- instant,
3763
- // Structural boundaries only (not per tick) safe to route to React.
3764
- onChange: (c, state) => {
3765
- if (state !== lastStateRef.current) {
3766
- lastStateRef.current = state;
3767
- onStateChangeRef.current?.(state);
3768
- }
3769
- setAnimated({ config: c, state });
3770
- },
3771
- onAction: (event, state) => onActionRef.current?.(event, state)
3772
- });
3773
- machineRef.current = machine;
3774
- lastStateRef.current = machine.state;
3775
- setAnimated({ config: machine.structuralConfig(), state: machine.state });
3776
- }, [key, live, instant]);
3777
- useEffect3(
3778
- () => () => {
3779
- machineRef.current?.dispose();
3780
- machineRef.current = null;
3781
- },
3782
- []
3572
+ // src/surface/PaperMaterial.tsx
3573
+ import * as THREE6 from "three";
3574
+ import { useEffect as useEffect4, useMemo as useMemo2 } from "react";
3575
+ import CustomShaderMaterial from "three-custom-shader-material";
3576
+
3577
+ // src/surface/compose.ts
3578
+ import * as THREE5 from "three";
3579
+
3580
+ // src/surface/translucency.ts
3581
+ import * as THREE4 from "three";
3582
+ var TRANSLUCENCY_VARYINGS = (
3583
+ /* glsl */
3584
+ `
3585
+ varying vec3 vPlWorldNormal;
3586
+ varying vec3 vPlViewDir;
3587
+ `
3588
+ );
3589
+ function translucencyVertexChunk(slots) {
3590
+ return (
3591
+ /* glsl */
3592
+ `
3593
+ {
3594
+ mat4 plModel = ${slots.model};
3595
+ vec4 plWorld = plModel * vec4(${slots.position}, 1.0);
3596
+ // Uniform scale only \u2014 layouts scale sheets evenly, so the plain 3\xD73 is
3597
+ // the correct normal matrix here and skips an inverse-transpose.
3598
+ vPlWorldNormal = normalize(mat3(plModel) * ${slots.normal});
3599
+ vPlViewDir = cameraPosition - plWorld.xyz;
3600
+ }
3601
+ `
3783
3602
  );
3603
+ }
3604
+ var TRANSMISSION_GAIN = 0.5;
3605
+ var TRANSLUCENCY_FRAGMENT = (
3606
+ /* glsl */
3607
+ `
3608
+ uniform float uTranslucency;
3609
+ uniform vec3 uBackLightDir;
3610
+ uniform vec3 uBackLightColor;
3611
+ uniform float uAmbientTransmission;
3612
+ ${TRANSLUCENCY_VARYINGS}
3613
+
3614
+ vec3 plTransmission(vec3 inkFilter) {
3615
+ if (uTranslucency <= 0.0) return vec3(0.0);
3616
+ vec3 n = normalize(vPlWorldNormal);
3617
+ // Sheets render double-sided; the back face needs the normal it actually shows.
3618
+ if (!gl_FrontFacing) n = -n;
3619
+ // The lamp is BEHIND this sheet when the face we are looking at points away
3620
+ // from it \u2014 that is the whole test.
3621
+ float behind = clamp(-dot(n, uBackLightDir), 0.0, 1.0);
3622
+ // A grazing view looks through more paper, and more paper passes less light.
3623
+ float thickness = abs(dot(n, normalize(vPlViewDir)));
3624
+ // Paper in a lit room glows whatever way it is turned \u2014 a sheet standing
3625
+ // edge-on to the only lamp is not black. Without this floor, a banner
3626
+ // whose face runs parallel to the key light gets neither diffuse nor
3627
+ // transmission and drops out of the picture entirely.
3628
+ vec3 arriving = uBackLightColor * behind + uAmbientTransmission;
3629
+ return arriving * uTranslucency * mix(0.25, 1.0, thickness) * inkFilter;
3630
+ }
3631
+ `
3632
+ );
3633
+ function translucencyValues(translucency, lighting) {
3634
+ const preset = typeof lighting === "string" ? getLightingPreset(lighting) : lighting;
3635
+ const [x, y, z26] = preset.key.position;
3636
+ const direction = new THREE4.Vector3(x, y, z26);
3637
+ if (direction.lengthSq() < 1e-12) direction.set(0, 1, 0);
3638
+ direction.normalize();
3639
+ const color = new THREE4.Color(preset.key.color).multiplyScalar(preset.key.intensity * TRANSMISSION_GAIN);
3640
+ return { translucency, direction, color, ambient: preset.ambient * TRANSMISSION_GAIN };
3641
+ }
3642
+ function translucencyUniforms(translucency, lighting) {
3643
+ const values = translucencyValues(translucency, lighting);
3784
3644
  return {
3785
- config: live && animated ? animated.config : stripStates(config),
3786
- state: live && animated ? animated.state : "rest",
3787
- machine: live ? machineRef.current : null
3645
+ uTranslucency: { value: values.translucency },
3646
+ uBackLightDir: { value: values.direction },
3647
+ uBackLightColor: { value: values.color },
3648
+ uAmbientTransmission: { value: values.ambient }
3788
3649
  };
3789
3650
  }
3790
3651
 
3791
- // src/PaperMesh.tsx
3792
- import * as THREE7 from "three";
3793
- import { gsap as gsap2 } from "gsap";
3794
- import { useFrame, useThree } from "@react-three/fiber";
3795
- import { forwardRef, useEffect as useEffect5, useImperativeHandle, useMemo as useMemo3, useRef as useRef3 } from "react";
3652
+ // src/surface/compose.ts
3653
+ var VERTEX = (
3654
+ /* glsl */
3655
+ `
3656
+ varying vec2 vPaperUv;
3657
+ ${TRANSLUCENCY_VARYINGS}
3658
+ void main() {
3659
+ vPaperUv = uv;
3660
+ ${translucencyVertexChunk({ model: "modelMatrix", position: "position", normal: "normal" })}
3661
+ }
3662
+ `
3663
+ );
3664
+ var HELPERS = (
3665
+ /* glsl */
3666
+ `
3667
+ varying vec2 vPaperUv;
3668
+ uniform float uBackDarken;
3796
3669
 
3797
- // src/core/stable.ts
3798
- import { useRef as useRef2 } from "react";
3799
- function useStable(value) {
3800
- const held = useRef2(value);
3801
- if (!deepEqual(held.current, value)) held.current = value;
3802
- return held.current;
3670
+ float plHash(vec2 p) {
3671
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
3803
3672
  }
3804
- function deepEqual(a, b) {
3805
- if (Object.is(a, b)) return true;
3806
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
3807
- if (Array.isArray(a) || Array.isArray(b)) {
3808
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
3809
- for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
3810
- return true;
3673
+
3674
+ float plNoise(vec2 p) {
3675
+ vec2 i = floor(p);
3676
+ vec2 f = fract(p);
3677
+ vec2 u = f * f * (3.0 - 2.0 * f);
3678
+ return mix(
3679
+ mix(plHash(i), plHash(i + vec2(1.0, 0.0)), u.x),
3680
+ mix(plHash(i + vec2(0.0, 1.0)), plHash(i + vec2(1.0, 1.0)), u.x),
3681
+ u.y
3682
+ );
3683
+ }
3684
+
3685
+ float plFbm(vec2 p) {
3686
+ float v = 0.0;
3687
+ float a = 0.5;
3688
+ for (int i = 0; i < 4; i++) {
3689
+ v += a * plNoise(p);
3690
+ p *= 2.03;
3691
+ a *= 0.5;
3811
3692
  }
3812
- const left = a;
3813
- const right = b;
3814
- const keys = /* @__PURE__ */ new Set();
3815
- for (const key of Object.keys(left)) if (left[key] !== void 0) keys.add(key);
3816
- for (const key of Object.keys(right)) if (right[key] !== void 0) keys.add(key);
3817
- for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
3818
- return true;
3693
+ return v;
3819
3694
  }
3695
+ `
3696
+ );
3697
+ var edgeFlags = (edges) => new THREE5.Vector4(
3698
+ edges.includes("top") ? 1 : 0,
3699
+ edges.includes("right") ? 1 : 0,
3700
+ edges.includes("bottom") ? 1 : 0,
3701
+ edges.includes("left") ? 1 : 0
3702
+ );
3703
+ var GRAIN_CHUNK = (
3704
+ /* glsl */
3705
+ `
3706
+ uniform float uGrainAmount;
3707
+ uniform float uGrainBanding;
3820
3708
 
3821
- // src/content/texture.ts
3822
- import * as THREE6 from "three";
3823
- import { useEffect as useEffect4, useState as useState3 } from "react";
3709
+ void plGrain(inout vec4 color, inout float rough) {
3710
+ float fiber = plFbm(vPaperUv * 240.0);
3711
+ float fleck = plNoise(vPaperUv * 900.0);
3712
+ float g = mix(0.5, fiber * 0.75 + fleck * 0.25, uGrainAmount);
3713
+ color.rgb *= 0.92 + g * 0.16;
3714
+ rough = clamp(rough + (g - 0.5) * uGrainAmount * 0.35, 0.0, 1.0);
3715
+ // Thermal-printer banding: faint horizontal density stripes.
3716
+ if (uGrainBanding > 0.0) {
3717
+ float band = sin(vPaperUv.y * 700.0) * 0.5 + 0.5;
3718
+ color.rgb *= 1.0 - uGrainBanding * 0.05 * band;
3719
+ }
3720
+ }
3721
+ `
3722
+ );
3723
+ var DECKLE_CHUNK = (
3724
+ /* glsl */
3725
+ `
3726
+ uniform vec4 uDeckleEdges; // top, right, bottom, left
3727
+ uniform float uDeckleRoughness;
3824
3728
 
3825
- // src/content/card.ts
3826
- var TITLE_RATIO = 0.52;
3827
- var NOTE_RATIO = 0.46;
3828
- var TITLE_TRACKING = 0.16;
3829
- var NOTE_TRACKING = 0.06;
3830
- function paintCard(ctx, w, h, content, stock, dpr) {
3831
- const ink = content.color === "#2b2620" ? stock.inkColor : content.color;
3832
- const size = content.size * dpr;
3833
- const pad = content.padding * Math.min(w, h);
3834
- const maxWidth = w - pad * 2;
3835
- const x = content.align === "center" ? w / 2 : pad;
3836
- ctx.textAlign = content.align === "center" ? "center" : "left";
3837
- ctx.textBaseline = "alphabetic";
3838
- const titleSize = size * TITLE_RATIO;
3839
- const noteSize = size * NOTE_RATIO;
3840
- const bodyStep = size * 1.35;
3841
- const bodyLines = content.body ? wrapLines(ctx, content.body, maxWidth, `${size}px ${content.font}`) : [];
3842
- const titleBlock = content.title ? titleSize * 1.9 : 0;
3843
- const ruleBlock = content.rule && content.title ? titleSize * 0.9 : 0;
3844
- const noteBlock = content.note ? noteSize * 2.4 : 0;
3845
- const bodyBlock = bodyLines.length * bodyStep;
3846
- const total = titleBlock + ruleBlock + bodyBlock + noteBlock;
3847
- let y = Math.max(pad, (h - total) / 2) + size * 0.9;
3848
- if (content.title) {
3849
- ctx.font = `${titleSize}px ${content.font}`;
3850
- ctx.letterSpacing = `${TITLE_TRACKING}em`;
3851
- ctx.fillStyle = ink;
3852
- ctx.globalAlpha = 0.72;
3853
- ctx.fillText(content.title.toUpperCase(), x, y - size * 0.5);
3854
- ctx.globalAlpha = 1;
3855
- ctx.letterSpacing = "0em";
3856
- y += titleBlock - size * 0.5;
3857
- if (content.rule) {
3858
- ctx.save();
3859
- ctx.strokeStyle = ink;
3860
- ctx.globalAlpha = 0.28;
3861
- ctx.lineWidth = Math.max(1, dpr * 0.75);
3862
- ctx.beginPath();
3863
- ctx.moveTo(content.align === "center" ? w / 2 - maxWidth / 2 : pad, y - titleSize * 0.5);
3864
- ctx.lineTo(content.align === "center" ? w / 2 + maxWidth / 2 : pad + maxWidth, y - titleSize * 0.5);
3865
- ctx.stroke();
3866
- ctx.restore();
3867
- y += ruleBlock;
3868
- }
3729
+ void plDeckle(inout vec4 color) {
3730
+ // Distance to each selected edge, gnawed by low-frequency noise.
3731
+ float depth = 0.012 + uDeckleRoughness * 0.05;
3732
+ float tear = 1.0;
3733
+ float fiberBand = 0.0;
3734
+ vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3735
+ vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
3736
+ for (int e = 0; e < 4; e++) {
3737
+ if (uDeckleEdges[e] < 0.5) continue;
3738
+ float n = plFbm(vec2(alongs[e] * 26.0, float(e) * 7.31)) - 0.5;
3739
+ float boundary = depth * (0.55 + n * 1.6);
3740
+ float d = dists[e] - boundary;
3741
+ tear = min(tear, step(0.0, d));
3742
+ // Lightened fiber band just inside the tear.
3743
+ fiberBand = max(fiberBand, smoothstep(depth * 1.4, 0.0, d) * step(0.0, d));
3869
3744
  }
3870
- if (content.ruled && bodyLines.length > 0) {
3871
- ctx.save();
3872
- ctx.strokeStyle = ink;
3873
- ctx.globalAlpha = 0.14;
3874
- ctx.lineWidth = Math.max(1, dpr * 0.6);
3875
- for (let i = 0; i < bodyLines.length; i++) {
3876
- const lineY = y + i * bodyStep + size * 0.28;
3877
- ctx.beginPath();
3878
- ctx.moveTo(pad, lineY);
3879
- ctx.lineTo(pad + maxWidth, lineY);
3880
- ctx.stroke();
3745
+ color.a *= tear;
3746
+ color.rgb = mix(color.rgb, vec3(1.0), fiberBand * 0.35);
3747
+ }
3748
+ `
3749
+ );
3750
+ var CREASE_CHUNK = (
3751
+ /* glsl */
3752
+ `
3753
+ uniform float uCreaseAngle;
3754
+ uniform float uCreaseStrength;
3755
+ uniform float uCreasePositions[4];
3756
+ uniform int uCreaseCount;
3757
+
3758
+ void plCrease(inout vec4 color, inout float rough) {
3759
+ vec2 dir = vec2(cos(uCreaseAngle), sin(uCreaseAngle));
3760
+ // Coordinate across the crease lines (0..1 over the sheet).
3761
+ float t = dot(vPaperUv - 0.5, vec2(-dir.y, dir.x)) + 0.5;
3762
+ for (int i = 0; i < 4; i++) {
3763
+ if (i >= uCreaseCount) break;
3764
+ float d = abs(t - uCreasePositions[i]);
3765
+ float shadow = smoothstep(0.014, 0.0, d);
3766
+ float sheen = smoothstep(0.02, 0.006, d) - smoothstep(0.006, 0.0, d);
3767
+ color.rgb *= 1.0 - shadow * uCreaseStrength * 0.28;
3768
+ color.rgb += sheen * uCreaseStrength * 0.05;
3769
+ rough = clamp(rough + shadow * uCreaseStrength * 0.2, 0.0, 1.0);
3770
+ }
3771
+ }
3772
+ `
3773
+ );
3774
+ var PERFORATION_CHUNK = (
3775
+ /* glsl */
3776
+ `
3777
+ uniform vec4 uPerfEdges; // top, right, bottom, left enabled
3778
+ uniform vec4 uPerfTorn; // 1 = ripped-through profile, 0 = clean punches
3779
+ uniform float uPerfRadius; // world units
3780
+ uniform float uPerfSpacing;
3781
+ uniform vec2 uSheetSize;
3782
+
3783
+ void plPerforation(inout vec4 color) {
3784
+ // Per-edge distance/along coordinates, converted from UV to world units so
3785
+ // hole size is stable across sheet dimensions.
3786
+ vec4 dists = vec4(1.0 - vPaperUv.y, 1.0 - vPaperUv.x, vPaperUv.y, vPaperUv.x);
3787
+ vec4 alongs = vec4(vPaperUv.x, vPaperUv.y, vPaperUv.x, vPaperUv.y);
3788
+ vec4 distScale = vec4(uSheetSize.y, uSheetSize.x, uSheetSize.y, uSheetSize.x);
3789
+ vec4 alongScale = vec4(uSheetSize.x, uSheetSize.y, uSheetSize.x, uSheetSize.y);
3790
+ float fiber = 0.0;
3791
+ for (int e = 0; e < 4; e++) {
3792
+ if (uPerfEdges[e] < 0.5) continue;
3793
+ float d = dists[e] * distScale[e];
3794
+ float a = alongs[e] * alongScale[e];
3795
+ // Signed distance along the edge to the nearest hole center.
3796
+ float cell = mod(a + uPerfSpacing * 0.5, uPerfSpacing) - uPerfSpacing * 0.5;
3797
+ if (uPerfTorn[e] < 0.5) {
3798
+ // Intact: clean semicircular punches on the edge line (alphaTest, not
3799
+ // blending \u2014 shadow correctness).
3800
+ if (length(vec2(cell, d)) < uPerfRadius) color.a = 0.0;
3801
+ } else {
3802
+ // Torn: ripped profile following the hole rhythm \u2014 alternating tabs and
3803
+ // notches, gnawed by noise, with a lightened fiber band along the tear.
3804
+ float rhythm = abs(sin(a / uPerfSpacing * 3.14159265));
3805
+ float n = plNoise(vec2(a * 40.0, float(e) * 7.31)) - 0.5;
3806
+ float cut = uPerfRadius * (0.35 + rhythm * 1.35 + n * 0.9);
3807
+ if (d < cut) color.a = 0.0;
3808
+ fiber = max(fiber, smoothstep(uPerfRadius * 2.4, 0.0, d - cut) * step(cut, d));
3881
3809
  }
3882
- ctx.restore();
3883
- }
3884
- ctx.font = `${size}px ${content.font}`;
3885
- ctx.fillStyle = ink;
3886
- for (const line of bodyLines) {
3887
- if (y > h - pad) break;
3888
- ctx.fillText(line, x, y);
3889
- y += bodyStep;
3890
- }
3891
- if (content.note) {
3892
- ctx.font = `${noteSize}px ${content.font}`;
3893
- ctx.letterSpacing = `${NOTE_TRACKING}em`;
3894
- ctx.globalAlpha = 0.6;
3895
- ctx.fillText(content.note, x, Math.min(y + noteSize * 1.4, h - pad));
3896
- ctx.globalAlpha = 1;
3897
- ctx.letterSpacing = "0em";
3898
3810
  }
3811
+ color.rgb = mix(color.rgb, vec3(1.0), fiber * 0.4);
3899
3812
  }
3813
+ `
3814
+ );
3815
+ var AGING_CHUNK = (
3816
+ /* glsl */
3817
+ `
3818
+ uniform float uAgingAmount;
3900
3819
 
3901
- // src/content/texture.ts
3902
- var LONG_EDGE = 1024;
3903
- var DPR = 2;
3904
- function contentCanvasSize(sheet2) {
3905
- const long = Math.max(sheet2.width, sheet2.height);
3906
- const w = Math.round(sheet2.width / long * LONG_EDGE * DPR);
3907
- const h = Math.round(sheet2.height / long * LONG_EDGE * DPR);
3908
- return [w, h];
3909
- }
3910
- function paintBackground(ctx, w, h, stock) {
3911
- ctx.fillStyle = stock.color;
3912
- ctx.fillRect(0, 0, w, h);
3913
- }
3914
- function paintImage(ctx, w, h, img, fit) {
3915
- const scale = fit === "cover" ? Math.max(w / img.width, h / img.height) : Math.min(w / img.width, h / img.height);
3916
- const dw = img.width * scale;
3917
- const dh = img.height * scale;
3918
- ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
3820
+ void plAging(inout vec4 color) {
3821
+ // Yellowing deepens toward the edges, like light exposure.
3822
+ float edge = max(abs(vPaperUv.x - 0.5), abs(vPaperUv.y - 0.5)) * 2.0;
3823
+ vec3 yellowed = color.rgb * vec3(1.0, 0.94, 0.78);
3824
+ color.rgb = mix(color.rgb, yellowed, uAgingAmount * (0.45 + edge * 0.55));
3825
+ // Foxing: sparse rusty blotches.
3826
+ float fox = plFbm(vPaperUv * 14.0 + 3.7);
3827
+ float spots = smoothstep(0.62, 0.78, fox) * uAgingAmount;
3828
+ color.rgb = mix(color.rgb, vec3(0.62, 0.45, 0.26), spots * 0.5);
3919
3829
  }
3920
- function paintText(ctx, w, h, content, stock) {
3921
- const size = content.size * DPR;
3922
- const pad = content.padding * Math.min(w, h);
3923
- const font = `${content.weight} ${size}px ${content.font}`;
3924
- ctx.font = font;
3925
- ctx.fillStyle = content.color === "#2b2620" ? stock.inkColor : content.color;
3926
- ctx.textBaseline = "top";
3927
- ctx.textAlign = content.align;
3928
- ctx.letterSpacing = `${content.tracking}em`;
3929
- const maxWidth = w - pad * 2;
3930
- const x = content.align === "left" ? pad : content.align === "right" ? w - pad : w / 2;
3931
- const lineStep = size * content.lineHeight;
3932
- const lines = wrapLines(ctx, content.text, maxWidth, font);
3933
- ctx.font = font;
3934
- ctx.letterSpacing = `${content.tracking}em`;
3935
- const block = lines.length * lineStep;
3936
- let y = content.valign === "center" ? Math.max(pad, (h - block) / 2) : pad;
3937
- for (const line of lines) {
3938
- if (y > h - pad) break;
3939
- ctx.fillText(line, x, y);
3940
- y += lineStep;
3830
+ `
3831
+ );
3832
+ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false, hasBackMap: false }, sheet2 = { width: 1, height: 1.4 }, lighting = "studio") {
3833
+ const grain = surface.grain ?? stock.defaultSurface.grain;
3834
+ const aging = surface.aging ?? stock.defaultSurface.aging;
3835
+ const deckle = surface.deckle;
3836
+ const creases = surface.creaseLines;
3837
+ const perforation = surface.perforation;
3838
+ const banding = stock.banding;
3839
+ const showThrough = stock.adhesive ? 0 : surface.showThrough ?? stock.showThrough;
3840
+ const chunks = [];
3841
+ const calls = [];
3842
+ const uniforms = {
3843
+ // Backside darkening: thicker/opaque stock lets less light through.
3844
+ // Adhesive backs skip it — the glue layer is its own bright surface.
3845
+ uBackDarken: {
3846
+ value: stock.adhesive ? 1 : 1 - Math.min(0.45, 0.12 + thickness * 0.9) * stock.opacity
3847
+ },
3848
+ uStockColor: { value: new THREE5.Color(stock.color) },
3849
+ uOpacity: { value: stock.opacity },
3850
+ uShowThrough: { value: showThrough },
3851
+ // Always compiled in: the shader early-outs at zero translucency, which
3852
+ // is cheaper than carrying a second program structure for it.
3853
+ ...translucencyUniforms(surface.translucency ?? stock.translucency, lighting)
3854
+ };
3855
+ if (maps.hasFrontMap) uniforms.uFrontMap = { value: null };
3856
+ if (maps.hasBackMap) uniforms.uBackMap = { value: null };
3857
+ if (grain !== void 0 || banding > 0) {
3858
+ chunks.push(GRAIN_CHUNK);
3859
+ calls.push("plGrain(csm_DiffuseColor, csm_Roughness);");
3860
+ uniforms.uGrainAmount = { value: grain ?? 0 };
3861
+ uniforms.uGrainBanding = { value: banding };
3941
3862
  }
3942
- ctx.letterSpacing = "0em";
3863
+ if (deckle) {
3864
+ chunks.push(DECKLE_CHUNK);
3865
+ calls.push("plDeckle(csm_DiffuseColor);");
3866
+ uniforms.uDeckleEdges = { value: edgeFlags(deckle.edges) };
3867
+ uniforms.uDeckleRoughness = { value: deckle.roughness };
3868
+ }
3869
+ if (perforation) {
3870
+ const edges = perforation.edges === "all" ? [...paperEdges] : perforation.edges;
3871
+ chunks.push(PERFORATION_CHUNK);
3872
+ calls.push("plPerforation(csm_DiffuseColor);");
3873
+ uniforms.uPerfEdges = { value: edgeFlags(edges) };
3874
+ uniforms.uPerfTorn = {
3875
+ value: new THREE5.Vector4(
3876
+ ...paperEdges.map((e) => edges.includes(e) && perforation.state[e] === "torn" ? 1 : 0)
3877
+ )
3878
+ };
3879
+ uniforms.uPerfRadius = { value: perforation.holeRadius };
3880
+ uniforms.uPerfSpacing = { value: perforation.spacing };
3881
+ uniforms.uSheetSize = { value: new THREE5.Vector2(sheet2.width, sheet2.height) };
3882
+ }
3883
+ if (creases) {
3884
+ chunks.push(CREASE_CHUNK);
3885
+ calls.push("plCrease(csm_DiffuseColor, csm_Roughness);");
3886
+ uniforms.uCreaseAngle = { value: creases.angle * Math.PI / 180 };
3887
+ uniforms.uCreaseStrength = { value: creases.strength };
3888
+ uniforms.uCreasePositions = { value: padPositions(creases.positions) };
3889
+ uniforms.uCreaseCount = { value: Math.min(creases.positions.length, 4) };
3890
+ }
3891
+ if (aging !== void 0) {
3892
+ chunks.push(AGING_CHUNK);
3893
+ calls.push("plAging(csm_DiffuseColor);");
3894
+ uniforms.uAgingAmount = { value: aging };
3895
+ }
3896
+ const frontExpr = maps.hasFrontMap ? "texture2D(uFrontMap, vPaperUv).rgb" : "uStockColor";
3897
+ const backBaseExpr = stock.adhesive ? "vec3(0.965, 0.96, 0.945)" : maps.hasBackMap ? "texture2D(uBackMap, vec2(1.0 - vPaperUv.x, vPaperUv.y)).rgb" : "uStockColor";
3898
+ const fragmentShader = (
3899
+ /* glsl */
3900
+ `
3901
+ ${HELPERS}
3902
+ uniform vec3 uStockColor;
3903
+ uniform float uOpacity;
3904
+ uniform float uShowThrough;
3905
+ ${maps.hasFrontMap ? "uniform sampler2D uFrontMap;" : ""}
3906
+ ${maps.hasBackMap && !stock.adhesive ? "uniform sampler2D uBackMap;" : ""}
3907
+ ${TRANSLUCENCY_FRAGMENT}
3908
+ ${chunks.join("\n")}
3909
+ void main() {
3910
+ vec3 front = ${frontExpr};
3911
+ if (gl_FrontFacing) {
3912
+ csm_DiffuseColor = vec4(front, uOpacity);
3913
+ } else {
3914
+ vec3 backBase = ${backBaseExpr};
3915
+ csm_DiffuseColor = vec4(backBase * mix(vec3(1.0), front, uShowThrough), uOpacity);
3916
+ }
3917
+ ${calls.join("\n ")}
3918
+ if (!gl_FrontFacing) csm_DiffuseColor.rgb *= uBackDarken;
3919
+ ${stock.adhesive ? "// Adhesive underside: higher specular than the printed face.\n if (!gl_FrontFacing) csm_Roughness = 0.18;" : ""}
3920
+ // What the key light pushes through the sheet, filtered by the ink on it.
3921
+ csm_Emissive = plTransmission(front);
3943
3922
  }
3944
- function renderContentToCanvas(content, sheet2, stock, image) {
3945
- const [w, h] = contentCanvasSize(sheet2);
3946
- const canvas = document.createElement("canvas");
3947
- canvas.width = w;
3948
- canvas.height = h;
3949
- const ctx = canvas.getContext("2d");
3950
- paintBackground(ctx, w, h, stock);
3951
- if (content.type === "image" && image && content.src) paintImage(ctx, w, h, image, content.fit);
3952
- if (content.type === "text") paintText(ctx, w, h, content, stock);
3953
- if (content.type === "receipt") paintReceipt(ctx, w, h, content, stock);
3954
- if (content.type === "card") paintCard(ctx, w, h, content, stock, DPR);
3955
- return canvas;
3923
+ `
3924
+ );
3925
+ return {
3926
+ structureKey: `${[
3927
+ grain !== void 0 || banding > 0 ? "g" : "",
3928
+ deckle ? "d" : "",
3929
+ creases ? "c" : "",
3930
+ aging !== void 0 ? "a" : "",
3931
+ perforation ? "p" : "",
3932
+ stock.adhesive ? "A" : ""
3933
+ ].join("")}:${maps.hasFrontMap ? "F" : ""}${maps.hasBackMap ? "B" : ""}`,
3934
+ vertexShader: VERTEX,
3935
+ fragmentShader,
3936
+ uniforms,
3937
+ alphaTest: deckle || perforation ? 0.5 : 0
3938
+ };
3956
3939
  }
3957
- function makeTexture(canvas) {
3958
- const tex = new THREE6.CanvasTexture(canvas);
3959
- tex.colorSpace = THREE6.SRGBColorSpace;
3960
- tex.anisotropy = 8;
3961
- tex.generateMipmaps = true;
3962
- return tex;
3940
+ function padPositions(positions) {
3941
+ const out = positions.slice(0, 4);
3942
+ while (out.length < 4) out.push(-1);
3943
+ return out;
3963
3944
  }
3964
- function useContentTexture(content, sheet2, stock) {
3965
- const [texture, setTexture] = useState3(null);
3966
- const key = JSON.stringify({ content: content ?? null, w: sheet2.width, h: sheet2.height, stock: stock.id });
3945
+
3946
+ // src/surface/PaperMaterial.tsx
3947
+ import { jsx as jsx3 } from "react/jsx-runtime";
3948
+ function PaperMaterial({
3949
+ stock,
3950
+ texture,
3951
+ backTexture,
3952
+ surface,
3953
+ thickness,
3954
+ sheet: sheet2,
3955
+ lighting = "studio"
3956
+ }) {
3957
+ const rig = useLightRig(lighting);
3958
+ const composed = composeSurface(
3959
+ surface,
3960
+ stock,
3961
+ thickness,
3962
+ {
3963
+ hasFrontMap: Boolean(texture),
3964
+ hasBackMap: Boolean(backTexture)
3965
+ },
3966
+ sheet2,
3967
+ rig
3968
+ );
3969
+ const bound = useMemo2(() => composed.uniforms, [composed.structureKey]);
3967
3970
  useEffect4(() => {
3968
- let disposed = false;
3969
- let tex = null;
3970
- if (!content) {
3971
- setTexture(null);
3972
- return;
3973
- }
3974
- const commit = (canvas) => {
3975
- if (disposed) return;
3976
- tex = makeTexture(canvas);
3977
- setTexture(tex);
3978
- };
3979
- if (content.type === "image" && content.src) {
3980
- const img = new Image();
3981
- img.crossOrigin = "anonymous";
3982
- img.onload = () => commit(renderContentToCanvas(content, sheet2, stock, img));
3983
- img.onerror = () => commit(renderContentToCanvas(content, sheet2, stock));
3984
- img.src = content.src;
3985
- } else if (content.type === "text" || content.type === "card") {
3986
- void ensureFont(content.font, content.size * DPR).then(
3987
- () => commit(renderContentToCanvas(content, sheet2, stock))
3988
- );
3989
- } else if (content.type === "receipt") {
3990
- document.fonts.ready.then(() => commit(renderContentToCanvas(content, sheet2, stock)));
3991
- } else {
3992
- commit(renderContentToCanvas(content, sheet2, stock));
3971
+ for (const [key, uniform] of Object.entries(composed.uniforms)) {
3972
+ if (!bound[key] || key === "uFrontMap" || key === "uBackMap") continue;
3973
+ if (bound[key].value instanceof THREE6.Color && uniform.value instanceof THREE6.Color) {
3974
+ ;
3975
+ bound[key].value.copy(uniform.value);
3976
+ } else {
3977
+ bound[key].value = uniform.value;
3978
+ }
3993
3979
  }
3994
- return () => {
3995
- disposed = true;
3996
- tex?.dispose();
3997
- };
3998
- }, [key]);
3999
- return texture;
3980
+ });
3981
+ useEffect4(() => {
3982
+ if (bound.uFrontMap) bound.uFrontMap.value = texture;
3983
+ if (bound.uBackMap) bound.uBackMap.value = backTexture ?? null;
3984
+ }, [bound, texture, backTexture]);
3985
+ return /* @__PURE__ */ jsx3(
3986
+ CustomShaderMaterial,
3987
+ {
3988
+ baseMaterial: THREE6.MeshStandardMaterial,
3989
+ vertexShader: composed.vertexShader,
3990
+ fragmentShader: composed.fragmentShader,
3991
+ uniforms: bound,
3992
+ color: "#ffffff",
3993
+ roughness: stock.roughness,
3994
+ metalness: 0,
3995
+ transparent: stock.opacity < 1,
3996
+ opacity: stock.opacity,
3997
+ alphaTest: composed.alphaTest,
3998
+ side: THREE6.DoubleSide
3999
+ },
4000
+ composed.structureKey
4001
+ );
4002
+ }
4003
+
4004
+ // src/motion/onTwos.ts
4005
+ var ON_TWOS_FPS = 12;
4006
+ function quantizeTime(t, fps = ON_TWOS_FPS) {
4007
+ return Math.floor(t * fps) / fps;
4008
+ }
4009
+ function quantizeProgress(p, duration, fps = ON_TWOS_FPS) {
4010
+ const steps = Math.max(1, Math.round(duration * fps));
4011
+ return Math.round(p * steps) / steps;
4000
4012
  }
4001
4013
 
4002
4014
  // src/PaperMesh.tsx
@@ -4792,305 +4804,74 @@ function DropZoneVisual({ registry: registry4, config }) {
4792
4804
  /* @__PURE__ */ jsx6("planeGeometry", { args: [w, h] }),
4793
4805
  /* @__PURE__ */ jsx6(
4794
4806
  "meshBasicMaterial",
4795
- {
4796
- color: hovered && style === "glow" ? "#8ea8ff" : "#5c6f9e",
4797
- transparent: true,
4798
- opacity: hovered && style === "glow" ? 0.32 : 0.14,
4799
- depthWrite: false
4800
- }
4801
- )
4802
- ] }),
4803
- /* @__PURE__ */ jsx6("lineSegments", { geometry: edges, children: /* @__PURE__ */ jsx6("lineBasicMaterial", { color: hovered ? "#aebfff" : "#6b7da8" }) })
4804
- ] });
4805
- }
4806
-
4807
- // src/field/sheetGrid.ts
4808
- import { z as z23 } from "zod";
4809
- var sheetLayoutSchema = z23.object({
4810
- rows: z23.number().int().min(1).max(12).default(2),
4811
- columns: z23.number().int().min(1).max(12).default(5),
4812
- /** World-units gap between slots. Stamps are printed in register — no jitter. */
4813
- gutter: z23.number().min(0).max(1).default(0.08),
4814
- /** Slot footprint in world units (the paper preset should match). */
4815
- cellWidth: z23.number().min(0.1).max(4).default(0.72),
4816
- cellHeight: z23.number().min(0.1).max(4).default(0.86),
4817
- /** Render the shared backing sheet behind the grid. */
4818
- backing: z23.boolean().default(true),
4819
- backingMargin: z23.number().min(0).max(1).default(0.12)
4820
- });
4821
- var SHEET_LIFT = 0.012;
4822
- function withSheetCellFromPaper(parsed, rawOptions, paperDims) {
4823
- if (!paperDims) return parsed;
4824
- const hasW = rawOptions !== void 0 && rawOptions.cellWidth !== void 0;
4825
- const hasH = rawOptions !== void 0 && rawOptions.cellHeight !== void 0;
4826
- if (hasW && hasH) return parsed;
4827
- return {
4828
- ...parsed,
4829
- cellWidth: hasW ? parsed.cellWidth : paperDims.width,
4830
- cellHeight: hasH ? parsed.cellHeight : paperDims.height
4831
- };
4832
- }
4833
- function sheetSlotXY(i, o) {
4834
- const col = i % o.columns;
4835
- const row = Math.floor(i / o.columns);
4836
- return {
4837
- x: (col - (o.columns - 1) / 2) * (o.cellWidth + o.gutter),
4838
- y: ((o.rows - 1) / 2 - row) * (o.cellHeight + o.gutter)
4839
- };
4840
- }
4841
- function sheetBackingSize(o) {
4842
- return {
4843
- width: o.columns * o.cellWidth + (o.columns - 1) * o.gutter + o.backingMargin * 2,
4844
- height: o.rows * o.cellHeight + (o.rows - 1) * o.gutter + o.backingMargin * 2
4845
- };
4846
- }
4847
- function outwardCorner(i, o) {
4848
- const col = i % o.columns;
4849
- const row = Math.floor(i / o.columns);
4850
- const horizontal = col + 0.5 < o.columns / 2 ? "left" : "right";
4851
- const vertical = row + 0.5 < o.rows / 2 ? "top" : "bottom";
4852
- return `${vertical}-${horizontal}`;
4853
- }
4854
- function tornEdgesOnDetach(i, o) {
4855
- const col = i % o.columns;
4856
- const row = Math.floor(i / o.columns);
4857
- return {
4858
- top: row > 0 ? "torn" : "intact",
4859
- bottom: row < o.rows - 1 ? "torn" : "intact",
4860
- left: col > 0 ? "torn" : "intact",
4861
- right: col < o.columns - 1 ? "torn" : "intact"
4862
- };
4863
- }
4864
-
4865
- // src/field/slots.ts
4866
- var EMPTY_SET = /* @__PURE__ */ new Set();
4867
- function effectiveFieldPapers(papers, images) {
4868
- if (papers) return papers;
4869
- if (images) {
4870
- return images.map((src) => ({
4871
- content: { type: "image", src, fit: "cover" }
4872
- }));
4873
- }
4874
- return Array.from({ length: 12 }, () => ({}));
4875
- }
4876
- function groupFieldPapers(papers, fallback) {
4877
- const groups = /* @__PURE__ */ new Map();
4878
- papers.forEach((slot, i) => {
4879
- const config = resolveConfig({ preset: slot.preset ?? fallback });
4880
- const key = JSON.stringify(config);
4881
- let group = groups.get(key);
4882
- if (!group) {
4883
- group = { config, indices: [], contents: [] };
4884
- groups.set(key, group);
4885
- }
4886
- group.indices.push(i);
4887
- group.contents.push(slot.content ? contentSchema.parse(slot.content) : config.content);
4888
- });
4889
- return [...groups.values()];
4890
- }
4891
- function fieldIsInteractive(papers, fallback, explicit) {
4892
- if (explicit !== void 0) return explicit;
4893
- return papers.some((s) => s.states) || groupFieldPapers(papers, fallback).some((g) => Boolean(g.config.states));
4894
- }
4895
- function resolveFieldSlotConfig(slot, fallback, index, layoutId, layoutOptions) {
4896
- let config = resolveConfig({ preset: slot.preset ?? fallback });
4897
- const patch = {};
4898
- if (slot.content) patch.content = slot.content;
4899
- if (slot.states) patch.states = slot.states;
4900
- if (layoutId === "sheet" && config.behavior?.type === "peel" && config.behavior.corner === "auto") {
4901
- const o = sheetLayoutSchema.parse(layoutOptions);
4902
- patch.behavior = { corner: outwardCorner(index, o) };
4903
- }
4904
- if (Object.keys(patch).length > 0) {
4905
- config = paperConfigSchema.parse(mergeConfig(config, patch));
4906
- }
4907
- return config;
4908
- }
4909
-
4910
- // src/content/atlas.ts
4911
- import * as THREE11 from "three";
4912
- import { useEffect as useEffect8, useState as useState4 } from "react";
4913
- var MAX_ATLAS = 4096;
4914
- function atlasGrid(count, aspect = 1) {
4915
- const cols = Math.max(1, Math.min(count, Math.ceil(Math.sqrt(count * Math.max(aspect, 0.01)))));
4916
- return { cols, rows: Math.max(1, Math.ceil(count / cols)) };
4917
- }
4918
- function useContentAtlas(contents, sheet2, stock) {
4919
- const [atlas, setAtlas] = useState4(null);
4920
- const stableContents = useStable(contents);
4921
- useEffect8(() => {
4922
- let disposed = false;
4923
- const aspect = sheet2.height / sheet2.width;
4924
- const { cols, rows } = atlasGrid(contents.length, aspect);
4925
- let tileW = Math.min(1024, Math.floor(MAX_ATLAS / cols));
4926
- let tileH = Math.round(tileW * aspect);
4927
- if (tileH * rows > MAX_ATLAS) {
4928
- tileH = Math.floor(MAX_ATLAS / rows);
4929
- tileW = Math.max(1, Math.round(tileH / aspect));
4930
- }
4931
- const canvas = document.createElement("canvas");
4932
- canvas.width = tileW * cols;
4933
- canvas.height = tileH * rows;
4934
- const ctx = canvas.getContext("2d");
4935
- ctx.fillStyle = stock.color;
4936
- ctx.fillRect(0, 0, canvas.width, canvas.height);
4937
- const texture = new THREE11.CanvasTexture(canvas);
4938
- texture.colorSpace = THREE11.SRGBColorSpace;
4939
- texture.anisotropy = 4;
4940
- setAtlas({ texture, cols, rows });
4941
- const drawTile = (index, tile) => {
4942
- if (disposed) return;
4943
- const x = index % cols * tileW;
4944
- const y = Math.floor(index / cols) * tileH;
4945
- ctx.drawImage(tile, x, y, tileW, tileH);
4946
- texture.needsUpdate = true;
4947
- };
4948
- contents.forEach((content, index) => {
4949
- if (content.type === "image") {
4950
- const img = new Image();
4951
- img.crossOrigin = "anonymous";
4952
- img.onload = () => drawTile(index, renderContentToCanvas(content, sheet2, stock, img));
4953
- img.src = content.src;
4954
- } else if (content.type === "text" || content.type === "receipt") {
4955
- document.fonts.ready.then(() => drawTile(index, renderContentToCanvas(content, sheet2, stock)));
4956
- } else {
4957
- drawTile(index, renderContentToCanvas(content, sheet2, stock));
4958
- }
4959
- });
4960
- return () => {
4961
- disposed = true;
4962
- texture.dispose();
4963
- };
4964
- }, [stableContents, sheet2.width, sheet2.height, stock.id]);
4965
- return atlas;
4966
- }
4967
-
4968
- // src/field/compose.ts
4969
- function glslType(value) {
4970
- if (typeof value === "number") return "float";
4971
- return ["float", "vec2", "vec3", "vec4"][value.length - 1];
4972
- }
4973
- function stackUniformValues(stack, sheet2) {
4974
- const uniforms = { uSheet: [sheet2.width, sheet2.height] };
4975
- stack.forEach((instance, i) => {
4976
- if (instance.enabled === false) return;
4977
- const deformer = getDeformer(instance.type);
4978
- if (!deformer.glsl) return;
4979
- const ns = `u${cap(instance.type)}${i}_`;
4980
- const values = deformer.glsl.uniforms(instance.options);
4981
- for (const [key, value] of Object.entries(values)) uniforms[ns + key] = value;
4982
- });
4983
- return uniforms;
4984
- }
4985
- function buildDisplacementGLSL(stack, sheet2) {
4986
- const decls = ["uniform vec2 uSheet;", "float plBias = 1.0;"];
4987
- const functions = [];
4988
- const calls = [];
4989
- const uniforms = { uSheet: [sheet2.width, sheet2.height] };
4990
- stack.forEach((instance, i) => {
4991
- if (instance.enabled === false) return;
4992
- const deformer = getDeformer(instance.type);
4993
- if (!deformer.glsl) {
4994
- throw new Error(
4995
- `[paperlab] Deformer "${instance.type}" has no GLSL implementation \u2014 it can't run in field mode.`
4996
- );
4997
- }
4998
- const ns = `u${cap(instance.type)}${i}_`;
4999
- const fn = `pl_${instance.type}${i}`;
5000
- const values = deformer.glsl.uniforms(instance.options);
5001
- for (const [key, value] of Object.entries(values)) {
5002
- decls.push(`uniform ${glslType(value)} ${ns}${key};`);
5003
- uniforms[ns + key] = value;
5004
- }
5005
- const strength = deformer.glsl.strength;
5006
- functions.push(
5007
- deformer.glsl.chunk.replaceAll("FN", fn).replace(
5008
- /U_(\w+)/g,
5009
- (_, name) => (
5010
- // The strength uniform reads through the per-instance bias, so one
5011
- // instanced draw call can bend every sheet by a different amount.
5012
- name === strength ? `(${ns}${name} * plBias)` : ns + name
5013
- )
4807
+ {
4808
+ color: hovered && style === "glow" ? "#8ea8ff" : "#5c6f9e",
4809
+ transparent: true,
4810
+ opacity: hovered && style === "glow" ? 0.32 : 0.14,
4811
+ depthWrite: false
4812
+ }
5014
4813
  )
5015
- );
5016
- calls.push(`${fn}(q, uv, t);`);
5017
- });
5018
- const displaceSrc = (
5019
- /* glsl */
5020
- `
5021
- vec3 plDisplace(vec3 p, vec2 uv, float t, float bias) {
5022
- plBias = bias;
5023
- vec3 q = p;
5024
- ${calls.join("\n ")}
5025
- return q;
5026
- }
5027
- `
5028
- );
5029
- return { functionsSrc: `${decls.join("\n")}
5030
- ${functions.join("\n")}`, displaceSrc, uniforms };
4814
+ ] }),
4815
+ /* @__PURE__ */ jsx6("lineSegments", { geometry: edges, children: /* @__PURE__ */ jsx6("lineBasicMaterial", { color: hovered ? "#aebfff" : "#6b7da8" }) })
4816
+ ] });
5031
4817
  }
5032
- function buildFieldVertexShader(composed) {
5033
- return (
5034
- /* glsl */
5035
- `
5036
- uniform float uPlTime;
5037
- attribute float aPhase;
5038
- attribute float aAtlas;
5039
- attribute float aBias;
5040
- varying vec2 vPaperUv;
5041
- varying float vAtlas;
5042
- ${TRANSLUCENCY_VARYINGS}
5043
- ${composed.functionsSrc}
5044
- ${composed.displaceSrc}
5045
- void main() {
5046
- float t = uPlTime + aPhase;
5047
- vec3 p = plDisplace(position, uv, t, aBias);
5048
- vec2 step = uSheet * 0.01;
5049
- vec3 px = plDisplace(position + vec3(step.x, 0.0, 0.0), uv + vec2(0.01, 0.0), t, aBias);
5050
- vec3 py = plDisplace(position + vec3(0.0, step.y, 0.0), uv + vec2(0.0, 0.01), t, aBias);
5051
- vec3 n = cross(px - p, py - p);
5052
- csm_Normal = length(n) > 1e-12 ? normalize(n) : vec3(0.0, 0.0, 1.0);
5053
- csm_Position = p;
5054
- ${translucencyVertexChunk({ model: "modelMatrix * instanceMatrix", position: "p", normal: "csm_Normal" })}
5055
- vPaperUv = uv;
5056
- vAtlas = aAtlas;
4818
+
4819
+ // src/field/sheetGrid.ts
4820
+ import { z as z23 } from "zod";
4821
+ var sheetLayoutSchema = z23.object({
4822
+ rows: z23.number().int().min(1).max(12).default(2),
4823
+ columns: z23.number().int().min(1).max(12).default(5),
4824
+ /** World-units gap between slots. Stamps are printed in register — no jitter. */
4825
+ gutter: z23.number().min(0).max(1).default(0.08),
4826
+ /** Slot footprint in world units (the paper preset should match). */
4827
+ cellWidth: z23.number().min(0.1).max(4).default(0.72),
4828
+ cellHeight: z23.number().min(0.1).max(4).default(0.86),
4829
+ /** Render the shared backing sheet behind the grid. */
4830
+ backing: z23.boolean().default(true),
4831
+ backingMargin: z23.number().min(0).max(1).default(0.12)
4832
+ });
4833
+ var SHEET_LIFT = 0.012;
4834
+ function withSheetCellFromPaper(parsed, rawOptions, paperDims) {
4835
+ if (!paperDims) return parsed;
4836
+ const hasW = rawOptions !== void 0 && rawOptions.cellWidth !== void 0;
4837
+ const hasH = rawOptions !== void 0 && rawOptions.cellHeight !== void 0;
4838
+ if (hasW && hasH) return parsed;
4839
+ return {
4840
+ ...parsed,
4841
+ cellWidth: hasW ? parsed.cellWidth : paperDims.width,
4842
+ cellHeight: hasH ? parsed.cellHeight : paperDims.height
4843
+ };
5057
4844
  }
5058
- `
5059
- );
4845
+ function sheetSlotXY(i, o) {
4846
+ const col = i % o.columns;
4847
+ const row = Math.floor(i / o.columns);
4848
+ return {
4849
+ x: (col - (o.columns - 1) / 2) * (o.cellWidth + o.gutter),
4850
+ y: ((o.rows - 1) / 2 - row) * (o.cellHeight + o.gutter)
4851
+ };
5060
4852
  }
5061
- function buildFieldFragmentShader() {
5062
- return (
5063
- /* glsl */
5064
- `
5065
- uniform sampler2D uAtlas;
5066
- uniform vec2 uAtlasGrid;
5067
- uniform float uBackDarken;
5068
- uniform vec3 uStockColor;
5069
- uniform float uShowThrough;
5070
- varying vec2 vPaperUv;
5071
- varying float vAtlas;
5072
- ${TRANSLUCENCY_FRAGMENT}
5073
- void main() {
5074
- float col = mod(vAtlas, uAtlasGrid.x);
5075
- float row = floor(vAtlas / uAtlasGrid.x);
5076
- vec2 tiled = (vPaperUv + vec2(col, uAtlasGrid.y - 1.0 - row)) / uAtlasGrid;
5077
- vec4 front = texture2D(uAtlas, tiled);
5078
- if (gl_FrontFacing) {
5079
- csm_DiffuseColor = front;
5080
- } else {
5081
- csm_DiffuseColor = vec4(uStockColor * mix(vec3(1.0), front.rgb, uShowThrough), 1.0);
5082
- csm_DiffuseColor.rgb *= uBackDarken;
5083
- }
5084
- // Light coming through the sheet, filtered by what is printed on it. Same
5085
- // ink either side \u2014 the light passes through the same fibres regardless of
5086
- // which face happens to be turned toward the camera.
5087
- csm_Emissive = plTransmission(front.rgb);
4853
+ function sheetBackingSize(o) {
4854
+ return {
4855
+ width: o.columns * o.cellWidth + (o.columns - 1) * o.gutter + o.backingMargin * 2,
4856
+ height: o.rows * o.cellHeight + (o.rows - 1) * o.gutter + o.backingMargin * 2
4857
+ };
5088
4858
  }
5089
- `
5090
- );
4859
+ function outwardCorner(i, o) {
4860
+ const col = i % o.columns;
4861
+ const row = Math.floor(i / o.columns);
4862
+ const horizontal = col + 0.5 < o.columns / 2 ? "left" : "right";
4863
+ const vertical = row + 0.5 < o.rows / 2 ? "top" : "bottom";
4864
+ return `${vertical}-${horizontal}`;
5091
4865
  }
5092
- function cap(s) {
5093
- return s.charAt(0).toUpperCase() + s.slice(1).replace(/-(\w)/g, (_, c) => c.toUpperCase());
4866
+ function tornEdgesOnDetach(i, o) {
4867
+ const col = i % o.columns;
4868
+ const row = Math.floor(i / o.columns);
4869
+ return {
4870
+ top: row > 0 ? "torn" : "intact",
4871
+ bottom: row < o.rows - 1 ? "torn" : "intact",
4872
+ left: col > 0 ? "torn" : "intact",
4873
+ right: col < o.columns - 1 ? "torn" : "intact"
4874
+ };
5094
4875
  }
5095
4876
 
5096
4877
  // src/stage/path.ts
@@ -5619,228 +5400,249 @@ registerLayout(rack);
5619
5400
  registerLayout(colonnade);
5620
5401
  registerLayout(sheet);
5621
5402
 
5622
- // src/content/backing.ts
5623
- function silhouetteRects(o, count) {
5624
- const { width, height } = sheetBackingSize(o);
5625
- const rects = [];
5626
- for (let i = 0; i < count; i++) {
5627
- const { x, y } = sheetSlotXY(i, o);
5628
- rects.push({
5629
- x: (x - o.cellWidth / 2 + width / 2) / width,
5630
- // World y up → canvas y down.
5631
- y: (height / 2 - y - o.cellHeight / 2) / height,
5632
- w: o.cellWidth / width,
5633
- h: o.cellHeight / height
5634
- });
5403
+ // src/PaperField.tsx
5404
+ import * as THREE15 from "three";
5405
+ import { gsap as gsap5 } from "gsap";
5406
+ import { Canvas, useFrame as useFrame5, useThree as useThree4 } from "@react-three/fiber";
5407
+ import { forwardRef as forwardRef2, useEffect as useEffect12, useMemo as useMemo9, useRef as useRef8 } from "react";
5408
+
5409
+ // src/field/slots.ts
5410
+ var EMPTY_SET = /* @__PURE__ */ new Set();
5411
+ function effectiveFieldPapers(papers, images) {
5412
+ if (papers) return papers;
5413
+ if (images) {
5414
+ return images.map((src) => ({
5415
+ content: { type: "image", src, fit: "cover" }
5416
+ }));
5635
5417
  }
5636
- return rects;
5637
- }
5638
- function lightenHex(hex, amount) {
5639
- const n = parseInt(hex.replace("#", ""), 16);
5640
- const ch = (shift) => {
5641
- const c = n >> shift & 255;
5642
- return Math.min(255, Math.round(c + (255 - c) * amount));
5643
- };
5644
- return `#${(ch(16) << 16 | ch(8) << 8 | ch(0)).toString(16).padStart(6, "0")}`;
5418
+ return Array.from({ length: 12 }, () => ({}));
5645
5419
  }
5646
- function drawBacking(canvas, spec) {
5647
- const ctx = canvas.getContext("2d");
5648
- if (!ctx) return;
5649
- const { width: cw, height: chh } = canvas;
5650
- ctx.fillStyle = spec.tint;
5651
- ctx.fillRect(0, 0, cw, chh);
5652
- const rects = silhouetteRects(spec.options, spec.count);
5653
- rects.forEach((r, i) => {
5654
- const removed = spec.removed.has(i);
5655
- const x = r.x * cw;
5656
- const y = r.y * chh;
5657
- const w = r.w * cw;
5658
- const h = r.h * chh;
5659
- const radius = Math.min(w, h) * 0.06;
5660
- ctx.fillStyle = lightenHex(spec.tint, removed ? 0.5 : 0.07);
5661
- roundedRect(ctx, x, y, w, h, radius);
5662
- ctx.fill();
5663
- if (removed) {
5664
- const sheen = ctx.createLinearGradient(x, y, x + w, y + h);
5665
- sheen.addColorStop(0, "rgba(255,255,255,0)");
5666
- sheen.addColorStop(0.5, "rgba(255,255,255,0.35)");
5667
- sheen.addColorStop(1, "rgba(255,255,255,0)");
5668
- ctx.fillStyle = sheen;
5669
- roundedRect(ctx, x, y, w, h, radius);
5670
- ctx.fill();
5420
+ function groupFieldPapers(papers, fallback) {
5421
+ const groups = /* @__PURE__ */ new Map();
5422
+ papers.forEach((slot, i) => {
5423
+ const config = resolveConfig({ preset: slot.preset ?? fallback });
5424
+ const key = JSON.stringify(config);
5425
+ let group = groups.get(key);
5426
+ if (!group) {
5427
+ group = { config, indices: [], contents: [] };
5428
+ groups.set(key, group);
5671
5429
  }
5430
+ group.indices.push(i);
5431
+ group.contents.push(slot.content ? contentSchema.parse(slot.content) : config.content);
5672
5432
  });
5433
+ return [...groups.values()];
5673
5434
  }
5674
- function roundedRect(ctx, x, y, w, h, r) {
5675
- ctx.beginPath();
5676
- ctx.moveTo(x + r, y);
5677
- ctx.arcTo(x + w, y, x + w, y + h, r);
5678
- ctx.arcTo(x + w, y + h, x, y + h, r);
5679
- ctx.arcTo(x, y + h, x, y, r);
5680
- ctx.arcTo(x, y, x + w, y, r);
5681
- ctx.closePath();
5435
+ function fieldIsInteractive(papers, fallback, explicit) {
5436
+ if (explicit !== void 0) return explicit;
5437
+ return papers.some((s) => s.states) || groupFieldPapers(papers, fallback).some((g) => Boolean(g.config.states));
5682
5438
  }
5683
-
5684
- // src/field/keyboardMirror.tsx
5685
- import { useState as useState5 } from "react";
5686
- import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
5687
- function fieldKeyboardStep(carry2, slot, key, controller) {
5688
- if (!carry2) {
5689
- if (key === "Enter" || key === " ") {
5690
- return { carry: controller.pick(slot) ? { slot, zoneIndex: 0 } : null, handled: true };
5691
- }
5692
- return { carry: carry2, handled: false };
5693
- }
5694
- if (carry2.slot !== slot) return { carry: carry2, handled: false };
5695
- const zoneCount = Math.max(controller.zoneIds().length, 1);
5696
- if (key === "ArrowRight" || key === "ArrowDown") {
5697
- return { carry: { ...carry2, zoneIndex: (carry2.zoneIndex + 1) % zoneCount }, handled: true };
5698
- }
5699
- if (key === "ArrowLeft" || key === "ArrowUp") {
5700
- return {
5701
- carry: { ...carry2, zoneIndex: (carry2.zoneIndex - 1 + zoneCount) % zoneCount },
5702
- handled: true
5703
- };
5704
- }
5705
- if (key === "Enter" || key === " ") {
5706
- const zone = controller.zoneIds()[carry2.zoneIndex];
5707
- if (zone) controller.placeAtZone(slot, zone);
5708
- return { carry: null, handled: true };
5439
+ function resolveFieldSlotConfig(slot, fallback, index, layoutId, layoutOptions) {
5440
+ let config = resolveConfig({ preset: slot.preset ?? fallback });
5441
+ const patch = {};
5442
+ if (slot.content) patch.content = slot.content;
5443
+ if (slot.states) patch.states = slot.states;
5444
+ if (layoutId === "sheet" && config.behavior?.type === "peel" && config.behavior.corner === "auto") {
5445
+ const o = sheetLayoutSchema.parse(layoutOptions);
5446
+ patch.behavior = { corner: outwardCorner(index, o) };
5709
5447
  }
5710
- if (key === "Escape") {
5711
- controller.cancel(slot);
5712
- return { carry: null, handled: true };
5448
+ if (Object.keys(patch).length > 0) {
5449
+ config = paperConfigSchema.parse(mergeConfig(config, patch));
5713
5450
  }
5714
- return { carry: carry2, handled: false };
5451
+ return config;
5715
5452
  }
5716
- var mirrorHidden = {
5717
- position: "absolute",
5718
- width: 1,
5719
- height: 1,
5720
- padding: 0,
5721
- margin: -1,
5722
- overflow: "hidden",
5723
- clip: "rect(0 0 0 0)",
5724
- whiteSpace: "nowrap",
5725
- border: 0
5726
- };
5727
- function FieldKeyboardMirror({
5728
- papers,
5729
- controller
5730
- }) {
5731
- const [carrying, setCarrying] = useState5(null);
5732
- const paperLabel = (slot, i) => {
5733
- try {
5734
- const config = resolveConfig({ preset: slot.preset });
5735
- const content = slot.content ? contentSchema.parse(slot.content) : config.content;
5736
- return `Paper ${i + 1}: ${contentText({ ...config, content })}`;
5737
- } catch {
5738
- return `Paper ${i + 1}`;
5453
+
5454
+ // src/field/fieldGroup.tsx
5455
+ import * as THREE12 from "three";
5456
+ import { gsap as gsap3 } from "gsap";
5457
+ import { useFrame as useFrame3 } from "@react-three/fiber";
5458
+ import { useEffect as useEffect9, useMemo as useMemo6, useRef as useRef6 } from "react";
5459
+ import CustomShaderMaterial2 from "three-custom-shader-material";
5460
+
5461
+ // src/content/atlas.ts
5462
+ import * as THREE11 from "three";
5463
+ import { useEffect as useEffect8, useState as useState4 } from "react";
5464
+ var MAX_ATLAS = 4096;
5465
+ function atlasGrid(count, aspect = 1) {
5466
+ const cols = Math.max(1, Math.min(count, Math.ceil(Math.sqrt(count * Math.max(aspect, 0.01)))));
5467
+ return { cols, rows: Math.max(1, Math.ceil(count / cols)) };
5468
+ }
5469
+ function useContentAtlas(contents, sheet2, stock) {
5470
+ const [atlas, setAtlas] = useState4(null);
5471
+ const stableContents = useStable(contents);
5472
+ useEffect8(() => {
5473
+ let disposed = false;
5474
+ const aspect = sheet2.height / sheet2.width;
5475
+ const { cols, rows } = atlasGrid(contents.length, aspect);
5476
+ let tileW = Math.min(1024, Math.floor(MAX_ATLAS / cols));
5477
+ let tileH = Math.round(tileW * aspect);
5478
+ if (tileH * rows > MAX_ATLAS) {
5479
+ tileH = Math.floor(MAX_ATLAS / rows);
5480
+ tileW = Math.max(1, Math.round(tileH / aspect));
5739
5481
  }
5740
- };
5741
- const onKeyDown = (i) => (e) => {
5742
- const ctl = controller.current;
5743
- if (!ctl) return;
5744
- const { carry: carry2, handled } = fieldKeyboardStep(carrying, i, e.key, ctl);
5745
- if (handled) e.preventDefault();
5746
- if (carry2 !== carrying) setCarrying(carry2);
5747
- };
5748
- return /* @__PURE__ */ jsxs4("fieldset", { style: mirrorHidden, children: [
5749
- /* @__PURE__ */ jsx7("legend", { children: "Interactive papers" }),
5750
- papers.map((slot, i) => /* @__PURE__ */ jsxs4(
5751
- "button",
5752
- {
5753
- type: "button",
5754
- onKeyDown: onKeyDown(i),
5755
- "aria-label": paperLabel(slot, i),
5756
- "aria-pressed": carrying?.slot === i,
5757
- children: [
5758
- paperLabel(slot, i),
5759
- carrying?.slot === i && /* @__PURE__ */ jsxs4("span", { "aria-live": "polite", children: [
5760
- " ",
5761
- "\u2014 carrying; zone ",
5762
- controller.current?.zoneIds()[carrying.zoneIndex] ?? "none",
5763
- "; Enter places, Escape returns"
5764
- ] })
5765
- ]
5766
- },
5767
- i
5768
- ))
5769
- ] });
5482
+ const canvas = document.createElement("canvas");
5483
+ canvas.width = tileW * cols;
5484
+ canvas.height = tileH * rows;
5485
+ const ctx = canvas.getContext("2d");
5486
+ ctx.fillStyle = stock.color;
5487
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
5488
+ const texture = new THREE11.CanvasTexture(canvas);
5489
+ texture.colorSpace = THREE11.SRGBColorSpace;
5490
+ texture.anisotropy = 4;
5491
+ setAtlas({ texture, cols, rows });
5492
+ const drawTile = (index, tile) => {
5493
+ if (disposed) return;
5494
+ const x = index % cols * tileW;
5495
+ const y = Math.floor(index / cols) * tileH;
5496
+ ctx.drawImage(tile, x, y, tileW, tileH);
5497
+ texture.needsUpdate = true;
5498
+ };
5499
+ contents.forEach((content, index) => {
5500
+ if (content.type === "image") {
5501
+ const img = new Image();
5502
+ img.crossOrigin = "anonymous";
5503
+ img.onload = () => drawTile(index, renderContentToCanvas(content, sheet2, stock, img));
5504
+ img.src = content.src;
5505
+ } else if (content.type === "text" || content.type === "receipt") {
5506
+ document.fonts.ready.then(() => drawTile(index, renderContentToCanvas(content, sheet2, stock)));
5507
+ } else {
5508
+ drawTile(index, renderContentToCanvas(content, sheet2, stock));
5509
+ }
5510
+ });
5511
+ return () => {
5512
+ disposed = true;
5513
+ texture.dispose();
5514
+ };
5515
+ }, [stableContents, sheet2.width, sheet2.height, stock.id]);
5516
+ return atlas;
5770
5517
  }
5771
5518
 
5772
- // src/field/framing.ts
5773
- var PHASE_SAMPLES = 8;
5774
- function resolveLayoutOptions(layoutId, layout, propOptions, firstSheet) {
5775
- const parsed = layout.optionsSchema.parse({
5776
- ...layout.defaults,
5777
- ...propOptions
5778
- });
5779
- if (layoutId !== "sheet") return parsed;
5780
- return withSheetCellFromPaper(
5781
- parsed,
5782
- propOptions,
5783
- firstSheet
5784
- );
5519
+ // src/field/compose.ts
5520
+ function glslType(value) {
5521
+ if (typeof value === "number") return "float";
5522
+ return ["float", "vec2", "vec3", "vec4"][value.length - 1];
5785
5523
  }
5786
- function fieldBounds(layout, n, options, sheet2) {
5787
- const reach = Math.hypot(sheet2.width, sheet2.height) / 2;
5788
- if (n <= 0) return { center: [0, 0, 0], half: [reach, reach, 0] };
5789
- const min = [Infinity, Infinity, Infinity];
5790
- const max = [-Infinity, -Infinity, -Infinity];
5791
- for (let s = 0; s < PHASE_SAMPLES; s++) {
5792
- for (let i = 0; i < n; i++) {
5793
- const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
5794
- const r = reach * Math.max(pose.scale, 0);
5795
- for (let axis = 0; axis < 3; axis++) {
5796
- min[axis] = Math.min(min[axis], pose.position[axis] - r);
5797
- max[axis] = Math.max(max[axis], pose.position[axis] + r);
5798
- }
5799
- }
5800
- }
5801
- return {
5802
- center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
5803
- half: [(max[0] - min[0]) / 2, (max[1] - min[1]) / 2, (max[2] - min[2]) / 2]
5804
- };
5524
+ function stackUniformValues(stack, sheet2) {
5525
+ const uniforms = { uSheet: [sheet2.width, sheet2.height] };
5526
+ stack.forEach((instance, i) => {
5527
+ if (instance.enabled === false) return;
5528
+ const deformer = getDeformer(instance.type);
5529
+ if (!deformer.glsl) return;
5530
+ const ns = `u${cap(instance.type)}${i}_`;
5531
+ const values = deformer.glsl.uniforms(instance.options);
5532
+ for (const [key, value] of Object.entries(values)) uniforms[ns + key] = value;
5533
+ });
5534
+ return uniforms;
5805
5535
  }
5806
- var LIFT = 0.11;
5807
- var DEG8 = Math.PI / 180;
5808
- function fitCamera(layout, n, options, sheet2, fovDeg, aspect, margin = 1.06) {
5809
- const { center } = fieldBounds(layout, n, options, sheet2);
5810
- const reach = Math.hypot(sheet2.width, sheet2.height) / 2 * margin;
5811
- const vTan = Math.tan(fovDeg * DEG8 / 2);
5812
- const hTan = vTan * Math.max(aspect, 0.01);
5813
- let distance = 0.1;
5814
- for (let s = 0; s < PHASE_SAMPLES; s++) {
5815
- for (let i = 0; i < Math.max(n, 0); i++) {
5816
- const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
5817
- const r = reach * Math.max(pose.scale, 0);
5818
- const depth = pose.position[2] - center[2];
5819
- distance = Math.max(
5820
- distance,
5821
- (Math.abs(pose.position[0] - center[0]) + r) / hTan + depth,
5822
- (Math.abs(pose.position[1] - center[1]) + r) / vTan + depth
5536
+ function buildDisplacementGLSL(stack, sheet2) {
5537
+ const decls = ["uniform vec2 uSheet;", "float plBias = 1.0;"];
5538
+ const functions = [];
5539
+ const calls = [];
5540
+ const uniforms = { uSheet: [sheet2.width, sheet2.height] };
5541
+ stack.forEach((instance, i) => {
5542
+ if (instance.enabled === false) return;
5543
+ const deformer = getDeformer(instance.type);
5544
+ if (!deformer.glsl) {
5545
+ throw new Error(
5546
+ `[paperlab] Deformer "${instance.type}" has no GLSL implementation \u2014 it can't run in field mode.`
5823
5547
  );
5824
5548
  }
5549
+ const ns = `u${cap(instance.type)}${i}_`;
5550
+ const fn = `pl_${instance.type}${i}`;
5551
+ const values = deformer.glsl.uniforms(instance.options);
5552
+ for (const [key, value] of Object.entries(values)) {
5553
+ decls.push(`uniform ${glslType(value)} ${ns}${key};`);
5554
+ uniforms[ns + key] = value;
5555
+ }
5556
+ const strength = deformer.glsl.strength;
5557
+ functions.push(
5558
+ deformer.glsl.chunk.replaceAll("FN", fn).replace(
5559
+ /U_(\w+)/g,
5560
+ (_, name) => (
5561
+ // The strength uniform reads through the per-instance bias, so one
5562
+ // instanced draw call can bend every sheet by a different amount.
5563
+ name === strength ? `(${ns}${name} * plBias)` : ns + name
5564
+ )
5565
+ )
5566
+ );
5567
+ calls.push(`${fn}(q, uv, t);`);
5568
+ });
5569
+ const displaceSrc = (
5570
+ /* glsl */
5571
+ `
5572
+ vec3 plDisplace(vec3 p, vec2 uv, float t, float bias) {
5573
+ plBias = bias;
5574
+ vec3 q = p;
5575
+ ${calls.join("\n ")}
5576
+ return q;
5577
+ }
5578
+ `
5579
+ );
5580
+ return { functionsSrc: `${decls.join("\n")}
5581
+ ${functions.join("\n")}`, displaceSrc, uniforms };
5582
+ }
5583
+ function buildFieldVertexShader(composed) {
5584
+ return (
5585
+ /* glsl */
5586
+ `
5587
+ uniform float uPlTime;
5588
+ attribute float aPhase;
5589
+ attribute float aAtlas;
5590
+ attribute float aBias;
5591
+ varying vec2 vPaperUv;
5592
+ varying float vAtlas;
5593
+ ${TRANSLUCENCY_VARYINGS}
5594
+ ${composed.functionsSrc}
5595
+ ${composed.displaceSrc}
5596
+ void main() {
5597
+ float t = uPlTime + aPhase;
5598
+ vec3 p = plDisplace(position, uv, t, aBias);
5599
+ vec2 step = uSheet * 0.01;
5600
+ vec3 px = plDisplace(position + vec3(step.x, 0.0, 0.0), uv + vec2(0.01, 0.0), t, aBias);
5601
+ vec3 py = plDisplace(position + vec3(0.0, step.y, 0.0), uv + vec2(0.0, 0.01), t, aBias);
5602
+ vec3 n = cross(px - p, py - p);
5603
+ csm_Normal = length(n) > 1e-12 ? normalize(n) : vec3(0.0, 0.0, 1.0);
5604
+ csm_Position = p;
5605
+ ${translucencyVertexChunk({ model: "modelMatrix * instanceMatrix", position: "p", normal: "csm_Normal" })}
5606
+ vPaperUv = uv;
5607
+ vAtlas = aAtlas;
5608
+ }
5609
+ `
5610
+ );
5611
+ }
5612
+ function buildFieldFragmentShader() {
5613
+ return (
5614
+ /* glsl */
5615
+ `
5616
+ uniform sampler2D uAtlas;
5617
+ uniform vec2 uAtlasGrid;
5618
+ uniform float uBackDarken;
5619
+ uniform vec3 uStockColor;
5620
+ uniform float uShowThrough;
5621
+ varying vec2 vPaperUv;
5622
+ varying float vAtlas;
5623
+ ${TRANSLUCENCY_FRAGMENT}
5624
+ void main() {
5625
+ float col = mod(vAtlas, uAtlasGrid.x);
5626
+ float row = floor(vAtlas / uAtlasGrid.x);
5627
+ vec2 tiled = (vPaperUv + vec2(col, uAtlasGrid.y - 1.0 - row)) / uAtlasGrid;
5628
+ vec4 front = texture2D(uAtlas, tiled);
5629
+ if (gl_FrontFacing) {
5630
+ csm_DiffuseColor = front;
5631
+ } else {
5632
+ csm_DiffuseColor = vec4(uStockColor * mix(vec3(1.0), front.rgb, uShowThrough), 1.0);
5633
+ csm_DiffuseColor.rgb *= uBackDarken;
5825
5634
  }
5826
- return {
5827
- position: [center[0], center[1] + distance * LIFT, center[2] + distance],
5828
- target: center
5829
- };
5635
+ // Light coming through the sheet, filtered by what is printed on it. Same
5636
+ // ink either side \u2014 the light passes through the same fibres regardless of
5637
+ // which face happens to be turned toward the camera.
5638
+ csm_Emissive = plTransmission(front.rgb);
5639
+ }
5640
+ `
5641
+ );
5642
+ }
5643
+ function cap(s) {
5644
+ return s.charAt(0).toUpperCase() + s.slice(1).replace(/-(\w)/g, (_, c) => c.toUpperCase());
5830
5645
  }
5831
-
5832
- // src/PaperField.tsx
5833
- import * as THREE15 from "three";
5834
- import { gsap as gsap5 } from "gsap";
5835
- import { Canvas, useFrame as useFrame5, useThree as useThree4 } from "@react-three/fiber";
5836
- import { forwardRef as forwardRef2, useEffect as useEffect12, useMemo as useMemo9, useRef as useRef8 } from "react";
5837
-
5838
- // src/field/fieldGroup.tsx
5839
- import * as THREE12 from "three";
5840
- import { gsap as gsap3 } from "gsap";
5841
- import { useFrame as useFrame3 } from "@react-three/fiber";
5842
- import { useEffect as useEffect9, useMemo as useMemo6, useRef as useRef6 } from "react";
5843
- import CustomShaderMaterial2 from "three-custom-shader-material";
5844
5646
 
5845
5647
  // src/field/stack.ts
5846
5648
  function fieldShapeStack(config, progress) {
@@ -5852,7 +5654,7 @@ function fieldShapeStack(config, progress) {
5852
5654
  }
5853
5655
 
5854
5656
  // src/field/fieldGroup.tsx
5855
- import { jsx as jsx8 } from "react/jsx-runtime";
5657
+ import { jsx as jsx7 } from "react/jsx-runtime";
5856
5658
  var scratchObj = new THREE12.Object3D();
5857
5659
  var scratchAero = { position: [0, 0, 0], rotation: [0, 0, 0] };
5858
5660
  var FIELD_SEGMENT_CAP = 48;
@@ -6055,7 +5857,7 @@ function FieldGroup({
6055
5857
  });
6056
5858
  return (
6057
5859
  // biome-ignore lint/a11y/noStaticElementInteractions: An R3F <instancedMesh> is a three.js object, not a DOM node — it has no role to give and no keyboard to receive. The keyboard route into the same action is on the canvas, which `useWalk` makes focusable and drives with the arrow keys.
6058
- /* @__PURE__ */ jsx8(
5860
+ /* @__PURE__ */ jsx7(
6059
5861
  "instancedMesh",
6060
5862
  {
6061
5863
  ref: meshRef,
@@ -6071,7 +5873,7 @@ function FieldGroup({
6071
5873
  event.stopPropagation();
6072
5874
  onSelect(paper);
6073
5875
  }),
6074
- children: /* @__PURE__ */ jsx8(
5876
+ children: /* @__PURE__ */ jsx7(
6075
5877
  CustomShaderMaterial2,
6076
5878
  {
6077
5879
  baseMaterial: THREE12.MeshStandardMaterial,
@@ -6128,7 +5930,71 @@ var easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
6128
5930
  // src/field/backingSheet.tsx
6129
5931
  import * as THREE13 from "three";
6130
5932
  import { useEffect as useEffect10, useMemo as useMemo7 } from "react";
6131
- import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
5933
+
5934
+ // src/content/backing.ts
5935
+ function silhouetteRects(o, count) {
5936
+ const { width, height } = sheetBackingSize(o);
5937
+ const rects = [];
5938
+ for (let i = 0; i < count; i++) {
5939
+ const { x, y } = sheetSlotXY(i, o);
5940
+ rects.push({
5941
+ x: (x - o.cellWidth / 2 + width / 2) / width,
5942
+ // World y up → canvas y down.
5943
+ y: (height / 2 - y - o.cellHeight / 2) / height,
5944
+ w: o.cellWidth / width,
5945
+ h: o.cellHeight / height
5946
+ });
5947
+ }
5948
+ return rects;
5949
+ }
5950
+ function lightenHex(hex, amount) {
5951
+ const n = parseInt(hex.replace("#", ""), 16);
5952
+ const ch = (shift) => {
5953
+ const c = n >> shift & 255;
5954
+ return Math.min(255, Math.round(c + (255 - c) * amount));
5955
+ };
5956
+ return `#${(ch(16) << 16 | ch(8) << 8 | ch(0)).toString(16).padStart(6, "0")}`;
5957
+ }
5958
+ function drawBacking(canvas, spec) {
5959
+ const ctx = canvas.getContext("2d");
5960
+ if (!ctx) return;
5961
+ const { width: cw, height: chh } = canvas;
5962
+ ctx.fillStyle = spec.tint;
5963
+ ctx.fillRect(0, 0, cw, chh);
5964
+ const rects = silhouetteRects(spec.options, spec.count);
5965
+ rects.forEach((r, i) => {
5966
+ const removed = spec.removed.has(i);
5967
+ const x = r.x * cw;
5968
+ const y = r.y * chh;
5969
+ const w = r.w * cw;
5970
+ const h = r.h * chh;
5971
+ const radius = Math.min(w, h) * 0.06;
5972
+ ctx.fillStyle = lightenHex(spec.tint, removed ? 0.5 : 0.07);
5973
+ roundedRect(ctx, x, y, w, h, radius);
5974
+ ctx.fill();
5975
+ if (removed) {
5976
+ const sheen = ctx.createLinearGradient(x, y, x + w, y + h);
5977
+ sheen.addColorStop(0, "rgba(255,255,255,0)");
5978
+ sheen.addColorStop(0.5, "rgba(255,255,255,0.35)");
5979
+ sheen.addColorStop(1, "rgba(255,255,255,0)");
5980
+ ctx.fillStyle = sheen;
5981
+ roundedRect(ctx, x, y, w, h, radius);
5982
+ ctx.fill();
5983
+ }
5984
+ });
5985
+ }
5986
+ function roundedRect(ctx, x, y, w, h, r) {
5987
+ ctx.beginPath();
5988
+ ctx.moveTo(x + r, y);
5989
+ ctx.arcTo(x + w, y, x + w, y + h, r);
5990
+ ctx.arcTo(x + w, y + h, x, y + h, r);
5991
+ ctx.arcTo(x, y + h, x, y, r);
5992
+ ctx.arcTo(x, y, x + w, y, r);
5993
+ ctx.closePath();
5994
+ }
5995
+
5996
+ // src/field/backingSheet.tsx
5997
+ import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
6132
5998
  var BACKING_TINT = "#f3ecdd";
6133
5999
  function BackingSheet({
6134
6000
  options,
@@ -6152,9 +6018,9 @@ function BackingSheet({
6152
6018
  texture.needsUpdate = true;
6153
6019
  }, [canvas, texture, JSON.stringify(options), count, removedKey]);
6154
6020
  useEffect10(() => () => texture?.dispose(), [texture]);
6155
- return /* @__PURE__ */ jsxs5("mesh", { receiveShadow: true, children: [
6156
- /* @__PURE__ */ jsx9("planeGeometry", { args: [width, height] }),
6157
- /* @__PURE__ */ jsx9("meshStandardMaterial", { map: texture, color: "#ffffff", roughness: 0.92, metalness: 0 })
6021
+ return /* @__PURE__ */ jsxs4("mesh", { receiveShadow: true, children: [
6022
+ /* @__PURE__ */ jsx8("planeGeometry", { args: [width, height] }),
6023
+ /* @__PURE__ */ jsx8("meshStandardMaterial", { map: texture, color: "#ffffff", roughness: 0.92, metalness: 0 })
6158
6024
  ] });
6159
6025
  }
6160
6026
 
@@ -6162,8 +6028,8 @@ function BackingSheet({
6162
6028
  import * as THREE14 from "three";
6163
6029
  import { gsap as gsap4 } from "gsap";
6164
6030
  import { useFrame as useFrame4, useThree as useThree3 } from "@react-three/fiber";
6165
- import { useContext as useContext3, useEffect as useEffect11, useMemo as useMemo8, useRef as useRef7, useState as useState6 } from "react";
6166
- import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
6031
+ import { useContext as useContext3, useEffect as useEffect11, useMemo as useMemo8, useRef as useRef7, useState as useState5 } from "react";
6032
+ import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
6167
6033
  var PICK_BEHAVIORS = /* @__PURE__ */ new Set(["peel", "carry"]);
6168
6034
  function InteractiveField(props) {
6169
6035
  const { papers, fallback, layoutId, layoutOptions, sheetOptions } = props;
@@ -6175,9 +6041,9 @@ function InteractiveField(props) {
6175
6041
  const camera = useThree3((s) => s.camera);
6176
6042
  const gl = useThree3((s) => s.gl);
6177
6043
  const controls = useThree3((s) => s.controls);
6178
- const [removed, setRemoved] = useState6(EMPTY_SET);
6179
- const [slotPatches, setSlotPatches] = useState6({});
6180
- const [slotStates, setSlotStates] = useState6({});
6044
+ const [removed, setRemoved] = useState5(EMPTY_SET);
6045
+ const [slotPatches, setSlotPatches] = useState5({});
6046
+ const [slotStates, setSlotStates] = useState5({});
6181
6047
  const slotConfigs = useMemo8(
6182
6048
  () => papers.map((slot, i) => {
6183
6049
  const config = resolveFieldSlotConfig(slot, fallback, i, layoutId, layoutOptions);
@@ -6470,12 +6336,12 @@ function InteractiveField(props) {
6470
6336
  ref.current = null;
6471
6337
  };
6472
6338
  }, [props.a11yRef]);
6473
- return /* @__PURE__ */ jsxs6("group", { children: [
6474
- sheetOptions?.backing && /* @__PURE__ */ jsx10(BackingSheet, { options: sheetOptions, count: total, removed }),
6475
- (props.zones ?? []).map((zone) => /* @__PURE__ */ jsx10(DropZoneVisual, { registry: registry4, config: zone }, zone.id)),
6339
+ return /* @__PURE__ */ jsxs5("group", { children: [
6340
+ sheetOptions?.backing && /* @__PURE__ */ jsx9(BackingSheet, { options: sheetOptions, count: total, removed }),
6341
+ (props.zones ?? []).map((zone) => /* @__PURE__ */ jsx9(DropZoneVisual, { registry: registry4, config: zone }, zone.id)),
6476
6342
  slotConfigs.map((config, i) => {
6477
6343
  const pose = poses[i];
6478
- return /* @__PURE__ */ jsx10(
6344
+ return /* @__PURE__ */ jsx9(
6479
6345
  "group",
6480
6346
  {
6481
6347
  ref: (g) => {
@@ -6490,7 +6356,7 @@ function InteractiveField(props) {
6490
6356
  if (!hit) return;
6491
6357
  pressRef.current = { slot: i, pointerId: e.pointerId, startX: hit[0], startY: hit[1] };
6492
6358
  },
6493
- children: /* @__PURE__ */ jsx10(
6359
+ children: /* @__PURE__ */ jsx9(
6494
6360
  PaperMesh,
6495
6361
  {
6496
6362
  ref: (h) => {
@@ -6508,6 +6374,154 @@ function InteractiveField(props) {
6508
6374
  ] });
6509
6375
  }
6510
6376
 
6377
+ // src/field/keyboardMirror.tsx
6378
+ import { useState as useState6 } from "react";
6379
+ import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
6380
+ function fieldKeyboardStep(carry2, slot, key, controller) {
6381
+ if (!carry2) {
6382
+ if (key === "Enter" || key === " ") {
6383
+ return { carry: controller.pick(slot) ? { slot, zoneIndex: 0 } : null, handled: true };
6384
+ }
6385
+ return { carry: carry2, handled: false };
6386
+ }
6387
+ if (carry2.slot !== slot) return { carry: carry2, handled: false };
6388
+ const zoneCount = Math.max(controller.zoneIds().length, 1);
6389
+ if (key === "ArrowRight" || key === "ArrowDown") {
6390
+ return { carry: { ...carry2, zoneIndex: (carry2.zoneIndex + 1) % zoneCount }, handled: true };
6391
+ }
6392
+ if (key === "ArrowLeft" || key === "ArrowUp") {
6393
+ return {
6394
+ carry: { ...carry2, zoneIndex: (carry2.zoneIndex - 1 + zoneCount) % zoneCount },
6395
+ handled: true
6396
+ };
6397
+ }
6398
+ if (key === "Enter" || key === " ") {
6399
+ const zone = controller.zoneIds()[carry2.zoneIndex];
6400
+ if (zone) controller.placeAtZone(slot, zone);
6401
+ return { carry: null, handled: true };
6402
+ }
6403
+ if (key === "Escape") {
6404
+ controller.cancel(slot);
6405
+ return { carry: null, handled: true };
6406
+ }
6407
+ return { carry: carry2, handled: false };
6408
+ }
6409
+ var mirrorHidden = {
6410
+ position: "absolute",
6411
+ width: 1,
6412
+ height: 1,
6413
+ padding: 0,
6414
+ margin: -1,
6415
+ overflow: "hidden",
6416
+ clip: "rect(0 0 0 0)",
6417
+ whiteSpace: "nowrap",
6418
+ border: 0
6419
+ };
6420
+ function FieldKeyboardMirror({
6421
+ papers,
6422
+ controller
6423
+ }) {
6424
+ const [carrying, setCarrying] = useState6(null);
6425
+ const paperLabel = (slot, i) => {
6426
+ try {
6427
+ const config = resolveConfig({ preset: slot.preset });
6428
+ const content = slot.content ? contentSchema.parse(slot.content) : config.content;
6429
+ return `Paper ${i + 1}: ${contentText({ ...config, content })}`;
6430
+ } catch {
6431
+ return `Paper ${i + 1}`;
6432
+ }
6433
+ };
6434
+ const onKeyDown = (i) => (e) => {
6435
+ const ctl = controller.current;
6436
+ if (!ctl) return;
6437
+ const { carry: carry2, handled } = fieldKeyboardStep(carrying, i, e.key, ctl);
6438
+ if (handled) e.preventDefault();
6439
+ if (carry2 !== carrying) setCarrying(carry2);
6440
+ };
6441
+ return /* @__PURE__ */ jsxs6("fieldset", { style: mirrorHidden, children: [
6442
+ /* @__PURE__ */ jsx10("legend", { children: "Interactive papers" }),
6443
+ papers.map((slot, i) => /* @__PURE__ */ jsxs6(
6444
+ "button",
6445
+ {
6446
+ type: "button",
6447
+ onKeyDown: onKeyDown(i),
6448
+ "aria-label": paperLabel(slot, i),
6449
+ "aria-pressed": carrying?.slot === i,
6450
+ children: [
6451
+ paperLabel(slot, i),
6452
+ carrying?.slot === i && /* @__PURE__ */ jsxs6("span", { "aria-live": "polite", children: [
6453
+ " ",
6454
+ "\u2014 carrying; zone ",
6455
+ controller.current?.zoneIds()[carrying.zoneIndex] ?? "none",
6456
+ "; Enter places, Escape returns"
6457
+ ] })
6458
+ ]
6459
+ },
6460
+ i
6461
+ ))
6462
+ ] });
6463
+ }
6464
+
6465
+ // src/field/framing.ts
6466
+ var PHASE_SAMPLES = 8;
6467
+ function resolveLayoutOptions(layoutId, layout, propOptions, firstSheet) {
6468
+ const parsed = layout.optionsSchema.parse({
6469
+ ...layout.defaults,
6470
+ ...propOptions
6471
+ });
6472
+ if (layoutId !== "sheet") return parsed;
6473
+ return withSheetCellFromPaper(
6474
+ parsed,
6475
+ propOptions,
6476
+ firstSheet
6477
+ );
6478
+ }
6479
+ function fieldBounds(layout, n, options, sheet2) {
6480
+ const reach = Math.hypot(sheet2.width, sheet2.height) / 2;
6481
+ if (n <= 0) return { center: [0, 0, 0], half: [reach, reach, 0] };
6482
+ const min = [Infinity, Infinity, Infinity];
6483
+ const max = [-Infinity, -Infinity, -Infinity];
6484
+ for (let s = 0; s < PHASE_SAMPLES; s++) {
6485
+ for (let i = 0; i < n; i++) {
6486
+ const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
6487
+ const r = reach * Math.max(pose.scale, 0);
6488
+ for (let axis = 0; axis < 3; axis++) {
6489
+ min[axis] = Math.min(min[axis], pose.position[axis] - r);
6490
+ max[axis] = Math.max(max[axis], pose.position[axis] + r);
6491
+ }
6492
+ }
6493
+ }
6494
+ return {
6495
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
6496
+ half: [(max[0] - min[0]) / 2, (max[1] - min[1]) / 2, (max[2] - min[2]) / 2]
6497
+ };
6498
+ }
6499
+ var LIFT = 0.11;
6500
+ var DEG8 = Math.PI / 180;
6501
+ function fitCamera(layout, n, options, sheet2, fovDeg, aspect, margin = 1.06) {
6502
+ const { center } = fieldBounds(layout, n, options, sheet2);
6503
+ const reach = Math.hypot(sheet2.width, sheet2.height) / 2 * margin;
6504
+ const vTan = Math.tan(fovDeg * DEG8 / 2);
6505
+ const hTan = vTan * Math.max(aspect, 0.01);
6506
+ let distance = 0.1;
6507
+ for (let s = 0; s < PHASE_SAMPLES; s++) {
6508
+ for (let i = 0; i < Math.max(n, 0); i++) {
6509
+ const pose = layout.pose(i, n, options, s / PHASE_SAMPLES, sheet2);
6510
+ const r = reach * Math.max(pose.scale, 0);
6511
+ const depth = pose.position[2] - center[2];
6512
+ distance = Math.max(
6513
+ distance,
6514
+ (Math.abs(pose.position[0] - center[0]) + r) / hTan + depth,
6515
+ (Math.abs(pose.position[1] - center[1]) + r) / vTan + depth
6516
+ );
6517
+ }
6518
+ }
6519
+ return {
6520
+ position: [center[0], center[1] + distance * LIFT, center[2] + distance],
6521
+ target: center
6522
+ };
6523
+ }
6524
+
6511
6525
  // src/PaperField.tsx
6512
6526
  import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
6513
6527
  var PaperFieldMesh = forwardRef2(
@@ -6854,63 +6868,18 @@ export {
6854
6868
  spanAlong,
6855
6869
  segmentsForArc,
6856
6870
  segmentsForSine,
6857
- cornerNames,
6858
- curlOptionsSchema,
6859
- curl,
6860
- peelOptionsSchema,
6861
- peel,
6862
- unrollOptionsSchema,
6863
- unroll,
6864
- flipOptionsSchema,
6865
- flip,
6866
- letterFoldOptionsSchema,
6867
- letterFold,
6868
- hangOptionsSchema,
6869
- hang,
6870
- flyOptionsSchema,
6871
- fly,
6872
- fallOptionsSchema,
6873
- fall,
6874
- carryOptionsSchema,
6875
- carry,
6876
- dampTo,
6877
- gust,
6878
- flightPose,
6879
- carryDrive,
6880
- flightOptionsSchema,
6881
- flight,
6882
- crumpleBehaviorOptionsSchema,
6883
- crumpleBehavior,
6884
- settleOptionsSchema,
6885
- settle,
6886
- ribbonOptionsSchema,
6887
- ribbon,
6888
- sheetSchema,
6889
6871
  stockNames,
6890
- stockSchema,
6891
- backContentSchema,
6892
- cardContentSchema,
6893
- receiptContentSchema,
6894
- contentSchema,
6895
6872
  paperEdges,
6896
- surfaceSchema,
6897
6873
  behaviorConfigSchema,
6898
- deformerInstanceSchema,
6899
6874
  physicsNames,
6900
6875
  clothConfigSchema,
6901
- physicsSchema,
6902
6876
  lightingNames,
6903
- filmNames,
6904
- sceneSchema,
6905
6877
  coreStateNames,
6906
- stateTransitionSchema,
6907
6878
  stateDefSchema,
6908
6879
  paperStatesSchema,
6909
6880
  paperConfigSchema,
6910
6881
  parsePreset,
6911
6882
  serializePreset,
6912
- resolveSegments,
6913
- createSheetGeometry,
6914
6883
  stocks,
6915
6884
  getStock,
6916
6885
  getPreset,
@@ -6919,111 +6888,40 @@ export {
6919
6888
  isBuiltinPreset,
6920
6889
  listPresets,
6921
6890
  uniquePresetName,
6922
- receiptTotals,
6923
- barcodeBars,
6924
6891
  wrapLines,
6925
- rollOptionsSchema,
6926
- roll,
6927
- bendOptionsSchema,
6928
- bend,
6929
- foldOptionsSchema,
6930
- fold,
6931
- waveOptionsSchema,
6932
- wave,
6933
- drapeOptionsSchema,
6934
- drape,
6935
- crumpleOptionsSchema,
6936
- crumple,
6937
6892
  registerDeformer,
6938
6893
  getDeformer,
6939
6894
  listDeformers,
6940
- resolveDeformerStack,
6941
- applyDeformerStack,
6942
6895
  displacePoint,
6943
- stackMinSegments,
6944
- stackAutoSegments,
6945
6896
  registerBehavior,
6946
6897
  getBehavior,
6947
6898
  listBehaviors,
6948
6899
  idleNames,
6949
- idlePresets,
6950
- getIdlePreset,
6951
- ClothSim,
6952
- lightingPresets,
6953
- getLightingPreset,
6954
6900
  lightSchema,
6955
6901
  lightAngles,
6956
- lightPosition,
6957
6902
  resolveLighting,
6958
- TRANSMISSION_GAIN,
6959
- translucencyValues,
6960
- translucencyUniforms,
6961
- composeSurface,
6962
6903
  LightRig,
6963
- PaperMaterial,
6964
6904
  usePrefersReducedMotion,
6965
6905
  supportsWebGL,
6966
- contentText,
6967
6906
  PaperMirror,
6968
6907
  PaperFallback,
6969
- ON_TWOS_FPS,
6970
- quantizeTime,
6971
- quantizeProgress,
6972
- stateEventTransitions,
6973
- stripStates,
6974
6908
  resolveStateConfig,
6975
6909
  recordStateOverride,
6976
- flattenNumeric,
6977
- PaperStateMachine,
6978
6910
  usePaperStates,
6979
6911
  useResolvedConfig,
6980
6912
  resolveConfig,
6981
6913
  PaperMesh,
6982
6914
  cssColorOr,
6983
- makeGoboTexture,
6984
6915
  PaperLighting,
6985
- DropZoneRegistry,
6986
- zoneAccepts,
6987
6916
  DropZone,
6988
6917
  sheetLayoutSchema,
6989
- SHEET_LIFT,
6990
- withSheetCellFromPaper,
6991
- sheetSlotXY,
6992
- sheetBackingSize,
6993
- outwardCorner,
6994
- tornEdgesOnDetach,
6995
- groupFieldPapers,
6996
- resolveFieldSlotConfig,
6997
- atlasGrid,
6998
- useContentAtlas,
6999
- stackUniformValues,
7000
6918
  buildDisplacementGLSL,
7001
- buildFieldVertexShader,
7002
- buildFieldFragmentShader,
7003
6919
  walkPathSchema,
7004
6920
  createWalkPath,
7005
6921
  getWalkPath,
7006
- ring,
7007
- fan,
7008
- spread,
7009
- pile,
7010
- wall,
7011
- spill,
7012
- sweep,
7013
- book,
7014
- accordion,
7015
- rack,
7016
- colonnade,
7017
- sheet,
7018
6922
  registerLayout,
7019
6923
  getLayout,
7020
6924
  listLayouts,
7021
- silhouetteRects,
7022
- lightenHex,
7023
- drawBacking,
7024
- fieldKeyboardStep,
7025
- fieldBounds,
7026
- fitCamera,
7027
6925
  PaperFieldMesh,
7028
6926
  PaperField,
7029
6927
  diffConfig,
@@ -7032,4 +6930,4 @@ export {
7032
6930
  describeConfig,
7033
6931
  buildAgentPayload
7034
6932
  };
7035
- //# sourceMappingURL=chunk-XE4E43MD.js.map
6933
+ //# sourceMappingURL=chunk-4ZU5DZEF.js.map