paperlab 0.7.1 → 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-NTDVOOBA.js → chunk-SVWWC5UL.js} +372 -151
  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 +402 -172
  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 +396 -179
  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-NTDVOOBA.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,
@@ -4437,6 +4498,16 @@ var ClothSim = class {
4437
4498
  * cannot do this job: it moves paper, and a uniformly curled sheet in still
4438
4499
  * air has no business going anywhere.
4439
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;
4440
4511
  restBend;
4441
4512
  /** For each bend spring, the particle between its ends; -1 for every other kind. */
4442
4513
  constraintMiddle;
@@ -4523,6 +4594,7 @@ var ClothSim = class {
4523
4594
  this.constraintStiffness = new Float64Array(m).fill(1);
4524
4595
  this.broken = new Uint8Array(m);
4525
4596
  this.restBend = new Float64Array(m);
4597
+ this.draught = new Float64Array(this.count * 3);
4526
4598
  this.constraintMiddle = new Int32Array(m).fill(-1);
4527
4599
  for (let k2 = 0; k2 < m; k2++) {
4528
4600
  const a = links[k2 * 3];
@@ -4861,9 +4933,9 @@ var ClothSim = class {
4861
4933
  this.prev[i3] = x2;
4862
4934
  this.prev[i3 + 1] = y2;
4863
4935
  this.prev[i3 + 2] = z31;
4864
- p2[i3] = x2 + vx + ax * im * dt2;
4865
- p2[i3 + 1] = y2 + vy + (ay * im - gravity * 3.2) * dt2;
4866
- 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;
4867
4939
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
4868
4940
  }
4869
4941
  for (let k2 = 0; k2 < this.grabbed.length; k2++) {
@@ -4961,41 +5033,13 @@ var ClothSim = class {
4961
5033
  }
4962
5034
  };
4963
5035
 
4964
- // src/surface/damageContract.ts
4965
- var DAMAGE_LOOK_DEFAULTS = {
4966
- // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
4967
- // it was tuned in — a control at its limit is a report that something
4968
- // underneath is wrong, which in this case was a fire nothing could see.
4969
- emberWidth: 0.9,
4970
- emberIntensity: 1.45,
4971
- emberCoverage: 0.6,
4972
- emberFlicker: 1.65,
4973
- emberGlow: 1.25,
4974
- sparkle: 0.5,
4975
- lipWidth: 1.2,
4976
- // Was 1.5, its slider's ceiling, which made the ash lip brighter than the
4977
- // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
4978
- // the sheet, not a highlight drawn on it.
4979
- lipBrightness: 0.85,
4980
- // Was 1, also a ceiling. At full warmth the char is milk chocolate —
4981
- // closer to cardboard than to charcoal (§5). Burnt paper keeps a little
4982
- // warmth in the plates and reads near black in a frame with a fire in it.
4983
- charWarmth: 0.3,
4984
- // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
4985
- // at full strength the char reads as a mosaic rather than as broken plates.
4986
- charCracks: 0.55,
4987
- scorchReach: 30,
4988
- scorchDarkness: 1.17,
4989
- fingers: 1.25,
4990
- edgeWave: 8.5,
4991
- edgeBite: 3.6
4992
- };
4993
- var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
4994
-
4995
5036
  // src/physics/damage.ts
4996
- var { char: CHAR, saturation: SATURATION, presence: PRESENCE } = DAMAGE_CHANNELS;
5037
+ var { char: CHAR, saturation: SATURATION, heat: HEAT, presence: PRESENCE } = DAMAGE_CHANNELS;
4997
5038
  var GONE_BELOW = 128;
4998
5039
  var CHAR_SHRINK = 0.12;
5040
+ var ACROSS_SHRINK = 0.55;
5041
+ var UPDRAFT = 1.1;
5042
+ var UPDRAFT_OUT = 0.35;
4999
5043
  var CHAR_CURL = 6;
5000
5044
  var FRONT_GRADIENT = 16 * 16;
5001
5045
  var WET_MASS = 2;
@@ -5028,9 +5072,12 @@ var DamageCoupling = class {
5028
5072
  /** Union-find over the particles, reused on every read. */
5029
5073
  parent;
5030
5074
  /**
5031
- * Per bend spring, how squarely it lies ACROSS the burn front, 0..1 — and
5075
+ * Per spring, how squarely it lies ACROSS the burn front, 0..1 — and
5032
5076
  * remembered once it has been.
5033
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
+ *
5034
5081
  * A burnt edge rolls about an axis along the front, like a scroll; it does
5035
5082
  * not dish into a bowl. Measured with the curl in every bend spring alike: a
5036
5083
  * burnt band settled into a saddle, its corners forward and its middle BACK
@@ -5043,7 +5090,7 @@ var DamageCoupling = class {
5043
5090
  * gone by, the char behind it is uniform and says nothing about direction,
5044
5091
  * but the paper stays rolled the way it rolled.
5045
5092
  */
5046
- curlWeight;
5093
+ acrossFront;
5047
5094
  /** The mesh's index as it was built — what {@link tear} hides triangles from and mends them back to. */
5048
5095
  original = null;
5049
5096
  /** Per triangle of that index: torn, and hidden until the history is rewound. */
@@ -5058,7 +5105,7 @@ var DamageCoupling = class {
5058
5105
  this.gone = new Uint8Array(sim.count);
5059
5106
  this.piece = new Int32Array(sim.count);
5060
5107
  this.parent = new Int32Array(sim.count);
5061
- this.curlWeight = new Float32Array(sim.constraintCount);
5108
+ this.acrossFront = new Float32Array(sim.constraintCount);
5062
5109
  }
5063
5110
  /**
5064
5111
  * Bring the sim's levers up to date with `source`. Call before stepping.
@@ -5070,7 +5117,7 @@ var DamageCoupling = class {
5070
5117
  const next = source ?? null;
5071
5118
  if (next === this.source && (next === null || next.version === this.version)) return false;
5072
5119
  if (next !== this.source) {
5073
- this.curlWeight.fill(0);
5120
+ this.acrossFront.fill(0);
5074
5121
  if (next && this.holed) {
5075
5122
  this.rewind();
5076
5123
  this.gone.fill(0);
@@ -5215,8 +5262,10 @@ var DamageCoupling = class {
5215
5262
  }
5216
5263
  }
5217
5264
  read(pixels) {
5218
- const { sim, gone, particleTexel, curlWeight, size } = this;
5265
+ const { sim, gone, particleTexel, acrossFront, size } = this;
5219
5266
  const {
5267
+ draught,
5268
+ cols,
5220
5269
  invMass,
5221
5270
  restLength,
5222
5271
  naturalLength,
@@ -5236,6 +5285,12 @@ var DamageCoupling = class {
5236
5285
  if (isGone) holed = true;
5237
5286
  gone[i] = isGone ? 1 : 0;
5238
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;
5239
5294
  }
5240
5295
  if (rewound) this.rewind();
5241
5296
  this.holed = holed;
@@ -5256,20 +5311,25 @@ var DamageCoupling = class {
5256
5311
  samples = 3;
5257
5312
  }
5258
5313
  const char = charSum / (samples * 255);
5259
- 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));
5260
5330
  if (constraintKind[k2] === 2) {
5261
- const t = particleTexel[m];
5262
- const x2 = t % size;
5263
- const y2 = t / size | 0;
5264
- const gx = pixels[(y2 * size + Math.min(size - 1, x2 + 2)) * 4 + CHAR] - pixels[(y2 * size + Math.max(0, x2 - 2)) * 4 + CHAR];
5265
- const gy = pixels[(Math.min(size - 1, y2 + 2) * size + x2) * 4 + CHAR] - pixels[(Math.max(0, y2 - 2) * size + x2) * 4 + CHAR];
5266
- const steep = gx * gx + gy * gy;
5267
- if (steep > FRONT_GRADIENT) {
5268
- const across = (b2 - a === 2 ? gx * gx : gy * gy) / steep;
5269
- if (across > curlWeight[k2]) curlWeight[k2] = across;
5270
- }
5271
5331
  const span = naturalLength[k2];
5272
- restBend[k2] = CHAR_CURL * char * curlWeight[k2] * span * span / 8;
5332
+ restBend[k2] = CHAR_CURL * char * across * span * span / 8;
5273
5333
  }
5274
5334
  }
5275
5335
  this.findPieces();
@@ -5354,19 +5414,20 @@ var DamageCoupling = class {
5354
5414
  */
5355
5415
  rewind() {
5356
5416
  this.sim.restore();
5357
- this.curlWeight.fill(0);
5417
+ this.acrossFront.fill(0);
5358
5418
  this.torn.fill(0);
5359
5419
  this.mend = true;
5360
5420
  }
5361
5421
  reset() {
5362
5422
  const { sim } = this;
5363
5423
  if (this.holed) this.rewind();
5424
+ sim.draught.fill(0);
5364
5425
  sim.invMass.fill(1);
5365
5426
  sim.restBend.fill(0);
5366
5427
  sim.broken.fill(0);
5367
5428
  sim.restLength.set(sim.naturalLength);
5368
5429
  this.gone.fill(0);
5369
- this.curlWeight.fill(0);
5430
+ this.acrossFront.fill(0);
5370
5431
  this.followers = [];
5371
5432
  this.holed = false;
5372
5433
  this.version = -1;
@@ -6736,6 +6797,82 @@ function p({
6736
6797
  }
6737
6798
  var j2 = r.forwardRef(p);
6738
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
+
6739
6876
  // src/surface/compose.ts
6740
6877
  var THREE9 = __toESM(require("three"), 1);
6741
6878
 
@@ -7151,7 +7288,7 @@ var DAMAGE_CHUNK = (
7151
7288
  `
7152
7289
  uniform sampler2D uDamage;
7153
7290
  uniform float uDamageDetail;
7154
- // (edgeWave mm, edgeBite mm, sparkle, \u2013) \u2014 see DamageLook.
7291
+ // (edgeWave mm, edgeBite mm, sparkle, charWidth mm) \u2014 see DamageLook.
7155
7292
  uniform vec4 uLook3;
7156
7293
 
7157
7294
  // The damage grid's texels sit ON the sheet \u2014 texel x at u = x / (N - 1), its
@@ -7288,6 +7425,8 @@ var DAMAGE_SHADE_CHUNK = (
7288
7425
  /* glsl */
7289
7426
  `
7290
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;
7291
7430
  // The look, packed \u2014 see DamageLook for what each number means.
7292
7431
  uniform vec4 uLook0; // emberWidth mm, emberIntensity, emberCoverage, emberFlicker
7293
7432
  uniform vec4 uLook1; // emberGlow, lipWidth mm, lipBrightness, charWarmth
@@ -7297,6 +7436,10 @@ vec3 plDamageLight;
7297
7436
 
7298
7437
  // World units per millimetre: a default sheet is one unit across, and A4 is 210 mm.
7299
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)};
7300
7443
 
7301
7444
  vec3 plLinear(vec3 srgb) {
7302
7445
  return pow(srgb, vec3(2.2));
@@ -7353,7 +7496,12 @@ vec3 plScorchTint(float t) {
7353
7496
  vec3 c3 = plLinear(vec3(0.541, 0.310, 0.141)); // #8A4F24
7354
7497
  vec3 c4 = plLinear(vec3(0.471, 0.275, 0.149)); // #784626
7355
7498
  vec3 c5 = plLinear(vec3(0.380, 0.216, 0.133)); // #613722
7356
- 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
7357
7505
  float s = clamp(t, 0.0, 1.0) * 6.0;
7358
7506
  // Each stop is blended with a SMOOTH step, not a linear one.
7359
7507
  //
@@ -7404,32 +7552,6 @@ void plDamage(inout vec4 color, inout float roughness) {
7404
7552
  vec2 perWorld = (vec2(textureSize(uDamage, 0)) - 1.0) / uSheetSize;
7405
7553
  vec2 gradP = vec2(e.a - w.a, n.a - s.a) * 0.5 * perWorld;
7406
7554
  vec2 gradC = vec2(e.r - w.r, n.r - s.r) * 0.5 * perWorld;
7407
- // Millimetres from the cut, on the paper side \u2014 measured against the field
7408
- // the cut is DRAWN from (softened, frayed, warped), through its own screen
7409
- // derivatives. It used to divide by the slope of the raw texels, and since
7410
- // paper burns through within a texel that slope is a cliff while the drawn
7411
- // edge is spread over millimetres: a point well into the char computed as a
7412
- // hair from the cut, and the ember line drew there \u2014 found by the gate.
7413
- // The slope, though, is the SMOOTH presence's. The fray's fibre noise
7414
- // changes pixel to pixel, and its slope is a cliff everywhere it touches:
7415
- // divided by that, a point deep in the char came out a hair from the cut
7416
- // and the ember glow drew there \u2014 the gate's stray pixels. The fray still
7417
- // moves where the edge is; it just cannot fake how steep it is.
7418
- float smoothA = plDamageSmooth;
7419
- vec2 cutSlope = vec2(dFdx(smoothA), dFdy(smoothA));
7420
- float pxFromCut = (d.a - 0.5) / max(length(cutSlope), 1e-4);
7421
- // Millimetres per pixel ALONG the way to the cut, not across the screen's
7422
- // x: a sheet leaning away is foreshortened vertically, so above and below a
7423
- // hole a pixel covers more paper than beside it, and a scale taken from x
7424
- // alone drew the ember zone too many pixels deep on the top and bottom rims.
7425
- vec2 toCutPx = cutSlope / max(length(cutSlope), 1e-6);
7426
- float mmPerPx = length(dFdx(-vViewPosition) * toCutPx.x + dFdy(-vViewPosition) * toCutPx.y) / PL_MM;
7427
- float mm = pxFromCut * mmPerPx;
7428
- // About a pixel of antialiasing, never more: where the warp makes the
7429
- // estimate jump between neighbours, fwidth(mm) is millimetres wide, and the
7430
- // ember's soft tail \u2014 faint, but multiplied by its light \u2014 drew stray glow
7431
- // well out into the char.
7432
- float aa = min(fwidth(mm), 1.5 * mmPerPx);
7433
7555
  // Scorch reaches ahead of the char, and further UP than anywhere else:
7434
7556
  // the char a centimetre below this point, and the char a few millimetres
7435
7557
  // around it, each lent to the scorch at a discount (\xA74.2, \xA74.5).
@@ -7443,38 +7565,61 @@ void plDamage(inout vec4 color, inout float roughness) {
7443
7565
  charWide += texture2D(uDamage, uv + vec2(cos(a), sin(a)) * 7.0 * mmUv).r;
7444
7566
  }
7445
7567
  charWide /= 8.0;
7446
- // Only paper that has a CUT beside it has an edge: a half-eaten cell out
7447
- // in the middle of the sheet reads as "half a presence" too, and without
7448
- // this the ember line and the ash lip drew on it \u2014 islands of glow on
7449
- // paper, the painted glow by another route.
7450
- // Eased in, not switched: a hard threshold on the neighbouring texels drew
7451
- // the grid back into the ember glow as small squares.
7452
- float cutNear = 1.0 - smoothstep(0.3, 0.62, min(min(e.a, w.a), min(n.a, s.a)));
7453
- // And the cut has to really be there. \`mm\` extrapolates this pixel's
7454
- // slope to half presence, and the uneven edge's warp bends the field inside
7455
- // those few millimetres \u2014 measured: with the warp off the stray ember light
7456
- // the gate finds all but vanishes. So step across the PAPER toward where the
7457
- // cut should be, half a millimetre past it but never beyond the ember
7458
- // zone's own outer edge (the lip and the widest bead \u2014 2.5 mm at the
7459
- // spec's numbers, \xA75) plus that half, and read what the cut is drawn from.
7460
- // Still paper: this is not an edge, however close the slope said it was.
7461
- vec2 toCutPaper = -(dFdx(vPaperUv) * toCutPx.x + dFdy(vPaperUv) * toCutPx.y);
7462
- float reachPx = min(max(mm, 0.0) + 0.5, uLook1.y + uLook0.x + 0.5) / max(mmPerPx, 1e-6);
7463
- float pastA = plDrawnPresenceAt(vPaperUv + toCutPaper * reachPx);
7464
- // And a hole, not a pinhole: the fray opens specks of void finer than a
7465
- // pixel in part-burnt paper, which the check above would take for the
7466
- // edge. The void has to go on for another half millimetre.
7467
- float beyondA = plDrawnPresenceAt(vPaperUv + toCutPaper * (reachPx + 0.5 / max(mmPerPx, 1e-6)));
7468
- 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);
7469
7600
 
7470
7601
  // An untouched texel is left exactly as it was \u2014 the identity the whole
7471
7602
  // seam is built on. Nothing here has happened to this paper.
7472
- 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) {
7473
7604
  return;
7474
7605
  }
7475
7606
 
7476
7607
  vec2 p = plLocal();
7477
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
+
7478
7623
  // Wet paper is darker and smoother: water fills the gaps between fibres
7479
7624
  // that scatter light, which is both effects from one cause.
7480
7625
  color.rgb *= 1.0 - 0.38 * d.g;
@@ -7526,27 +7671,64 @@ void plDamage(inout vec4 color, inout float roughness) {
7526
7671
  // being finer than a level it can only ever move a value into the
7527
7672
  // neighbouring one.
7528
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);
7529
7681
  // A steep leading edge \u2014 paper to straw in a sliver \u2014 then the long ramp.
7530
- float lead = smoothstep(0.02, 0.05, c);
7531
- float ramp = smoothstep(0.05, 0.7, c);
7532
- 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);
7533
7701
 
7534
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
7535
7703
  // A wide, smooth hand-over from scorch to char \u2014 the gradient is the point.
7536
- 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);
7537
7714
  vec2 cells = plVoronoi(p / (3.6 * PL_MM));
7538
7715
  // Sparse: a real crack network is broken, not a tiled floor \u2014 most cell
7539
7716
  // borders never split.
7540
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)));
7541
7718
  float plate = plHash(floor(p / (3.6 * PL_MM)) + 3.7);
7542
- // Dark orange where the char meets the scorch, deep brown toward the cut \u2014
7543
- // warm all the way, and fading smoothly into the scorch's lighter browns
7544
- // rather than stopping at a grey band.
7545
- // Warm (dark orange \u2192 deep brown) or, turned down, the sampled greys.
7546
- vec3 body = mix(plLinear(vec3(0.255, 0.239, 0.227)), plLinear(vec3(0.165, 0.086, 0.043)), uLook1.w);
7547
- 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);
7548
7728
  vec3 cracks = plLinear(vec3(0.039, 0.024, 0.016)); // #0A0604
