paperlab 0.7.0 → 0.8.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.
Files changed (35) hide show
  1. package/README.md +7 -5
  2. package/dist/{FxPostPass-M66QMVVC.js → FxPostPass-V6YDK4H2.js} +23 -5
  3. package/dist/FxPostPass-V6YDK4H2.js.map +1 -0
  4. package/dist/chunk-232SU3AQ.js +34 -0
  5. package/dist/chunk-232SU3AQ.js.map +1 -0
  6. package/dist/{chunk-GB6BMHC3.js → chunk-7WOGU2D4.js} +7 -19
  7. package/dist/{chunk-GB6BMHC3.js.map → chunk-7WOGU2D4.js.map} +1 -1
  8. package/dist/{chunk-MZCAN3AR.js → chunk-SVWWC5UL.js} +576 -160
  9. package/dist/chunk-SVWWC5UL.js.map +1 -0
  10. package/dist/{damageContract-DPF1YFLZ.d.cts → damageContract-nF1fgK7P.d.cts} +31 -3
  11. package/dist/{damageContract-DPF1YFLZ.d.ts → damageContract-nF1fgK7P.d.ts} +31 -3
  12. package/dist/fx.cjs +320 -91
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +81 -55
  15. package/dist/fx.d.ts +81 -55
  16. package/dist/fx.js +278 -48
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +606 -181
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +13 -5
  21. package/dist/index.d.ts +13 -5
  22. package/dist/index.js +17 -4
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-C5PqY0Q5.d.cts → slots-D_EE8GXn.d.cts} +12 -0
  25. package/dist/{slots-C5PqY0Q5.d.ts → slots-D_EE8GXn.d.ts} +12 -0
  26. package/dist/stage.cjs +600 -188
  27. package/dist/stage.cjs.map +1 -1
  28. package/dist/stage.d.cts +1 -1
  29. package/dist/stage.d.ts +1 -1
  30. package/dist/stage.js +2 -2
  31. package/package.json +1 -1
  32. package/dist/FxPostPass-M66QMVVC.js.map +0 -1
  33. package/dist/chunk-7TLIWPGM.js +0 -36
  34. package/dist/chunk-7TLIWPGM.js.map +0 -1
  35. package/dist/chunk-MZCAN3AR.js.map +0 -1
package/dist/stage.cjs CHANGED
@@ -1766,8 +1766,23 @@ var backdropSchema = import_zod15.z.object({
1766
1766
  /** Out of focus, for the same reason. */
1767
1767
  blur: import_zod15.z.number().min(0).max(1).default(0.2)
1768
1768
  });