7549
- 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);
7550
7732
  // Mottled at the plate scale and finer, and darker than the sampled body
7551
7733
  // under a bright key \u2014 the reference's char reads near black in the frame.
7552
7734
  charColor *= (0.75 + 0.35 * plate) * (0.8 + 0.4 * plNoise(p * 260.0));
@@ -7558,42 +7740,42 @@ void plDamage(inout vec4 color, inout float roughness) {
7558
7740
  plHeight += charZone * ((plNoise(p * 380.0) - 0.5) * 0.00008 + (plate - 0.5) * 0.00018 - crack * 0.00016);
7559
7741
 
7560
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
7561
- // Inside the spec's zones (\xA75): the lip 0.5\u20131.5 mm, then the ember line
7562
- // 0.3\u20131 mm \u2014 2.5 mm from the cut at most, which the gate holds it to.
7563
- // Along the rim. Both the lip and the ember line below are laid out on this,
7564
- // and unlike the scorch fingers above it is safe to sample noise in: it is
7565
- // used ONLY within a couple of millimetres of the cut, where the presence
7566
- // gradient is the actual edge and points somewhere real. The fingers'
7567
- // mistake was carrying the same idea out into flat char, where the gradient
7568
- // is rounding error and a noise sampled in its frame draws the contours.
7569
- vec2 tangentP = length(gradP) > 1e-4 ? normalize(vec2(-gradP.y, gradP.x)) : vec2(1.0, 0.0);
7570
-
7571
- float lipWidth = mix(0.4, max(uLook1.y, 0.41), plFbm(p / (2.5 * PL_MM)));
7572
- // Broken, not a hem. Ash flakes off a cooling edge in pieces, so the lip
7573
- // comes and goes along the rim and wanders in width where it is there. It
7574
- // used to be a continuous band of even width with a highlight down the
7575
- // middle of it, which read as a neon tube round the hole \u2014 the single most
7576
- // synthetic thing at wide view.
7577
- float lipAlong = dot(p, tangentP) / (3.2 * PL_MM);
7578
- float lipBreak = smoothstep(0.34, 0.56, plFbm(vec2(lipAlong, 0.9)));
7579
- float lip = (1.0 - smoothstep(lipWidth - aa, lipWidth + aa, mm)) * step(d.a, 0.999) * cutNear * lipBreak;
7580
- 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;
7581
7757
  color.rgb = mix(color.rgb, ash, lip);
7582
7758
  roughness = mix(roughness, 0.99, lip);
7583
- // Barely raised. THIS is where the highlight came from: a 0.7 mm ridge
7584
- // running round the hole is a cylinder, and a cylinder under a key light
7585
- // has a specular line down it however rough the surface is. Ash is a
7586
- // fragile crust a fraction of a millimetre proud of the paper, not a bead
7587
- // of solder \u2014 and the shape of it belongs to the broken edge above, not to
7588
- // a smooth ramp.
7589
- 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;
7590
7772
 
7591
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
7592
7774
  // Beads along the cut: position along the edge, crawling on the burn's
7593
7775
  // clock, with a flicker of their own. Only where it is hot \u2014 and as the
7594
7776
  // heat goes the threshold rises, so the line breaks into fewer, dimmer
7595
7777
  // beads and they go out one by one.
7596
- 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;
7597
7779
  float bead = plNoise(vec2(s1, 0.37)) * 0.7 + plNoise(vec2(s1 * 2.3, 5.1)) * 0.3;
7598
7780
  float flicker = 0.62 + 0.38 * plNoise(vec2(floor(s1) * 7.13, uDamageTime * 9.0 * uLook0.w));
7599
7781
  // Down to a smoulder's last flicker: a spent edge keeps a little heat in
@@ -7608,7 +7790,8 @@ void plDamage(inout vec4 color, inout float roughness) {
7608
7790
  float lit = smoothstep(mix(0.82, 0.45, hot) - shift, mix(0.9, 0.58, hot) - shift, bead);
7609
7791
  // Each bead tapers at its ends \u2014 a bead, not a dash cut from a strip.
7610
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);
7611
- 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;
7612
7795
  // Mostly orange: a bead is red at its ends and yellow-white only at its
7613
7796
  // hottest core, which is what Ember_line.png shows. Mapped low and pushed
7614
7797
  // high by a power, so white is the exception rather than the line.
@@ -7628,13 +7811,15 @@ void plDamage(inout vec4 color, inout float roughness) {
7628
7811
  // edge is hot, and inside the zone the beads are held to \u2014 the lip plus
7629
7812
  // the widest bead, measured from the cut \u2014 so the glow never reaches out
7630
7813
  // over paper the gate says heat must not light.
7631
- float zoneEnd = uLook1.y + uLook0.x;
7632
- 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;
7633
7816
  float patch_ = smoothstep(0.35, 0.72, plFbm(p / (4.0 * PL_MM) + vec2(uDamageTime * 0.15 * uLook0.w, 0.0)));
7634
7817
  plDamageLight += vec3(0.55, 0.06, 0.01) * seam * patch_ * hot * uLook1.x;
7635
7818
  float speck = step(0.985, plHash(floor(p / (0.35 * PL_MM)) + floor(uDamageTime * 8.0 * uLook0.w)));
7636
7819
  plDamageLight += plEmberRamp(0.9) * 1.6 * speck * seam * hot * uLook3.z;
7637
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));
7638
7823
  plDamageCut(color, d);
7639
7824
  }
7640
7825
  `
@@ -7745,6 +7930,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7745
7930
  chunks.push(DAMAGE_CHUNK, DAMAGE_SHADE_CHUNK);
7746
7931
  calls.push("plDamage(csm_DiffuseColor, csm_Roughness);");
7747
7932
  uniforms.uDamage = { value: null };
7933
+ uniforms.uDamageEdge = { value: null };
7748
7934
  uniforms.uDamageDetail = { value: 1 };
7749
7935
  uniforms.uDamageTime = { value: 0 };
7750
7936
  const look = DAMAGE_LOOK_DEFAULTS;
@@ -7757,7 +7943,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7757
7943
  uniforms.uLook2 = {
7758
7944
  value: new THREE9.Vector4(look.charCracks, look.scorchReach, look.scorchDarkness, look.fingers)
7759
7945
  };
7760
- 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) };
7761
7947
  }
7762
7948
  const cutting = [];
7763
7949
  const cuts = [];
@@ -7861,35 +8047,65 @@ function useLightRig(own) {
7861
8047
  var THREE10 = __toESM(require("three"), 1);
7862
8048
  var import_fiber4 = require("@react-three/fiber");
7863
8049
  var import_react9 = require("react");
7864
- function useDamageTexture(source) {
7865
- 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)(() => {
7866
8054
  if (!source) return null;
7867
- const t = new THREE10.DataTexture(
8055
+ const damage = new THREE10.DataTexture(
7868
8056
  source.pixels,
7869
8057
  source.size,
7870
8058
  source.size,
7871
8059
  THREE10.RGBAFormat,
7872
8060
  THREE10.UnsignedByteType
7873
8061
  );
7874
- t.magFilter = THREE10.LinearFilter;
7875
- t.minFilter = THREE10.LinearFilter;
7876
- t.wrapS = THREE10.ClampToEdgeWrapping;
7877
- t.wrapT = THREE10.ClampToEdgeWrapping;
7878
- t.generateMipmaps = false;
7879
- t.colorSpace = THREE10.NoColorSpace;
7880
- t.flipY = false;
7881
- t.userData.version = source.version;
7882
- t.needsUpdate = true;
7883
- return t;
7884
- }, [source]);
7885
- (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
+ );
7886
8091
  (0, import_fiber4.useFrame)(() => {
7887
- if (!source || !texture) return;
7888
- if (texture.userData.version === source.version) return;
7889
- texture.userData.version = source.version;
7890
- 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);
7891
8097
  });
7892
- 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;
7893
8109
  }
7894
8110
 
7895
8111
  // src/surface/PaperMaterial.tsx
@@ -7906,7 +8122,7 @@ function PaperMaterial({
7906
8122
  damage
7907
8123
  }) {
7908
8124
  const rig = useLightRig(lighting);
7909
- const damageTexture = useDamageTexture(damage);
8125
+ const damageTextures = useDamageTexture(damage, sheet2);
7910
8126
  const composed = composeSurface(
7911
8127
  surface,
7912
8128
  stock,
@@ -7914,7 +8130,7 @@ function PaperMaterial({
7914
8130
  {
7915
8131
  hasFrontMap: Boolean(texture),
7916
8132
  hasBackMap: Boolean(backTexture),
7917
- hasDamage: Boolean(damageTexture)
8133
+ hasDamage: Boolean(damageTextures)
7918
8134
  },
7919
8135
  sheet2,
7920
8136
  rig,
@@ -7923,7 +8139,7 @@ function PaperMaterial({
7923
8139
  const bound = (0, import_react10.useMemo)(() => composed.uniforms, [composed.structureKey]);
7924
8140
  (0, import_react10.useEffect)(() => {
7925
8141
  for (const [key, uniform] of Object.entries(composed.uniforms)) {
7926
- 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")) {
7927
8143
  continue;
7928
8144
  }
7929
8145
  if (bound[key].value instanceof THREE11.Color && uniform.value instanceof THREE11.Color) {
@@ -7937,8 +8153,9 @@ function PaperMaterial({
7937
8153
  (0, import_react10.useEffect)(() => {
7938
8154
  if (bound.uFrontMap) bound.uFrontMap.value = texture;
7939
8155
  if (bound.uBackMap) bound.uBackMap.value = backTexture ?? null;
7940
- if (bound.uDamage) bound.uDamage.value = damageTexture;
7941
- }, [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]);
7942
8159
  (0, import_fiber5.useFrame)((state) => {
7943
8160
  if (!bound.uDamageDetail) return;
7944
8161
  if (bound.uDamageTime) {
@@ -7971,7 +8188,7 @@ function PaperMaterial({
7971
8188
  v2(look.edgeWave, d.edgeWave),
7972
8189
  v2(look.edgeBite, d.edgeBite),
7973
8190
  v2(look.sparkle, d.sparkle),
7974
- 0
8191
+ v2(look.charWidth, d.charWidth)
7975
8192
  );
7976
8193
  }
7977
8194
  const detail = damage?.detail ?? 1;