1769
+ var floorSchema = import_zod15.z.object({
1770
+ enabled: import_zod15.z.boolean().default(false),
1771
+ /** Dark and matte: it is the ground, not the subject. */
1772
+ color: import_zod15.z.string().default("#1a1917").describe("color"),
1773
+ /**
1774
+ * Where it lies, in the sheet's own space. The contact shadow falls here
1775
+ * too, and a simulated sheet stops here — one floor, one height, so paper
1776
+ * cannot land through the thing its shadow is on.
1777
+ */
1778
+ y: import_zod15.z.number().min(-4).max(0).default(-1.05),
1779
+ /** How rough it is, 0 mirror to 1 chalk. Slate, by default. */
1780
+ roughness: import_zod15.z.number().min(0).max(1).default(0.92)
1781
+ });
1769
1782
  var sceneSchema = import_zod15.z.object({
1770
1783
  lighting: import_zod15.z.enum(lightingNames).default("studio"),
1784
+ /** The ground under the sheet — see `floorSchema`. Off unless asked for. */
1785
+ floor: floorSchema.prefault({}),
1771
1786
  /** What is behind the sheet. Unset leaves the canvas alone. */
1772
1787
  backdrop: backdropSchema.optional(),
1773
1788
  /**
@@ -2195,6 +2210,34 @@ function buildEnvironment(renderer, preset) {
2195
2210
  };
2196
2211
  }
2197
2212
 
2213
+ // src/surface/damageContract.ts
2214
+ function roomLight(source) {
2215
+ const room = source?.firelight?.room;
2216
+ if (room === void 0 || !(room >= 0)) return 1;
2217
+ return Math.min(1, room);
2218
+ }
2219
+ var DAMAGE_LOOK_DEFAULTS = {
2220
+ emberWidth: 1.25,
2221
+ emberIntensity: 1.05,
2222
+ emberCoverage: 0.6,
2223
+ emberFlicker: 1.65,
2224
+ emberGlow: 1.25,
2225
+ sparkle: 0.5,
2226
+ // As wide as the char band (Noor, 2026-09-13): the lip has to be seen. At
2227
+ // 0.95 mm it was a hairline tracing the edge.
2228
+ lipWidth: 3.5,
2229
+ charWidth: 3.5,
2230
+ lipBrightness: 0.76,
2231
+ charWarmth: 0.32,
2232
+ charCracks: 0.45,
2233
+ scorchReach: 9,
2234
+ scorchDarkness: 0.86,
2235
+ fingers: 1.95,
2236
+ edgeWave: 13,
2237
+ edgeBite: 6
2238
+ };
2239
+ var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
2240
+
2198
2241
  // src/scene/PaperLighting.tsx
2199
2242
  var import_jsx_runtime2 = require("react/jsx-runtime");
2200
2243
  var toneMappings = {
@@ -2293,7 +2336,8 @@ function PaperLighting({
2293
2336
  reducedMotion,
2294
2337
  shadowMapSize,
2295
2338
  contactShadow = true,
2296
- environment = true
2339
+ environment = true,
2340
+ damage
2297
2341
  }) {
2298
2342
  const p2 = (0, import_react2.useMemo)(() => rig ?? resolveLighting(preset, light), [rig, preset, light]);
2299
2343
  const mapSize = shadowMapSize ?? p2.shadow.mapSize;
@@ -2327,6 +2371,20 @@ function PaperLighting({
2327
2371
  driftRef.current += delta * p2.gobo.drift;
2328
2372
  goboMap.offset.set(driftRef.current, driftRef.current * 0.6);
2329
2373
  });
2374
+ const keyIntensity = p2.gobo && goboMap ? p2.key.intensity * 3.2 : p2.key.intensity;
2375
+ const keyRef = (0, import_react2.useRef)(null);
2376
+ const ambientRef = (0, import_react2.useRef)(null);
2377
+ const hemisphereRef = (0, import_react2.useRef)(null);
2378
+ const roomRef = (0, import_react2.useRef)(1);
2379
+ (0, import_fiber.useFrame)(() => {
2380
+ const room = roomLight(damage);
2381
+ if (room === 1 && roomRef.current === 1) return;
2382
+ roomRef.current = room;
2383
+ if (keyRef.current) keyRef.current.intensity = keyIntensity * room;
2384
+ if (ambientRef.current) ambientRef.current.intensity = p2.ambient * room;
2385
+ if (hemisphereRef.current) hemisphereRef.current.intensity = p2.studio * HEMISPHERE_STAND_IN * room;
2386
+ if (p2.studio > 0 && environment) scene.environmentIntensity = p2.studio * room;
2387
+ });
2330
2388
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
2331
2389
  p2.studio > 0 && (environment ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StudioLight, { rig: p2 }) : (
2332
2390
  // The studio light degrades rather than disappearing. A hemisphere
@@ -2337,19 +2395,21 @@ function PaperLighting({
2337
2395
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2338
2396
  "hemisphereLight",
2339
2397
  {
2398
+ ref: hemisphereRef,
2340
2399
  color: p2.sky.horizon,
2341
2400
  groundColor: p2.sky.ground,
2342
2401
  intensity: p2.studio * HEMISPHERE_STAND_IN
2343
2402
  }
2344
2403
  )
2345
2404
  )),
2346
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ambientLight", { intensity: p2.ambient }),
2405
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ambientLight", { ref: ambientRef, intensity: p2.ambient }),
2347
2406
  p2.gobo && goboMap ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2348
2407
  "spotLight",
2349
2408
  {
2409
+ ref: keyRef,
2350
2410
  position: p2.key.position,
2351
2411
  color: p2.key.color,
2352
- intensity: p2.key.intensity * 3.2,
2412
+ intensity: keyIntensity,
2353
2413
  angle: p2.gobo.angle,
2354
2414
  penumbra: 0.5,
2355
2415
  decay: 0,
@@ -2362,9 +2422,10 @@ function PaperLighting({
2362
2422
  ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2363
2423
  "directionalLight",
2364
2424
  {
2425
+ ref: keyRef,
2365
2426
  position: p2.key.position,
2366
2427
  color: p2.key.color,
2367
- intensity: p2.key.intensity,
2428
+ intensity: keyIntensity,
2368
2429
  castShadow,
2369
2430
  "shadow-mapSize": [mapSize || 1, mapSize || 1],
2370
2431
  "shadow-radius": p2.shadow.radius,
@@ -4360,6 +4421,8 @@ var ClothSim = class {
4360
4421
  height;
4361
4422
  positions;
4362
4423
  prev;
4424
+ /** Where the constructor laid every particle out — what {@link restore} puts back. */
4425
+ laidOut;
4363
4426
  pinned;
4364
4427
  pinTargets;
4365
4428
  /**
@@ -4435,6 +4498,16 @@ var ClothSim = class {
4435
4498
  * cannot do this job: it moves paper, and a uniformly curled sheet in still
4436
4499
  * air has no business going anywhere.
4437
4500
  */
4501
+ /**
4502
+ * An acceleration on each particle from whatever is in the air around the
4503
+ * sheet, xyz — the fire's updraft, so far (`DamageCoupling`). Not the wind:
4504
+ * the wind is one thing blowing on the whole sheet, and this is local, a
4505
+ * hot column over a burning rim and nothing a hand's width away.
4506
+ *
4507
+ * An acceleration rather than a force, so it lifts a soaked sheet no
4508
+ * differently from a dry one: hot air does not know what paper weighs.
4509
+ */
4510
+ draught;
4438
4511
  restBend;
4439
4512
  /** For each bend spring, the particle between its ends; -1 for every other kind. */
4440
4513
  constraintMiddle;
@@ -4493,6 +4566,7 @@ var ClothSim = class {
4493
4566
  }
4494
4567
  }
4495
4568
  this.prev.set(this.positions);
4569
+ this.laidOut = this.positions.slice();
4496
4570
  const idx = (r2, c2) => r2 * cols + c2;
4497
4571
  const links = [];
4498
4572
  const link = (a, b2, kind) => {
@@ -4520,6 +4594,7 @@ var ClothSim = class {
4520
4594
  this.constraintStiffness = new Float64Array(m).fill(1);
4521
4595
  this.broken = new Uint8Array(m);
4522
4596
  this.restBend = new Float64Array(m);
4597
+ this.draught = new Float64Array(this.count * 3);
4523
4598
  this.constraintMiddle = new Int32Array(m).fill(-1);
4524
4599
  for (let k2 = 0; k2 < m; k2++) {
4525
4600
  const a = links[k2 * 3];
@@ -4610,6 +4685,22 @@ var ClothSim = class {
4610
4685
  this.asleep = false;
4611
4686
  this.stillFrames = 0;
4612
4687
  }
4688
+ /**
4689
+ * Back to the sheet as it was laid out: flat, still, every particle where
4690
+ * the constructor put it. Pins, a grab and every lever are left alone.
4691
+ *
4692
+ * For a history that has been rewound — a burn played back from an earlier
4693
+ * moment, say, which hands back paper that had burnt away and paper that
4694
+ * had FALLEN, lying on the floor with its springs to the sheet suddenly
4695
+ * whole again. Solved from there, the solve hauls it up through the sheet
4696
+ * in a tangle. Laid out afresh, it is simply the sheet it is again.
4697
+ */
4698
+ restore() {
4699
+ this.positions.set(this.laidOut);
4700
+ this.prev.set(this.laidOut);
4701
+ this.accumulator = 0;
4702
+ this.wake();
4703
+ }
4613
4704
  /**
4614
4705
  * Take hold of the sheet at one particle.
4615
4706
  *
@@ -4842,9 +4933,9 @@ var ClothSim = class {
4842
4933
  this.prev[i3] = x2;
4843
4934
  this.prev[i3 + 1] = y2;
4844
4935
  this.prev[i3 + 2] = z31;
4845
- p2[i3] = x2 + vx + ax * im * dt2;
4846
- p2[i3 + 1] = y2 + vy + (ay * im - gravity * 3.2) * dt2;
4847
- p2[i3 + 2] = z31 + vz + az * im * dt2;
4936
+ p2[i3] = x2 + vx + (ax * im + this.draught[i3]) * dt2;
4937
+ p2[i3 + 1] = y2 + vy + (ay * im - gravity * 3.2 + this.draught[i3 + 1]) * dt2;
4938
+ p2[i3 + 2] = z31 + vz + (az * im + this.draught[i3 + 2]) * dt2;
4848
4939
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
4849
4940
  }
4850
4941
  for (let k2 = 0; k2 < this.grabbed.length; k2++) {
@@ -4942,44 +5033,17 @@ var ClothSim = class {
4942
5033
  }
4943
5034
  };
4944
5035
 
4945
- // src/surface/damageContract.ts
4946
- var DAMAGE_LOOK_DEFAULTS = {
4947
- // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
4948
- // it was tuned in — a control at its limit is a report that something
4949
- // underneath is wrong, which in this case was a fire nothing could see.
4950
- emberWidth: 0.9,
4951
- emberIntensity: 1.45,
4952
- emberCoverage: 0.6,
4953
- emberFlicker: 1.65,
4954
- emberGlow: 1.25,
4955
- sparkle: 0.5,
4956
- lipWidth: 1.2,
4957
- // Was 1.5, its slider's ceiling, which made the ash lip brighter than the
4958
- // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
4959
- // the sheet, not a highlight drawn on it.
4960
- lipBrightness: 0.85,
4961
- // Was 1, also a ceiling. At full warmth the char is milk chocolate —
4962
- // closer to cardboard than to charcoal (§5). Burnt paper keeps a little
4963
- // warmth in the plates and reads near black in a frame with a fire in it.
4964
- charWarmth: 0.3,
4965
- // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
4966
- // at full strength the char reads as a mosaic rather than as broken plates.
4967
- charCracks: 0.55,
4968
- scorchReach: 30,
4969
- scorchDarkness: 1.17,
4970
- fingers: 1.25,
4971
- edgeWave: 8.5,
4972
- edgeBite: 3.6
4973
- };
4974
- var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
4975
-
4976
5036
  // src/physics/damage.ts
4977
- var { char: CHAR, saturation: SATURATION, presence: PRESENCE } = DAMAGE_CHANNELS;
5037
+ var { char: CHAR, saturation: SATURATION, heat: HEAT, presence: PRESENCE } = DAMAGE_CHANNELS;
4978
5038
  var GONE_BELOW = 128;
4979
5039
  var CHAR_SHRINK = 0.12;
5040
+ var ACROSS_SHRINK = 0.55;
5041
+ var UPDRAFT = 1.1;
5042
+ var UPDRAFT_OUT = 0.35;
4980
5043
  var CHAR_CURL = 6;
4981
5044
  var FRONT_GRADIENT = 16 * 16;
4982
5045
  var WET_MASS = 2;
5046
+ var TEAR_STRETCH = 1.5;
4983
5047
  var DamageCoupling = class {
4984
5048
  sim;
4985
5049
  source = null;
@@ -4989,12 +5053,31 @@ var DamageCoupling = class {
4989
5053
  particleTexel = new Int32Array(0);
4990
5054
  /** Gone, per particle, as of the last read. */
4991
5055
  gone;
5056
+ /** Whether any particle has ever been gone since the last reset — nothing can tear before. */
5057
+ holed = false;
4992
5058
  /** The gone particles with live paper beside them. See {@link follow}. */
4993
5059
  followers = [];
4994
5060
  /**
4995
- * Per bend spring, how squarely it lies ACROSS the burn front, 0..1 — and
5061
+ * Which piece of paper each particle belongs to, named by the lowest index
5062
+ * in it — or -1 in the middle of a hole.
5063
+ *
5064
+ * A piece is whatever the sim's unbroken springs still hold together, which
5065
+ * is exactly what moves as one: two pieces are two things the solve lets
5066
+ * part. A gone particle on a cut takes the piece it borders MOST — a cut a
5067
+ * single particle wide borders two, and a particle averaged between them
5068
+ * would be dragged across the gap as one piece fell, stretching both edges
5069
+ * into it.
5070
+ */
5071
+ piece;
5072
+ /** Union-find over the particles, reused on every read. */
5073
+ parent;
5074
+ /**
5075
+ * Per spring, how squarely it lies ACROSS the burn front, 0..1 — and
4996
5076
  * remembered once it has been.
4997
5077
  *
5078
+ * It decides two things: how far the spring shrinks ({@link ACROSS_SHRINK})
5079
+ * and, for a bend spring, which way the paper rolls ({@link CHAR_CURL}).
5080
+ *
4998
5081
  * A burnt edge rolls about an axis along the front, like a scroll; it does
4999
5082
  * not dish into a bowl. Measured with the curl in every bend spring alike: a
5000
5083
  * burnt band settled into a saddle, its corners forward and its middle BACK
@@ -5007,11 +5090,22 @@ var DamageCoupling = class {
5007
5090
  * gone by, the char behind it is uniform and says nothing about direction,
5008
5091
  * but the paper stays rolled the way it rolled.
5009
5092
  */
5010
- curlWeight;
5093
+ acrossFront;
5094
+ /** The mesh's index as it was built — what {@link tear} hides triangles from and mends them back to. */
5095
+ original = null;
5096
+ /** Per triangle of that index: torn, and hidden until the history is rewound. */
5097
+ torn = new Uint8Array(0);
5098
+ /** The triangles with a gone corner — the only ones that can tear. */
5099
+ rim = [];
5100
+ rimStale = true;
5101
+ /** Every torn triangle has to be put back: the history was rewound, or the source went. */
5102
+ mend = false;
5011
5103
  constructor(sim) {
5012
5104
  this.sim = sim;
5013
5105
  this.gone = new Uint8Array(sim.count);
5014
- this.curlWeight = new Float32Array(sim.constraintCount);
5106
+ this.piece = new Int32Array(sim.count);
5107
+ this.parent = new Int32Array(sim.count);
5108
+ this.acrossFront = new Float32Array(sim.constraintCount);
5015
5109
  }
5016
5110
  /**
5017
5111
  * Bring the sim's levers up to date with `source`. Call before stepping.
@@ -5022,7 +5116,13 @@ var DamageCoupling = class {
5022
5116
  update(source) {
5023
5117
  const next = source ?? null;
5024
5118
  if (next === this.source && (next === null || next.version === this.version)) return false;
5025
- if (next !== this.source) this.curlWeight.fill(0);
5119
+ if (next !== this.source) {
5120
+ this.acrossFront.fill(0);
5121
+ if (next && this.holed) {
5122
+ this.rewind();
5123
+ this.gone.fill(0);
5124
+ }
5125
+ }
5026
5126
  this.source = next;
5027
5127
  if (!next) {
5028
5128
  this.reset();
@@ -5042,12 +5142,13 @@ var DamageCoupling = class {
5042
5142
  * laid out flat around them — their positions plus its rest offset from
5043
5143
  * each, averaged. The triangles that straddle the burnt edge then keep
5044
5144
  * roughly their shape instead of stretching back to wherever the particle
5045
- * was when it burnt. Only a band of a single particle's width, with live
5046
- * paper pulling it both ways, is left averaging between them.
5145
+ * was when it burnt. Only the neighbours in its own {@link piece} count, so
5146
+ * on a cut between two pieces it leaves with one of them.
5047
5147
  */
5048
5148
  follow() {
5049
5149
  if (this.followers.length === 0) return;
5050
5150
  const { gone, sim } = this;
5151
+ const piece = this.piece;
5051
5152
  const p2 = sim.positions;
5052
5153
  const { cols, rows } = sim;
5053
5154
  const cellX = cols > 1 ? sim.width / (cols - 1) : 0;
@@ -5055,6 +5156,7 @@ var DamageCoupling = class {
5055
5156
  for (const g of this.followers) {
5056
5157
  const r2 = g / cols | 0;
5057
5158
  const c2 = g % cols;
5159
+ const own = piece[g];
5058
5160
  let sx = 0;
5059
5161
  let sy = 0;
5060
5162
  let sz = 0;
@@ -5066,7 +5168,7 @@ var DamageCoupling = class {
5066
5168
  const nc = c2 + dc;
5067
5169
  if (dr === 0 && dc === 0 || nc < 0 || nc >= cols) continue;
5068
5170
  const j3 = nr * cols + nc;
5069
- if (gone[j3]) continue;
5171
+ if (gone[j3] || piece[j3] !== own) continue;
5070
5172
  const j32 = j3 * 3;
5071
5173
  sx += p2[j32] - dc * cellX;
5072
5174
  sy += p2[j32 + 1] + dr * cellY;
@@ -5081,6 +5183,70 @@ var DamageCoupling = class {
5081
5183
  p2[g3 + 2] = sz / n;
5082
5184
  }
5083
5185
  }
5186
+ /**
5187
+ * Hide the triangles a piece has torn away from. Call after {@link follow},
5188
+ * with the sheet's index buffer: it is rewritten in place, and the return
5189
+ * says whether it changed, so the caller knows to upload it.
5190
+ *
5191
+ * A triangle tears when one of its edges has stretched past
5192
+ * {@link TEAR_STRETCH} — which, among triangles with a burnt-away corner,
5193
+ * only ever happens across a gap that is opening. It is then collapsed to a
5194
+ * point, which draws nothing, casts no shadow and adds nothing to its
5195
+ * corners' normals, and it stays hidden: torn paper does not mend because
5196
+ * the two sides swing close again. Only a rewound history puts it back.
5197
+ *
5198
+ * A sheet in one piece, held, never stretches one that far — so a burn that
5199
+ * cuts nothing loose leaves this index exactly as it was built.
5200
+ */
5201
+ tear(index) {
5202
+ if (!this.holed && !this.mend) return false;
5203
+ if (!this.original || this.original.length !== index.length) {
5204
+ this.original = index.slice();
5205
+ this.torn = new Uint8Array(index.length / 3);
5206
+ this.rimStale = true;
5207
+ }
5208
+ const { original, torn, gone, sim } = this;
5209
+ let changed = false;
5210
+ if (this.mend) {
5211
+ this.mend = false;
5212
+ index.set(original);
5213
+ changed = true;
5214
+ }
5215
+ if (this.rimStale) {
5216
+ this.rimStale = false;
5217
+ const rim = [];
5218
+ for (let t = 0; t < torn.length; t++) {
5219
+ if (gone[original[t * 3]] || gone[original[t * 3 + 1]] || gone[original[t * 3 + 2]]) rim.push(t);
5220
+ }
5221
+ this.rim = rim;
5222
+ }
5223
+ const p2 = sim.positions;
5224
+ const { cols, rows } = sim;
5225
+ const cellX = cols > 1 ? sim.width / (cols - 1) : 0;
5226
+ const cellY = rows > 1 ? sim.height / (rows - 1) : 0;
5227
+ const limit = TEAR_STRETCH * TEAR_STRETCH;
5228
+ const stretched = (i, j3) => {
5229
+ const dc = i % cols - j3 % cols;
5230
+ const dr = (i / cols | 0) - (j3 / cols | 0);
5231
+ const rest = (dc * cellX) ** 2 + (dr * cellY) ** 2;
5232
+ const i3 = i * 3;
5233
+ const j32 = j3 * 3;
5234
+ const now = (p2[i3] - p2[j32]) ** 2 + (p2[i3 + 1] - p2[j32 + 1]) ** 2 + (p2[i3 + 2] - p2[j32 + 2]) ** 2;
5235
+ return now > rest * limit;
5236
+ };
5237
+ for (const t of this.rim) {
5238
+ if (torn[t]) continue;
5239
+ const a = original[t * 3];
5240
+ const b2 = original[t * 3 + 1];
5241
+ const c2 = original[t * 3 + 2];
5242
+ if (!stretched(a, b2) && !stretched(b2, c2) && !stretched(c2, a)) continue;
5243
+ torn[t] = 1;
5244
+ index[t * 3 + 1] = a;
5245
+ index[t * 3 + 2] = a;
5246
+ changed = true;
5247
+ }
5248
+ return changed;
5249
+ }
5084
5250
  /** Map each particle to its texel. The damage grid's row 0 is v = 0, the sheet's BOTTOM. */
5085
5251
  layout(size) {
5086
5252
  const { cols, rows, count } = this.sim;
@@ -5096,8 +5262,10 @@ var DamageCoupling = class {
5096
5262
  }
5097
5263
  }
5098
5264
  read(pixels) {
5099
- const { sim, gone, particleTexel, curlWeight, size } = this;
5265
+ const { sim, gone, particleTexel, acrossFront, size } = this;
5100
5266
  const {
5267
+ draught,
5268
+ cols,
5101
5269
  invMass,
5102
5270
  restLength,
5103
5271
  naturalLength,
@@ -5108,12 +5276,25 @@ var DamageCoupling = class {
5108
5276
  constraintKind,
5109
5277
  constraintMiddle
5110
5278
  } = sim;
5279
+ let rewound = false;
5280
+ let holed = false;
5111
5281
  for (let i = 0; i < sim.count; i++) {
5112
5282
  const t = particleTexel[i] * 4;
5113
5283
  const isGone = pixels[t + PRESENCE] < GONE_BELOW;
5284
+ if (gone[i] && !isGone) rewound = true;
5285
+ if (isGone) holed = true;
5114
5286
  gone[i] = isGone ? 1 : 0;
5115
5287
  invMass[i] = isGone ? 0 : 1 / (1 + WET_MASS * pixels[t + SATURATION] / 255);
5288
+ const heat = isGone ? 0 : pixels[t + HEAT] / 255;
5289
+ const lift = heat > 0.15 ? (heat - 0.15) / 0.85 : 0;
5290
+ const i3 = i * 3;
5291
+ draught[i3] = 0;
5292
+ draught[i3 + 1] = UPDRAFT * lift;
5293
+ draught[i3 + 2] = UPDRAFT * UPDRAFT_OUT * lift;
5116
5294
  }
5295
+ if (rewound) this.rewind();
5296
+ this.holed = holed;
5297
+ this.rimStale = true;
5117
5298
  for (let k2 = 0; k2 < sim.constraintCount; k2++) {
5118
5299
  const a = constraintA[k2];
5119
5300
  const b2 = constraintB[k2];
@@ -5130,54 +5311,125 @@ var DamageCoupling = class {
5130
5311
  samples = 3;
5131
5312
  }
5132
5313
  const char = charSum / (samples * 255);
5133
- restLength[k2] = naturalLength[k2] * (1 - CHAR_SHRINK * char);
5314
+ const t = particleTexel[m >= 0 ? m : a];
5315
+ const x2 = t % size;
5316
+ const y2 = t / size | 0;
5317
+ const gx = pixels[(y2 * size + Math.min(size - 1, x2 + 2)) * 4 + CHAR] - pixels[(y2 * size + Math.max(0, x2 - 2)) * 4 + CHAR];
5318
+ const gy = pixels[(Math.min(size - 1, y2 + 2) * size + x2) * 4 + CHAR] - pixels[(Math.max(0, y2 - 2) * size + x2) * 4 + CHAR];
5319
+ const steep = gx * gx + gy * gy;
5320
+ if (steep > FRONT_GRADIENT) {
5321
+ const dc = b2 % cols - a % cols;
5322
+ const dr = (b2 / cols | 0) - (a / cols | 0);
5323
+ const along = dc * gx - dr * gy;
5324
+ const span = dc * dc + dr * dr || 1;
5325
+ const across2 = along * along / (span * steep);
5326
+ if (across2 > acrossFront[k2]) acrossFront[k2] = across2;
5327
+ }
5328
+ const across = acrossFront[k2];
5329
+ restLength[k2] = naturalLength[k2] * (1 - CHAR_SHRINK * char * (1 - (1 - ACROSS_SHRINK) * across));
5134
5330
  if (constraintKind[k2] === 2) {
5135
- const t = particleTexel[m];
5136
- const x2 = t % size;
5137
- const y2 = t / size | 0;
5138
- const gx = pixels[(y2 * size + Math.min(size - 1, x2 + 2)) * 4 + CHAR] - pixels[(y2 * size + Math.max(0, x2 - 2)) * 4 + CHAR];
5139
- const gy = pixels[(Math.min(size - 1, y2 + 2) * size + x2) * 4 + CHAR] - pixels[(Math.max(0, y2 - 2) * size + x2) * 4 + CHAR];
5140
- const steep = gx * gx + gy * gy;
5141
- if (steep > FRONT_GRADIENT) {
5142
- const across = (b2 - a === 2 ? gx * gx : gy * gy) / steep;
5143
- if (across > curlWeight[k2]) curlWeight[k2] = across;
5144
- }
5145
5331
  const span = naturalLength[k2];
5146
- restBend[k2] = CHAR_CURL * char * curlWeight[k2] * span * span / 8;
5332
+ restBend[k2] = CHAR_CURL * char * across * span * span / 8;
5333
+ }
5334
+ }
5335
+ this.findPieces();
5336
+ }
5337
+ /**
5338
+ * Which piece each particle is in ({@link piece}), and which gone ones
5339
+ * follow a piece at all ({@link followers}).
5340
+ *
5341
+ * Pieces by union-find over the springs that still hold, rooted at the
5342
+ * lowest index so the same burn names the same pieces every time. Then each
5343
+ * gone particle with live paper beside it joins the piece it has the most
5344
+ * neighbours in, the lower-named on a tie.
5345
+ */
5346
+ findPieces() {
5347
+ const { sim, gone } = this;
5348
+ const parent = this.parent;
5349
+ const piece = this.piece;
5350
+ const { broken, constraintA, constraintB } = sim;
5351
+ for (let i = 0; i < sim.count; i++) parent[i] = i;
5352
+ const find = (i) => {
5353
+ let r2 = i;
5354
+ while (parent[r2] !== r2) r2 = parent[r2];
5355
+ while (parent[i] !== r2) {
5356
+ const up = parent[i];
5357
+ parent[i] = r2;
5358
+ i = up;
5147
5359
  }
5360
+ return r2;
5361
+ };
5362
+ for (let k2 = 0; k2 < sim.constraintCount; k2++) {
5363
+ if (broken[k2]) continue;
5364
+ const ra = find(constraintA[k2]);
5365
+ const rb = find(constraintB[k2]);
5366
+ if (ra < rb) parent[rb] = ra;
5367
+ else if (rb < ra) parent[ra] = rb;
5148
5368
  }
5369
+ for (let i = 0; i < sim.count; i++) piece[i] = gone[i] ? -1 : find(i);
5149
5370
  const followers = [];
5150
5371
  const { cols, rows } = sim;
5372
+ const seen = [];
5373
+ const votes = [];
5151
5374
  for (let i = 0; i < sim.count; i++) {
5152
5375
  if (!gone[i]) continue;
5153
5376
  const r2 = i / cols | 0;
5154
5377
  const c2 = i % cols;
5155
- let beside = false;
5156
- for (let dr = -1; dr <= 1 && !beside; dr++) {
5378
+ seen.length = 0;
5379
+ votes.length = 0;
5380
+ for (let dr = -1; dr <= 1; dr++) {
5157
5381
  const nr = r2 + dr;
5158
5382
  if (nr < 0 || nr >= rows) continue;
5159
5383
  for (let dc = -1; dc <= 1; dc++) {
5160
5384
  const nc = c2 + dc;
5161
5385
  if (nc < 0 || nc >= cols) continue;
5162
- if (!gone[nr * cols + nc]) {
5163
- beside = true;
5164
- break;
5386
+ const j3 = nr * cols + nc;
5387
+ if (gone[j3]) continue;
5388
+ const at = seen.indexOf(piece[j3]);
5389
+ if (at < 0) {
5390
+ seen.push(piece[j3]);
5391
+ votes.push(1);
5392
+ } else {
5393
+ votes[at]++;
5165
5394
  }
5166
5395
  }
5167
5396
  }
5168
- if (beside) followers.push(i);
5397
+ if (seen.length === 0) continue;
5398
+ let best = 0;
5399
+ for (let k2 = 1; k2 < seen.length; k2++) {
5400
+ if (votes[k2] > votes[best] || votes[k2] === votes[best] && seen[k2] < seen[best]) best = k2;
5401
+ }
5402
+ piece[i] = seen[best];
5403
+ followers.push(i);
5169
5404
  }
5170
5405
  this.followers = followers;
5171
5406
  }
5407
+ /**
5408
+ * A rewound history: the sheet starts over.
5409
+ *
5410
+ * Paper that had fallen away is paper again, with every spring back to the
5411
+ * sheet it was cut from — solved from where it lies, the solve would haul it
5412
+ * up off the floor through the sheet in a tangle. And the curl it rolled
5413
+ * into belonged to a front that has not passed yet.
5414
+ */
5415
+ rewind() {
5416
+ this.sim.restore();
5417
+ this.acrossFront.fill(0);
5418
+ this.torn.fill(0);
5419
+ this.mend = true;
5420
+ }
5172
5421
  reset() {
5173
5422
  const { sim } = this;
5423
+ if (this.holed) this.rewind();
5424
+ sim.draught.fill(0);
5174
5425
  sim.invMass.fill(1);
5175
5426
  sim.restBend.fill(0);
5176
5427
  sim.broken.fill(0);
5177
5428
  sim.restLength.set(sim.naturalLength);
5178
5429
  this.gone.fill(0);
5179
- this.curlWeight.fill(0);
5430
+ this.acrossFront.fill(0);
5180
5431
  this.followers = [];
5432
+ this.holed = false;
5181
5433
  this.version = -1;
5182
5434
  sim.wake();
5183
5435
  }
@@ -6545,6 +6797,82 @@ function p({
6545
6797
  }
6546
6798
  var j2 = r.forwardRef(p);
6547
6799
 
6800
+ // src/surface/cutDistance.ts
6801
+ var CUT_REACH_MM = 16;
6802
+ var SPECK_TEXELS = 3;
6803
+ var GONE_BELOW2 = 128;
6804
+ var scratch = null;
6805
+ function cutDistance(pixels, size, texelMm, out) {
6806
+ const n = size * size;
6807
+ if (!scratch || scratch.size !== size) {
6808
+ scratch = { size, dist: new Float32Array(n), kept: new Uint8Array(n), stack: new Int32Array(n) };
6809
+ }
6810
+ const { dist, kept, stack } = scratch;
6811
+ const gone = (i) => pixels[i * 4 + 3] < GONE_BELOW2;
6812
+ kept.fill(0);
6813
+ const seen = dist;
6814
+ seen.fill(0);
6815
+ for (let start = 0; start < n; start++) {
6816
+ if (seen[start] || !gone(start)) continue;
6817
+ let top = 0;
6818
+ let count = 0;
6819
+ stack[top++] = start;
6820
+ seen[start] = 1;
6821
+ const first = top;
6822
+ let head = 0;
6823
+ while (head < top) {
6824
+ const i = stack[head++];
6825
+ count++;
6826
+ const x2 = i % size;
6827
+ const y2 = i / size | 0;
6828
+ const next = [
6829
+ x2 > 0 ? i - 1 : -1,
6830
+ x2 < size - 1 ? i + 1 : -1,
6831
+ y2 > 0 ? i - size : -1,
6832
+ y2 < size - 1 ? i + size : -1
6833
+ ];
6834
+ for (const j3 of next) {
6835
+ if (j3 < 0 || seen[j3] || !gone(j3)) continue;
6836
+ seen[j3] = 1;
6837
+ stack[top++] = j3;
6838
+ }
6839
+ }
6840
+ if (count > SPECK_TEXELS) for (let k2 = first - 1; k2 < top; k2++) kept[stack[k2]] = 1;
6841
+ }
6842
+ const [dx, dy] = texelMm;
6843
+ const dd = Math.hypot(dx, dy);
6844
+ for (let i = 0; i < n; i++) dist[i] = kept[i] ? 0 : Number.POSITIVE_INFINITY;
6845
+ for (let y2 = 0; y2 < size; y2++) {
6846
+ for (let x2 = 0; x2 < size; x2++) {
6847
+ const i = y2 * size + x2;
6848
+ let v2 = dist[i];
6849
+ if (v2 === 0) continue;
6850
+ if (x2 > 0) v2 = Math.min(v2, dist[i - 1] + dx);
6851
+ if (y2 > 0) {
6852
+ v2 = Math.min(v2, dist[i - size] + dy);
6853
+ if (x2 > 0) v2 = Math.min(v2, dist[i - size - 1] + dd);
6854
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i - size + 1] + dd);
6855
+ }
6856
+ dist[i] = v2;
6857
+ }
6858
+ }
6859
+ for (let y2 = size - 1; y2 >= 0; y2--) {
6860
+ for (let x2 = size - 1; x2 >= 0; x2--) {
6861
+ const i = y2 * size + x2;
6862
+ let v2 = dist[i];
6863
+ if (v2 === 0) continue;
6864
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i + 1] + dx);
6865
+ if (y2 < size - 1) {
6866
+ v2 = Math.min(v2, dist[i + size] + dy);
6867
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i + size + 1] + dd);
6868
+ if (x2 > 0) v2 = Math.min(v2, dist[i + size - 1] + dd);
6869
+ }
6870
+ dist[i] = v2;
6871
+ }
6872
+ }
6873
+ for (let i = 0; i < n; i++) out[i] = Math.round(Math.min(dist[i], CUT_REACH_MM) / CUT_REACH_MM * 255);
6874
+ }
6875
+
6548
6876
  // src/surface/compose.ts
6549
6877
  var THREE9 = __toESM(require("three"), 1);
6550
6878
 
@@ -6960,7 +7288,7 @@ var DAMAGE_CHUNK = (
6960
7288
  `
6961
7289
  uniform sampler2D uDamage;
6962
7290
  uniform float uDamageDetail;
6963
- // (edgeWave mm, edgeBite mm, sparkle, \u2013) \u2014 see DamageLook.
7291
+ // (edgeWave mm, edgeBite mm, sparkle, charWidth mm) \u2014 see DamageLook.
6964
7292
  uniform vec4 uLook3;
6965
7293
 
6966
7294
  // The damage grid's texels sit ON the sheet \u2014 texel x at u = x / (N - 1), its
@@ -7097,6 +7425,8 @@ var DAMAGE_SHADE_CHUNK = (
7097
7425
  /* glsl */
7098
7426
  `
7099
7427
  uniform float uDamageTime;
7428
+ // How far each texel is from the cut, as a fraction of PL_CUT_REACH \u2014 see cutDistance.
7429
+ uniform sampler2D uDamageEdge;
7100
7430
  // The look, packed \u2014 see DamageLook for what each number means.
7101
7431
  uniform vec4 uLook0; // emberWidth mm, emberIntensity, emberCoverage, emberFlicker
7102
7432
  uniform vec4 uLook1; // emberGlow, lipWidth mm, lipBrightness, charWarmth
@@ -7106,6 +7436,10 @@ vec3 plDamageLight;
7106
7436
 
7107
7437
  // World units per millimetre: a default sheet is one unit across, and A4 is 210 mm.
7108
7438
  const float PL_MM = 1.0 / 210.0;
7439
+ // How far out a burnt edge's zones can be measured from the cut, mm: past
7440
+ // the widest lip, ember line, char and scorch the look allows by default.
7441
+ // The same number cutDistance encodes with, so the two cannot drift.
7442
+ const float PL_CUT_REACH = ${CUT_REACH_MM.toFixed(1)};
7109
7443
 
7110
7444
  vec3 plLinear(vec3 srgb) {
7111
7445
  return pow(srgb, vec3(2.2));
@@ -7162,7 +7496,12 @@ vec3 plScorchTint(float t) {
7162
7496
  vec3 c3 = plLinear(vec3(0.541, 0.310, 0.141)); // #8A4F24
7163
7497
  vec3 c4 = plLinear(vec3(0.471, 0.275, 0.149)); // #784626
7164
7498
  vec3 c5 = plLinear(vec3(0.380, 0.216, 0.133)); // #613722
7165
- vec3 c6 = plLinear(vec3(0.310, 0.200, 0.137)); // #4F3323
7499
+ // The dark end hands over to the char's black: a nearly neutral umber, not
7500
+ // the saturated brown (#4F3323) it used to stop at. Beside black char that
7501
+ // brown showed through wherever the band thinned, and turned the black
7502
+ // brown \u2014 measured, the cold char's saturation went from 0.43 to 0.11 with
7503
+ // the scorch's darkening switched off.
7504
+ vec3 c6 = plLinear(vec3(0.169, 0.133, 0.110)); // #2B221C
7166
7505
  float s = clamp(t, 0.0, 1.0) * 6.0;
7167
7506
  // Each stop is blended with a SMOOTH step, not a linear one.
7168
7507
  //
@@ -7213,32 +7552,6 @@ void plDamage(inout vec4 color, inout float roughness) {
7213
7552
  vec2 perWorld = (vec2(textureSize(uDamage, 0)) - 1.0) / uSheetSize;
7214
7553
  vec2 gradP = vec2(e.a - w.a, n.a - s.a) * 0.5 * perWorld;
7215
7554
  vec2 gradC = vec2(e.r - w.r, n.r - s.r) * 0.5 * perWorld;
7216
- // Millimetres from the cut, on the paper side \u2014 measured against the field
7217
- // the cut is DRAWN from (softened, frayed, warped), through its own screen
7218
- // derivatives. It used to divide by the slope of the raw texels, and since
7219
- // paper burns through within a texel that slope is a cliff while the drawn
7220
- // edge is spread over millimetres: a point well into the char computed as a
7221
- // hair from the cut, and the ember line drew there \u2014 found by the gate.
7222
- // The slope, though, is the SMOOTH presence's. The fray's fibre noise
7223
- // changes pixel to pixel, and its slope is a cliff everywhere it touches:
7224
- // divided by that, a point deep in the char came out a hair from the cut
7225
- // and the ember glow drew there \u2014 the gate's stray pixels. The fray still
7226
- // moves where the edge is; it just cannot fake how steep it is.
7227
- float smoothA = plDamageSmooth;
7228
- vec2 cutSlope = vec2(dFdx(smoothA), dFdy(smoothA));
7229
- float pxFromCut = (d.a - 0.5) / max(length(cutSlope), 1e-4);
7230
- // Millimetres per pixel ALONG the way to the cut, not across the screen's
7231
- // x: a sheet leaning away is foreshortened vertically, so above and below a
7232
- // hole a pixel covers more paper than beside it, and a scale taken from x
7233
- // alone drew the ember zone too many pixels deep on the top and bottom rims.
7234
- vec2 toCutPx = cutSlope / max(length(cutSlope), 1e-6);
7235
- float mmPerPx = length(dFdx(-vViewPosition) * toCutPx.x + dFdy(-vViewPosition) * toCutPx.y) / PL_MM;
7236
- float mm = pxFromCut * mmPerPx;
7237
- // About a pixel of antialiasing, never more: where the warp makes the
7238
- // estimate jump between neighbours, fwidth(mm) is millimetres wide, and the
7239
- // ember's soft tail \u2014 faint, but multiplied by its light \u2014 drew stray glow
7240
- // well out into the char.
7241
- float aa = min(fwidth(mm), 1.5 * mmPerPx);
7242
7555
  // Scorch reaches ahead of the char, and further UP than anywhere else:
7243
7556
  // the char a centimetre below this point, and the char a few millimetres
7244
7557
  // around it, each lent to the scorch at a discount (\xA74.2, \xA74.5).
@@ -7252,38 +7565,61 @@ void plDamage(inout vec4 color, inout float roughness) {
7252
7565
  charWide += texture2D(uDamage, uv + vec2(cos(a), sin(a)) * 7.0 * mmUv).r;
7253
7566
  }
7254
7567
  charWide /= 8.0;
7255
- // Only paper that has a CUT beside it has an edge: a half-eaten cell out
7256
- // in the middle of the sheet reads as "half a presence" too, and without
7257
- // this the ember line and the ash lip drew on it \u2014 islands of glow on
7258
- // paper, the painted glow by another route.
7259
- // Eased in, not switched: a hard threshold on the neighbouring texels drew
7260
- // the grid back into the ember glow as small squares.
7261
- float cutNear = 1.0 - smoothstep(0.3, 0.62, min(min(e.a, w.a), min(n.a, s.a)));
7262
- // And the cut has to really be there. \`mm\` extrapolates this pixel's
7263
- // slope to half presence, and the uneven edge's warp bends the field inside
7264
- // those few millimetres \u2014 measured: with the warp off the stray ember light
7265
- // the gate finds all but vanishes. So step across the PAPER toward where the
7266
- // cut should be, half a millimetre past it but never beyond the ember
7267
- // zone's own outer edge (the lip and the widest bead \u2014 2.5 mm at the
7268
- // spec's numbers, \xA75) plus that half, and read what the cut is drawn from.
7269
- // Still paper: this is not an edge, however close the slope said it was.
7270
- vec2 toCutPaper = -(dFdx(vPaperUv) * toCutPx.x + dFdy(vPaperUv) * toCutPx.y);
7271
- float reachPx = min(max(mm, 0.0) + 0.5, uLook1.y + uLook0.x + 0.5) / max(mmPerPx, 1e-6);
7272
- float pastA = plDrawnPresenceAt(vPaperUv + toCutPaper * reachPx);
7273
- // And a hole, not a pinhole: the fray opens specks of void finer than a
7274
- // pixel in part-burnt paper, which the check above would take for the
7275
- // edge. The void has to go on for another half millimetre.
7276
- float beyondA = plDrawnPresenceAt(vPaperUv + toCutPaper * (reachPx + 0.5 / max(mmPerPx, 1e-6)));
7277
- cutNear *= (1.0 - smoothstep(0.42, 0.55, pastA)) * (1.0 - smoothstep(0.42, 0.55, beyondA));
7568
+ // \u2500\u2500 The cut, measured \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7569
+ // How far this point is from the cut, in millimetres, out to PL_CUT_REACH \u2014
7570
+ // the one measure every zone of a burnt edge is laid out on: the ash lip,
7571
+ // the ember line, the char and the scorch (Ember_line annotated). Worked
7572
+ // out on the CPU once per change of the field (cutDistance), where a speck
7573
+ // of burnt-through paper can be told from a cut: measured here per pixel,
7574
+ // every speck near the rim grew its own lip, char and scorch, and the bands
7575
+ // came apart into blotches.
7576
+ //
7577
+ // Burnt AWAY, not charred: the black and the lip belong to paper beside a
7578
+ // cut, so a burn toasts before it blackens.
7579
+ //
7580
+ // Read through the same warp as the cut, so the zones follow the edge that
7581
+ // is drawn. Bilinear between a gone texel and the paper beside it, the
7582
+ // drawn edge reads half a texel, which comes back off.
7583
+ vec2 edgeTexel = 1.0 / vec2(textureSize(uDamageEdge, 0));
7584
+ vec2 edgeWorld = uSheetSize / (vec2(textureSize(uDamageEdge, 0)) - 1.0);
7585
+ float edgeRaw = texture2D(uDamageEdge, uv).r;
7586
+ float edgeHalf = 0.5 * min(edgeWorld.x, edgeWorld.y) / PL_MM;
7587
+ float xCut = max(edgeRaw * PL_CUT_REACH - edgeHalf, 0.0);
7588
+ // A pixel or so of antialiasing, measured on the same measure.
7589
+ float aa = clamp(fwidth(xCut), 0.05, 1.0);
7590
+ // Which way the cut lies, from how the distance falls \u2014 and along the edge,
7591
+ // across that. In the sheet's own space, so noise laid along it follows the
7592
+ // rim wherever the rim goes.
7593
+ vec2 toward = -vec2(
7594
+ (texture2D(uDamageEdge, uv + vec2(edgeTexel.x, 0.0)).r - texture2D(uDamageEdge, uv - vec2(edgeTexel.x, 0.0)).r) / edgeWorld.x,
7595
+ (texture2D(uDamageEdge, uv + vec2(0.0, edgeTexel.y)).r - texture2D(uDamageEdge, uv - vec2(0.0, edgeTexel.y)).r) / edgeWorld.y
7596
+ );
7597
+ vec2 tangentR = length(toward) > 1e-4 ? normalize(vec2(-toward.y, toward.x)) : vec2(1.0, 0.0);
7598
+ // Paper with a cut within reach, eased out toward the reach.
7599
+ float cutZone = 1.0 - smoothstep(PL_CUT_REACH - 2.0, PL_CUT_REACH - 0.5, xCut);
7278
7600
 
7279
7601
  // An untouched texel is left exactly as it was \u2014 the identity the whole
7280
7602
  // seam is built on. Nothing here has happened to this paper.
7281
- if (d.r <= 0.0 && d.g <= 0.0 && d.a >= 1.0 && charBelow <= 0.0 && charWide <= 0.0 && e.a >= 1.0 && w.a >= 1.0 && n.a >= 1.0 && s.a >= 1.0) {
7603
+ if (d.r <= 0.0 && d.g <= 0.0 && d.a >= 1.0 && charBelow <= 0.0 && charWide <= 0.0 && edgeRaw >= 1.0 && e.a >= 1.0 && w.a >= 1.0 && n.a >= 1.0 && s.a >= 1.0) {
7282
7604
  return;
7283
7605
  }
7284
7606
 
7285
7607
  vec2 p = plLocal();
7286
7608
 
7609
+ // The zones, out from the cut: the ash lip, the ember line on its outer
7610
+ // edge, the char, then the scorch \u2014 each as wide as the look says, and
7611
+ // uneven along the rim so none of them is a ribbon. The lip's unevenness
7612
+ // stays inside the zone the ember gate allows it (lip plus bead).
7613
+ float lipEdge = uLook1.y * mix(0.7, 1.1, plFbm(vec2(dot(p, tangentR) / (5.0 * PL_MM), 1.7)));
7614
+ float charEnd = lipEdge + uLook0.x + uLook3.w * mix(0.8, 1.2, plFbm(vec2(dot(p, tangentR) / (7.0 * PL_MM), 4.3)));
7615
+ // Further above the burn than below it \u2014 hot gas rises and cooks the paper
7616
+ // over it (\xA74.5) \u2014 out to the scorch's full reach straight up, and about
7617
+ // half of it below and beside.
7618
+ vec2 upMm = normalize(upUv * uSheetSize);
7619
+ float above = length(toward) > 1e-3 ? max(0.0, dot(-normalize(toward), upMm)) : 0.0;
7620
+ float scorchEnd = charEnd + uLook2.y * mix(0.45, 1.0, above)
7621
+ + (plFbm(vec2(dot(p, tangentR) / (4.0 * PL_MM), 3.1)) - 0.5) * uLook2.w * 2.5;
7622
+
7287
7623
  // Wet paper is darker and smoother: water fills the gaps between fibres
7288
7624
  // that scatter light, which is both effects from one cause.
7289
7625
  color.rgb *= 1.0 - 0.38 * d.g;
@@ -7335,27 +7671,64 @@ void plDamage(inout vec4 color, inout float roughness) {
7335
7671
  // being finer than a level it can only ever move a value into the
7336
7672
  // neighbouring one.
7337
7673
  c = clamp(c + (plNoise(p * 900.0) - 0.5) * (1.5 / 255.0), 0.0, 1.0);
7674
+ // The colour INSIDE the scorch reads a smoothed c: the same char and the
7675
+ // same reaches, without the fingers. The fingers belong to the front's
7676
+ // silhouette (lead, below); carried into the ramp as well, a strong fingers
7677
+ // setting swung the scorch between tan and near-black umber a few
7678
+ // millimetres apart \u2014 a leopard print, not a toast.
7679
+ float cSmooth = 1.0 - (1.0 - clamp(mix(d.r, soft, 0.65), 0.0, 1.0)) * (1.0 - clamp(0.62 * charBelow * 0.75, 0.0, 1.0)) * (1.0 - clamp(0.4 * charWide * 0.75, 0.0, 1.0));
7680
+ cSmooth = clamp(cSmooth + ((plNoise(p * 300.0) - 0.5) * 0.05 + (plNoise(p * 90.0) - 0.5) * 0.04) * smoothstep(0.0, 0.1, cSmooth), 0.0, 1.0);
7338
7681
  // A steep leading edge \u2014 paper to straw in a sliver \u2014 then the long ramp.
7339
- float lead = smoothstep(0.02, 0.05, c);
7340
- float ramp = smoothstep(0.05, 0.7, c);
7341
- color.rgb *= mix(vec3(1.0), pow(plScorchTint(ramp), vec3(uLook2.z)), lead);
7682
+ // The front's teeth from the noisy c; filled in behind them from the smooth
7683
+ // one, so a dip in the noise cannot open a patch of clean paper inside it.
7684
+ float lead = max(smoothstep(0.02, 0.05, c), smoothstep(0.1, 0.2, cSmooth));
7685
+ // The browns are done by 0.45, where the char's umber hand-over takes them:
7686
+ // the ramp used to run on to 0.7, over the char, and most of what read as
7687
+ // "char" was this ramp's darkest brown.
7688
+ float ramp = smoothstep(0.05, 0.45, cSmooth);
7689
+ // That toast is for paper with no cut near it \u2014 the first stage of a burn,
7690
+ // before anything has gone through.
7691
+ color.rgb *= mix(vec3(1.0), pow(plScorchTint(ramp), vec3(uLook2.z)), lead * (1.0 - cutZone));
7692
+ // Round a cut, the scorch is a band of its own: dark, only a little lighter
7693
+ // than the char it leads from, a shade warmer toward its outer edge, then a
7694
+ // steep leading edge into clean paper (Ember_line annotated, zone 5: "steep
7695
+ // leading edge \xB7 albedo only"). A black band beside a pale tan one was the
7696
+ // contrast that read as wrong.
7697
+ float scorchT = smoothstep(charEnd, scorchEnd, xCut);
7698
+ vec3 scorchTint = mix(plLinear(vec3(0.24, 0.17, 0.12)), plLinear(vec3(0.42, 0.28, 0.17)), scorchT) / plLinear(vec3(0.90, 0.885, 0.855));
7699
+ float scorchZone = (1.0 - smoothstep(scorchEnd - 0.35, scorchEnd + 0.35, xCut)) * cutZone;
7700
+ color.rgb *= mix(vec3(1.0), pow(min(scorchTint, vec3(1.0)), vec3(uLook2.z)), scorchZone);
7342
7701
 
7343
7702
  // \u2500\u2500 Char \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7344
7703
  // A wide, smooth hand-over from scorch to char \u2014 the gradient is the point.
7345
- float charZone = smoothstep(0.5, 0.9, c);
7704
+ // From 0.3: the field carries char in about one texel beside the cut, so
7705
+ // past a couple of millimetres c is mostly the scorch borrowed from below,
7706
+ // and a zone that began at 0.5 was hardly ever char at all.
7707
+ // Away from a cut, c only makes char where it is really high: at 0.3\u20130.6
7708
+ // the finger noise pushed it across the line all through the scorch, and
7709
+ // every crossing was a black island in the orange \u2014 a leopard print.
7710
+ // Round a cut, the band from the lip out to charEnd; away from one, only
7711
+ // where the toast has gone all the way to char.
7712
+ float charBand = (1.0 - smoothstep(charEnd - 0.5, charEnd + 0.5, xCut)) * cutZone;
7713
+ float charZone = max(smoothstep(0.6, 0.85, cSmooth) * (1.0 - cutZone), charBand);
7346
7714
  vec2 cells = plVoronoi(p / (3.6 * PL_MM));
7347
7715
  // Sparse: a real crack network is broken, not a tiled floor \u2014 most cell
7348
7716
  // borders never split.
7349
7717
  float crack = (1.0 - smoothstep(0.008, 0.03, cells.y - cells.x)) * smoothstep(0.45, 0.65, plNoise(p / (7.0 * PL_MM)));
7350
7718
  float plate = plHash(floor(p / (3.6 * PL_MM)) + 3.7);
7351
- // Dark orange where the char meets the scorch, deep brown toward the cut \u2014
7352
- // warm all the way, and fading smoothly into the scorch's lighter browns
7353
- // rather than stopping at a grey band.
7354
- // Warm (dark orange \u2192 deep brown) or, turned down, the sampled greys.
7355
- vec3 body = mix(plLinear(vec3(0.255, 0.239, 0.227)), plLinear(vec3(0.165, 0.086, 0.043)), uLook1.w);
7356
- vec3 under = mix(plLinear(vec3(0.275, 0.169, 0.090)), plLinear(vec3(0.42, 0.18, 0.06)), uLook1.w);
7719
+ // Black. Real char is a near-neutral black \u2014 (22, 20, 18) on the cold
7720
+ // macro references, about 11% of the paper's value. The warm brown this
7721
+ // used to be was Hero.png's char LIT BY ITS FLAMES, painted into the paper,
7722
+ // which is why ours stayed orange on a frame with no fire in it. Warmth on
7723
+ // char comes from the fire's light now, and goes when the fire does.
7724
+ // charWarmth adds only a trace of brown to the black, and a warmer umber
7725
+ // where the char hands over to the scorch.
7726
+ vec3 body = mix(plLinear(vec3(0.086, 0.078, 0.071)), plLinear(vec3(0.11, 0.08, 0.06)), uLook1.w);
7727
+ vec3 under = mix(plLinear(vec3(0.20, 0.15, 0.11)), plLinear(vec3(0.28, 0.16, 0.08)), uLook1.w);
7357
7728
  vec3 cracks = plLinear(vec3(0.039, 0.024, 0.016)); // #0A0604
7358
- vec3 charColor = mix(under, body, smoothstep(0.66, 1.0, c));
7729
+ // Black through the band; the umber only in its outer millimetre.
7730
+ float toUnder = mix(1.0 - smoothstep(0.45, 0.8, cSmooth), smoothstep(charEnd - 1.2, charEnd + 0.3, xCut), cutZone);
7731
+ vec3 charColor = mix(body, under, toUnder);
7359
7732
  // Mottled at the plate scale and finer, and darker than the sampled body
7360
7733
  // under a bright key \u2014 the reference's char reads near black in the frame.
7361
7734
  charColor *= (0.75 + 0.35 * plate) * (0.8 + 0.4 * plNoise(p * 260.0));
@@ -7367,42 +7740,42 @@ void plDamage(inout vec4 color, inout float roughness) {
7367
7740
  plHeight += charZone * ((plNoise(p * 380.0) - 0.5) * 0.00008 + (plate - 0.5) * 0.00018 - crack * 0.00016);
7368
7741
 
7369
7742
  // \u2500\u2500 Ash lip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7370
- // Inside the spec's zones (\xA75): the lip 0.5\u20131.5 mm, then the ember line
7371
- // 0.3\u20131 mm \u2014 2.5 mm from the cut at most, which the gate holds it to.
7372
- // Along the rim. Both the lip and the ember line below are laid out on this,
7373
- // and unlike the scorch fingers above it is safe to sample noise in: it is
7374
- // used ONLY within a couple of millimetres of the cut, where the presence
7375
- // gradient is the actual edge and points somewhere real. The fingers'
7376
- // mistake was carrying the same idea out into flat char, where the gradient
7377
- // is rounding error and a noise sampled in its frame draws the contours.
7378
- vec2 tangentP = length(gradP) > 1e-4 ? normalize(vec2(-gradP.y, gradP.x)) : vec2(1.0, 0.0);
7379
-
7380
- float lipWidth = mix(0.4, max(uLook1.y, 0.41), plFbm(p / (2.5 * PL_MM)));
7381
- // Broken, not a hem. Ash flakes off a cooling edge in pieces, so the lip
7382
- // comes and goes along the rim and wanders in width where it is there. It
7383
- // used to be a continuous band of even width with a highlight down the
7384
- // middle of it, which read as a neon tube round the hole \u2014 the single most
7385
- // synthetic thing at wide view.
7386
- float lipAlong = dot(p, tangentP) / (3.2 * PL_MM);
7387
- float lipBreak = smoothstep(0.34, 0.56, plFbm(vec2(lipAlong, 0.9)));
7388
- float lip = (1.0 - smoothstep(lipWidth - aa, lipWidth + aa, mm)) * step(d.a, 0.999) * cutNear * lipBreak;
7389
- vec3 ash = plLinear(mix(vec3(0.525, 0.498, 0.490), vec3(0.741, 0.725, 0.725), plNoise(p * 520.0) * 0.6 + plNoise(p * 90.0) * 0.4)) * uLook1.z;
7743
+ // A crust as wide as the char band, from the cut to lipEdge \u2014 the pale
7744
+ // thing that makes a hole read as a hole on a black stage (Ember_line
7745
+ // annotated, zone 2). It used to be held to about a millimetre, which at a
7746
+ // reading distance is a hairline tracing the edge, not ash.
7747
+ //
7748
+ // Broken, not a hem: ash flakes off a cooling edge in pieces, so the lip
7749
+ // comes and goes along the rim and wanders in width. Mostly there, though \u2014
7750
+ // it has to be seen.
7751
+ float lipAlong = dot(p, tangentR) / (3.2 * PL_MM);
7752
+ float lipBreak = smoothstep(0.1, 0.3, plFbm(vec2(lipAlong, 0.9)));
7753
+ float lip = (1.0 - smoothstep(lipEdge - aa, lipEdge + aa, xCut)) * cutZone * lipBreak;
7754
+ // Pale: the reference's #A49E9D up to a near-white grey, so it reads white
7755
+ // beside the char. Still matte and still dimmer than the paper.
7756
+ vec3 ash = plLinear(mix(vec3(0.643, 0.620, 0.616), vec3(0.84, 0.82, 0.81), plNoise(p * 520.0) * 0.6 + plNoise(p * 90.0) * 0.4)) * uLook1.z;
7390
7757
  color.rgb = mix(color.rgb, ash, lip);
7391
7758
  roughness = mix(roughness, 0.99, lip);
7392
- // Barely raised. THIS is where the highlight came from: a 0.7 mm ridge
7393
- // running round the hole is a cylinder, and a cylinder under a key light
7394
- // has a specular line down it however rough the surface is. Ash is a
7395
- // fragile crust a fraction of a millimetre proud of the paper, not a bead
7396
- // of solder \u2014 and the shape of it belongs to the broken edge above, not to
7397
- // a smooth ramp.
7398
- plHeight += lip * (1.0 - mm / max(lipWidth, 0.1)) * 0.00018;
7759
+ // And the crust the ragged edge is made of. The cut is frayed at fibre
7760
+ // scale, and the fray opened specks of void all through the first few
7761
+ // millimetres \u2014 exactly where the lip lies \u2014 so at a reading distance the
7762
+ // lip was grey specks in black. Paper inside the fray's band is kept, as
7763
+ // ash, instead of cut away: the lip overhangs the hole a little, broken
7764
+ // where the lip is broken, the way the reference's crust does.
7765
+ float crust = lipBreak * cutZone * smoothstep(0.18, 0.3, d.a) * (1.0 - step(0.5, d.a));
7766
+ color.rgb = mix(color.rgb, ash, crust);
7767
+ roughness = mix(roughness, 0.99, crust);
7768
+ // Raised, and crumbly rather than rounded: a smooth ridge round the hole is
7769
+ // a cylinder, and a cylinder under a key light has a specular line down it
7770
+ // however rough it is \u2014 the neon tube this once was.
7771
+ plHeight += lip * (0.55 + 0.45 * plNoise(p * 700.0)) * 0.00016;
7399
7772
 
7400
7773
  // \u2500\u2500 The ember line \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7401
7774
  // Beads along the cut: position along the edge, crawling on the burn's
7402
7775
  // clock, with a flicker of their own. Only where it is hot \u2014 and as the
7403
7776
  // heat goes the threshold rises, so the line breaks into fewer, dimmer
7404
7777
  // beads and they go out one by one.
7405
- float s1 = dot(p, tangentP) / (2.4 * PL_MM) - uDamageTime * 0.55 * uLook0.w;
7778
+ float s1 = dot(p, tangentR) / (2.4 * PL_MM) - uDamageTime * 0.55 * uLook0.w;
7406
7779
  float bead = plNoise(vec2(s1, 0.37)) * 0.7 + plNoise(vec2(s1 * 2.3, 5.1)) * 0.3;
7407
7780
  float flicker = 0.62 + 0.38 * plNoise(vec2(floor(s1) * 7.13, uDamageTime * 9.0 * uLook0.w));
7408
7781
  // Down to a smoulder's last flicker: a spent edge keeps a little heat in
@@ -7417,7 +7790,8 @@ void plDamage(inout vec4 color, inout float roughness) {
7417
7790
  float lit = smoothstep(mix(0.82, 0.45, hot) - shift, mix(0.9, 0.58, hot) - shift, bead);
7418
7791
  // Each bead tapers at its ends \u2014 a bead, not a dash cut from a strip.
7419
7792
  float width = max(mix(0.4, max(uLook0.x, 0.41), plNoise(vec2(s1 * 0.7, 9.0))) * (0.35 + 0.65 * lit), 1.4 * aa);
7420
- float band = smoothstep(lipWidth - aa, lipWidth + aa, mm) * (1.0 - smoothstep(lipWidth + width - aa, lipWidth + width + aa, mm)) * cutNear;
7793
+ // On the lip's outer edge, between the ash and the char.
7794
+ float band = smoothstep(lipEdge - aa, lipEdge + aa, xCut) * (1.0 - smoothstep(lipEdge + width - aa, lipEdge + width + aa, xCut)) * cutZone;
7421
7795
  // Mostly orange: a bead is red at its ends and yellow-white only at its
7422
7796
  // hottest core, which is what Ember_line.png shows. Mapped low and pushed
7423
7797
  // high by a power, so white is the exception rather than the line.
@@ -7437,13 +7811,15 @@ void plDamage(inout vec4 color, inout float roughness) {
7437
7811
  // edge is hot, and inside the zone the beads are held to \u2014 the lip plus
7438
7812
  // the widest bead, measured from the cut \u2014 so the glow never reaches out
7439
7813
  // over paper the gate says heat must not light.
7440
- float zoneEnd = uLook1.y + uLook0.x;
7441
- float seam = smoothstep(lipWidth - aa, lipWidth + aa, mm) * (1.0 - smoothstep(zoneEnd - 0.6, zoneEnd, mm)) * cutNear;
7814
+ float zoneEnd = lipEdge + uLook0.x;
7815
+ float seam = smoothstep(lipEdge - aa, lipEdge + aa, xCut) * (1.0 - smoothstep(zoneEnd - 0.6, zoneEnd, xCut)) * cutZone;
7442
7816
  float patch_ = smoothstep(0.35, 0.72, plFbm(p / (4.0 * PL_MM) + vec2(uDamageTime * 0.15 * uLook0.w, 0.0)));
7443
7817
  plDamageLight += vec3(0.55, 0.06, 0.01) * seam * patch_ * hot * uLook1.x;
7444
7818
  float speck = step(0.985, plHash(floor(p / (0.35 * PL_MM)) + floor(uDamageTime * 8.0 * uLook0.w)));
7445
7819
  plDamageLight += plEmberRamp(0.9) * 1.6 * speck * seam * hot * uLook3.z;
7446
7820
 
7821
+ // The crust stays, however the fray cut it.
7822
+ d.a = mix(d.a, max(d.a, 0.5), step(0.5, crust));
7447
7823
  plDamageCut(color, d);
7448
7824
  }
7449
7825
  `
@@ -7554,6 +7930,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7554
7930
  chunks.push(DAMAGE_CHUNK, DAMAGE_SHADE_CHUNK);
7555
7931
  calls.push("plDamage(csm_DiffuseColor, csm_Roughness);");
7556
7932
  uniforms.uDamage = { value: null };
7933
+ uniforms.uDamageEdge = { value: null };
7557
7934
  uniforms.uDamageDetail = { value: 1 };
7558
7935
  uniforms.uDamageTime = { value: 0 };
7559
7936
  const look = DAMAGE_LOOK_DEFAULTS;
@@ -7566,7 +7943,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7566
7943
  uniforms.uLook2 = {
7567
7944
  value: new THREE9.Vector4(look.charCracks, look.scorchReach, look.scorchDarkness, look.fingers)
7568
7945
  };
7569
- uniforms.uLook3 = { value: new THREE9.Vector4(look.edgeWave, look.edgeBite, look.sparkle, 0) };
7946
+ uniforms.uLook3 = { value: new THREE9.Vector4(look.edgeWave, look.edgeBite, look.sparkle, look.charWidth) };
7570
7947
  }
7571
7948
  const cutting = [];
7572
7949
  const cuts = [];
@@ -7670,35 +8047,65 @@ function useLightRig(own) {
7670
8047
  var THREE10 = __toESM(require("three"), 1);
7671
8048
  var import_fiber4 = require("@react-three/fiber");
7672
8049
  var import_react9 = require("react");
7673
- function useDamageTexture(source) {
7674
- const texture = (0, import_react9.useMemo)(() => {
8050
+ var MM_PER_WORLD = 210;
8051
+ function useDamageTexture(source, sheet2 = { width: 1, height: 1.4 }) {
8052
+ const { width, height } = sheet2;
8053
+ const textures = (0, import_react9.useMemo)(() => {
7675
8054
  if (!source) return null;
7676
- const t = new THREE10.DataTexture(
8055
+ const damage = new THREE10.DataTexture(
7677
8056
  source.pixels,
7678
8057
  source.size,
7679
8058
  source.size,
7680
8059
  THREE10.RGBAFormat,
7681
8060
  THREE10.UnsignedByteType
7682
8061
  );
7683
- t.magFilter = THREE10.LinearFilter;
7684
- t.minFilter = THREE10.LinearFilter;
7685
- t.wrapS = THREE10.ClampToEdgeWrapping;
7686
- t.wrapT = THREE10.ClampToEdgeWrapping;
7687
- t.generateMipmaps = false;
7688
- t.colorSpace = THREE10.NoColorSpace;
7689
- t.flipY = false;
7690
- t.userData.version = source.version;
7691
- t.needsUpdate = true;
7692
- return t;
7693
- }, [source]);
7694
- (0, import_react9.useEffect)(() => () => texture?.dispose(), [texture]);
8062
+ const edge = new THREE10.DataTexture(
8063
+ new Uint8Array(source.size * source.size),
8064
+ source.size,
8065
+ source.size,
8066
+ THREE10.RedFormat,
8067
+ THREE10.UnsignedByteType
8068
+ );
8069
+ for (const t of [damage, edge]) {
8070
+ t.magFilter = THREE10.LinearFilter;
8071
+ t.minFilter = THREE10.LinearFilter;
8072
+ t.wrapS = THREE10.ClampToEdgeWrapping;
8073
+ t.wrapT = THREE10.ClampToEdgeWrapping;
8074
+ t.generateMipmaps = false;
8075
+ t.colorSpace = THREE10.NoColorSpace;
8076
+ t.flipY = false;
8077
+ t.unpackAlignment = 1;
8078
+ }
8079
+ measureEdge(source, edge, width, height);
8080
+ damage.userData.version = source.version;
8081
+ damage.needsUpdate = true;
8082
+ return { damage, edge };
8083
+ }, [source, width, height]);
8084
+ (0, import_react9.useEffect)(
8085
+ () => () => {
8086
+ textures?.damage.dispose();
8087
+ textures?.edge.dispose();
8088
+ },
8089
+ [textures]
8090
+ );
7695
8091
  (0, import_fiber4.useFrame)(() => {
7696
- if (!source || !texture) return;
7697
- if (texture.userData.version === source.version) return;
7698
- texture.userData.version = source.version;
7699
- texture.needsUpdate = true;
8092
+ if (!source || !textures) return;
8093
+ if (textures.damage.userData.version === source.version) return;
8094
+ textures.damage.userData.version = source.version;
8095
+ textures.damage.needsUpdate = true;
8096
+ measureEdge(source, textures.edge, width, height);
7700
8097
  });
7701
- return texture;
8098
+ return textures;
8099
+ }
8100
+ function measureEdge(source, edge, width, height) {
8101
+ const last = Math.max(1, source.size - 1);
8102
+ cutDistance(
8103
+ source.pixels,
8104
+ source.size,
8105
+ [width * MM_PER_WORLD / last, height * MM_PER_WORLD / last],
8106
+ edge.image.data
8107
+ );
8108
+ edge.needsUpdate = true;
7702
8109
  }
7703
8110
 
7704
8111
  // src/surface/PaperMaterial.tsx
@@ -7715,7 +8122,7 @@ function PaperMaterial({
7715
8122
  damage
7716
8123
  }) {
7717
8124
  const rig = useLightRig(lighting);
7718
- const damageTexture = useDamageTexture(damage);
8125
+ const damageTextures = useDamageTexture(damage, sheet2);
7719
8126
  const composed = composeSurface(
7720
8127
  surface,
7721
8128
  stock,
@@ -7723,7 +8130,7 @@ function PaperMaterial({
7723
8130
  {
7724
8131
  hasFrontMap: Boolean(texture),
7725
8132
  hasBackMap: Boolean(backTexture),
7726
- hasDamage: Boolean(damageTexture)
8133
+ hasDamage: Boolean(damageTextures)
7727
8134
  },
7728
8135
  sheet2,
7729
8136
  rig,
@@ -7732,7 +8139,7 @@ function PaperMaterial({
7732
8139
  const bound = (0, import_react10.useMemo)(() => composed.uniforms, [composed.structureKey]);
7733
8140
  (0, import_react10.useEffect)(() => {
7734
8141
  for (const [key, uniform] of Object.entries(composed.uniforms)) {
7735
- if (!bound[key] || key === "uFrontMap" || key === "uBackMap" || key === "uDamage" || key === "uDamageDetail" || key === "uDamageTime" || key.startsWith("uLook")) {
8142
+ if (!bound[key] || key === "uFrontMap" || key === "uBackMap" || key === "uDamage" || key === "uDamageEdge" || key === "uDamageDetail" || key === "uDamageTime" || key.startsWith("uLook")) {
7736
8143
  continue;
7737
8144
  }
7738
8145
  if (bound[key].value instanceof THREE11.Color && uniform.value instanceof THREE11.Color) {
@@ -7746,8 +8153,9 @@ function PaperMaterial({
7746
8153
  (0, import_react10.useEffect)(() => {
7747
8154
  if (bound.uFrontMap) bound.uFrontMap.value = texture;
7748
8155
  if (bound.uBackMap) bound.uBackMap.value = backTexture ?? null;
7749
- if (bound.uDamage) bound.uDamage.value = damageTexture;
7750
- }, [bound, texture, backTexture, damageTexture]);
8156
+ if (bound.uDamage) bound.uDamage.value = damageTextures?.damage ?? null;
8157
+ if (bound.uDamageEdge) bound.uDamageEdge.value = damageTextures?.edge ?? null;
8158
+ }, [bound, texture, backTexture, damageTextures]);
7751
8159
  (0, import_fiber5.useFrame)((state) => {
7752
8160
  if (!bound.uDamageDetail) return;
7753
8161
  if (bound.uDamageTime) {
@@ -7780,7 +8188,7 @@ function PaperMaterial({
7780
8188
  v2(look.edgeWave, d.edgeWave),
7781
8189
  v2(look.edgeBite, d.edgeBite),
7782
8190
  v2(look.sparkle, d.sparkle),
7783
- 0
8191
+ v2(look.charWidth, d.charWidth)
7784
8192
  );
7785
8193
  }
7786
8194
  const detail = damage?.detail ?? 1;
@@ -8491,7 +8899,11 @@ var PaperMesh = (0, import_react12.forwardRef)(function PaperMesh2(props, ref) {
8491
8899
  coupling?.update(props.damage);
8492
8900
  sim.step(delta);
8493
8901
  const moved = !sim.asleep;
8494
- if (moved) coupling?.follow();
8902
+ if (moved && coupling) {
8903
+ coupling.follow();
8904
+ const index = geometry.index;
8905
+ if (index && coupling.tear(index.array)) index.needsUpdate = true;
8906
+ }
8495
8907
  if (!applyShape(sim.positions, moved) && moved) {
8496
8908
  const position = geometry.attributes.position;
8497
8909
  position.array.set(sim.positions);