paperlab 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/dist/FxPostPass-M66QMVVC.js +166 -0
- package/dist/FxPostPass-M66QMVVC.js.map +1 -0
- package/dist/Grade-SBGYELKV.js +49 -0
- package/dist/Grade-SBGYELKV.js.map +1 -0
- package/dist/chunk-7TLIWPGM.js +36 -0
- package/dist/chunk-7TLIWPGM.js.map +1 -0
- package/dist/chunk-GB6BMHC3.js +811 -0
- package/dist/chunk-GB6BMHC3.js.map +1 -0
- package/dist/{chunk-HRXQTJFS.js → chunk-MZCAN3AR.js} +3629 -640
- package/dist/chunk-MZCAN3AR.js.map +1 -0
- package/dist/damageContract-DPF1YFLZ.d.cts +129 -0
- package/dist/damageContract-DPF1YFLZ.d.ts +129 -0
- package/dist/fx.cjs +4276 -0
- package/dist/fx.cjs.map +1 -0
- package/dist/fx.d.cts +1796 -0
- package/dist/fx.d.ts +1796 -0
- package/dist/fx.js +3194 -0
- package/dist/fx.js.map +1 -0
- package/dist/index.cjs +4817 -1149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +208 -32
- package/dist/index.d.ts +208 -32
- package/dist/index.js +26 -4
- package/dist/index.js.map +1 -1
- package/dist/slots-C5PqY0Q5.d.cts +2297 -0
- package/dist/slots-C5PqY0Q5.d.ts +2297 -0
- package/dist/stage.cjs +5021 -1335
- package/dist/stage.cjs.map +1 -1
- package/dist/stage.d.cts +78 -636
- package/dist/stage.d.ts +78 -636
- package/dist/stage.js +41 -61
- package/dist/stage.js.map +1 -1
- package/package.json +18 -5
- package/dist/chunk-HRXQTJFS.js.map +0 -1
- package/dist/slots-CIo7FnlY.d.cts +0 -13357
- package/dist/slots-CIo7FnlY.d.ts +0 -13357
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
A hero image that peels, a receipt that unrolls, a letter that folds, a poster rippling in wind, a gallery ring of prints. A sheet is real 3D geometry, not a CSS trick and not a video — content is a texture on a mesh that genuinely bends, so text and imagery curl with perfect continuity.
|
|
10
10
|
|
|
11
|
-
**[Try it →](https://paperlab.nawwara.studio/)** · [the editor](https://paperlab.nawwara.studio/editor/) (desktop) · [the reference](https://paperlab.nawwara.studio/docs/) · [for coding agents](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md)
|
|
11
|
+
**[Try it →](https://paperlab.nawwara.studio/)** · [the editor](https://paperlab.nawwara.studio/editor/) (desktop) · [the reference](https://paperlab.nawwara.studio/docs/) · [with your hands](https://paperlab.nawwara.studio/hands/) (webcam) · [for coding agents](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md)
|
|
12
12
|
|
|
13
13
|
| | |
|
|
14
14
|
|---|---|
|
|
@@ -25,13 +25,17 @@ npm i paperlab three @react-three/fiber gsap
|
|
|
25
25
|
|
|
26
26
|
Requires React ≥ 19 and three ≥ 0.162. TypeScript types ship with the package; both ESM and CJS builds are published.
|
|
27
27
|
|
|
28
|
+
> **npm users:** `@react-three/fiber@9.7.0` caps React at `>=19 <19.3`, and npm installs React 19.3 by default, so the line above can fail with `ERESOLVE`. Ask for a React the renderer accepts — `npm i paperlab three @react-three/fiber gsap react@19.2 react-dom@19.2` — until fiber widens its range. pnpm and yarn resolve it without complaint. This is upstream's ceiling, not Paperlab's: the library itself supports any React ≥ 19.
|
|
29
|
+
|
|
28
30
|
Stage mode lives at the `paperlab/stage` subpath and needs two more peers:
|
|
29
31
|
|
|
30
32
|
```sh
|
|
31
33
|
npm i @react-three/postprocessing postprocessing
|
|
32
34
|
```
|
|
33
35
|
|
|
34
|
-
They are optional. `<Paper>` and `<PaperField>` never reach for them, so a bundle that does not import `paperlab/stage` contains none of it —
|
|
36
|
+
They are genuinely optional, in both directions. `<Paper>` and `<PaperField>` never reach for them, so a bundle that does not import `paperlab/stage` contains none of it — the subpath keeps the import specifier itself out of your build graph. And `paperlab/stage` loads the print pass on demand rather than at module scope, so a stage renders without them too: you lose the tone curve, bloom, vignette and grain, and the console says so once.
|
|
37
|
+
|
|
38
|
+
`paperlab/fx` is the third entry point: what happens **to** the paper. A sheet takes `<Paper damage={source}>` — char, water, heat and missing paper over its own UV — and draws it, cuts its shadow with it, and on a simulated sheet shrinks, curls, weighs down and breaks the paper it describes. What *causes* damage lives behind the subpath: a field that burns, soaks and erodes; the embers, smoke and ash a burn throws off; and a fire synthesised from the same numbers the picture is drawn from. A `<Paper>`-only bundle resolves none of it.
|
|
35
39
|
|
|
36
40
|
## Quick start
|
|
37
41
|
|
|
@@ -98,6 +102,16 @@ Underneath them are seven **deformers** — `roll`, `curl`, `bend`, `fold`, `wav
|
|
|
98
102
|
|
|
99
103
|
One sheet of words, seven papers. Stock is not a colour swap: thermal takes on banding, newsprint takes grain, vellum goes translucent and lets the light through it. On top of stock sit composable surface effects — grain, torn deckle edges, crease lines, perforation, aging — as shader chunks. Alpha-affecting effects use `alphaTest` rather than blending, so shadows stay correct.
|
|
100
104
|
|
|
105
|
+
### Memory — the paper keeps what you do to it
|
|
106
|
+
|
|
107
|
+
Paper is plastic where cloth is elastic. Every deformer here is a pure function of its options, so a sheet folded to 180° and back to 0° used to come out pristine — right for cloth, wrong for the one material this library models. Now it creases.
|
|
108
|
+
|
|
109
|
+
A fold that closes past 45° at a line that stays put leaves a crease behind at `peak × set`, where `set` is how much that paper keeps: kraft holds one hard, vellum springs back. A fold whose line *travels* leaves nothing, which is why paper coming off a roll is bent at the floor rather than creased along it. Creases bend the sheet as well as marking it, they can be handed to a paper that was never folded (a letter that arrives having been folded once), and they serialize — into a preset, and down a share link.
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
<Paper preset="letter-fold" memory={{ set: 0.6 }} onCrease={save} />
|
|
113
|
+
```
|
|
114
|
+
|
|
101
115
|
### Layouts — 12
|
|
102
116
|
|
|
103
117
|

|
|
@@ -144,7 +158,7 @@ And it is navigable rather than a video. It drifts on its own until you touch it
|
|
|
144
158
|
- **Physics** — curated idle motion (`float`, `tumble`, `dangle`, `taped`, `breeze`) that composes with behaviors, and a verlet **cloth** mode: pin the top edge, add wind, grab the sheet and pull. Cloth and behaviors are mutually exclusive by schema — cloth owns the vertices.
|
|
145
159
|
- **Interaction states** — a preset can carry `states`: overrides-on-base diffs keyed `rest` / `hover` / `pressed` / `picked` / `placed`, with the triggers built in. Drag a stamp past its threshold and it tears off its sheet (the perforation edges facing its neighbours flip to torn), release it over a `<DropZone>` and it settles, release it anywhere else and it flutters home. The whole flow is reachable from the keyboard: focus a paper, Enter picks, arrows move between zones, Enter places, Escape returns it.
|
|
146
160
|
- **Hardware that holds the paper up** — thread to the ceiling or a rod across the top edge, gripped by a clip or a peg. A hung thing that shows what holds it stops reading as a rectangle that happens to float.
|
|
147
|
-
- **Presets** —
|
|
161
|
+
- **Presets** — 18 paper presets and 6 stage presets, and everything serializes to `.paper` JSON validated by a zod schema. Diffable, forkable, shareable.
|
|
148
162
|
- **Agent-first export** — the editor's **Copy for AI** button produces a self-contained brief you paste into a coding agent: install line, inlined component, placement contract, and a verification step the agent can self-check. See [AGENTS.md](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) and [docs/llms.txt](https://github.com/NourMtir0722/Paperlab/blob/main/docs/llms.txt).
|
|
149
163
|
- **Accessible by default** — `prefers-reduced-motion` freezes behaviors at their pose and disables physics and entrances, a hidden DOM mirror carries the content for screen readers and find-in-page, and a flat DOM fallback renders when WebGL isn't available.
|
|
150
164
|
|
|
@@ -179,7 +193,7 @@ That's the whole loop: **make → send → remix → ship.** If you'd rather you
|
|
|
179
193
|
|
|
180
194
|
## The apps
|
|
181
195
|
|
|
182
|
-
|
|
196
|
+
Four surfaces ship alongside the library, all built on its public API only.
|
|
183
197
|
|
|
184
198
|
**[The playground](https://paperlab.nawwara.studio/playground/)** — one input, one scene, shareable by link. Type a sentence and it builds you a room out of it. Built for a phone.
|
|
185
199
|
|
|
@@ -195,6 +209,10 @@ Field mode composes galleries against the same panel — swap the layout, watch
|
|
|
195
209
|
|
|
196
210
|
**[The reference](https://paperlab.nawwara.studio/docs/)** — the whole catalogue with every behavior, deformer, layout, stock and surface rendering live. The catalogue is generated from the registries, so it cannot advertise something the library doesn't have.
|
|
197
211
|
|
|
212
|
+
**[Your hands](https://paperlab.nawwara.studio/hands/)** — the same paper, driven by a webcam instead of a mouse. Pinch to take hold and pull, point to score a line, make a fist to fold along it, turn your palm to change the stock, flick paint at it, blow at it to raise the wind, pull an edge to tear it. Every gesture lands on a feature the library already ships — the page is a hundred percent public API, and `packages/paperlab` doesn't know it exists.
|
|
213
|
+
|
|
214
|
+
The tracking is [MediaPipe](https://ai.google.dev/edge/mediapipe) (`@mediapipe/tasks-vision`, Apache-2.0) and it runs entirely in your browser: the models download from Google once, and after that no video and no measurement taken from it leaves the device. There is no server to send it to, and a `connect-src` CSP on the page makes that enforceable rather than a promise — including against MediaPipe's own usage telemetry, which the page blocks. Needs a camera, and asks before it takes one.
|
|
215
|
+
|
|
198
216
|
## Development
|
|
199
217
|
|
|
200
218
|
pnpm + Turborepo, Node 22, [Biome](https://biomejs.dev) for lint and format.
|
|
@@ -207,7 +225,7 @@ pnpm dev # the editor at localhost:5173
|
|
|
207
225
|
| | |
|
|
208
226
|
|---|---|
|
|
209
227
|
| [`packages/paperlab`](https://github.com/NourMtir0722/Paperlab/blob/main/packages/paperlab/) | the npm library — the only published artifact |
|
|
210
|
-
| [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — every knob, and the export |
|
|
228
|
+
| [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — every knob, and the export. Also the `/hands` page, built from the same app in a second pass |
|
|
211
229
|
| [`apps/playground`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/playground/) | the playground — one input, one scene, shareable by link |
|
|
212
230
|
| [`apps/docs`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/docs/) | the reference site, with every behavior running live |
|
|
213
231
|
| [`tools/`](https://github.com/NourMtir0722/Paperlab/blob/main/tools/) | browser harnesses — parity, perf, screenshots, the README's motion |
|
|
@@ -222,14 +240,15 @@ pnpm test:parity # 37 golden-vector cases: every deformer's GLSL twin vs its
|
|
|
222
240
|
pnpm test:drive # the stage really walks when you drag, wheel or arrow it
|
|
223
241
|
pnpm test:share # sculpt → link → a browser that has never seen the paper
|
|
224
242
|
pnpm test:dropdown # every dropdown option is reachable, including below the fold
|
|
225
|
-
pnpm test:route # the site root
|
|
243
|
+
pnpm test:route # the site root routes by device, and links every route it deploys
|
|
244
|
+
pnpm test:hands # scripted gestures really reach the paper (needs a camera-less Chromium)
|
|
226
245
|
pnpm typecheck
|
|
227
246
|
pnpm lint
|
|
228
247
|
pnpm knip # dead code and unused exports
|
|
229
248
|
pnpm build
|
|
230
249
|
```
|
|
231
250
|
|
|
232
|
-
Anything that needs a real GPU, real pointer events or a second browser profile is a browser harness in `tools/` rather than a unit test. All
|
|
251
|
+
Anything that needs a real GPU, real pointer events or a second browser profile is a browser harness in `tools/` rather than a unit test. All of them but `test:hands` are CI gates, along with `publint` and `are-the-types-wrong` on the published package. `test:hands` runs in [its own workflow](https://github.com/NourMtir0722/Paperlab/blob/main/.github/workflows/hands.yml) instead — on the paths that can break it, and weekly — because it fetches its models from Google and a required gate would let someone else's CDN block every unrelated PR. A red X you have to read, rather than a veto.
|
|
233
252
|
|
|
234
253
|
### Measurement
|
|
235
254
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FX_BLOOM,
|
|
3
|
+
FX_BLOOM_THRESHOLD,
|
|
4
|
+
PAPER_WHITE,
|
|
5
|
+
flameAnchors,
|
|
6
|
+
fxQualityFor
|
|
7
|
+
} from "./chunk-GB6BMHC3.js";
|
|
8
|
+
import "./chunk-7TLIWPGM.js";
|
|
9
|
+
|
|
10
|
+
// src/fx/FxPostPass.tsx
|
|
11
|
+
import { Bloom, EffectComposer, ToneMapping } from "@react-three/postprocessing";
|
|
12
|
+
import { useFrame } from "@react-three/fiber";
|
|
13
|
+
import { ToneMappingMode } from "postprocessing";
|
|
14
|
+
import { useMemo, useRef } from "react";
|
|
15
|
+
import * as THREE2 from "three";
|
|
16
|
+
|
|
17
|
+
// src/fx/haze.ts
|
|
18
|
+
import * as THREE from "three";
|
|
19
|
+
import { BlendFunction, Effect } from "postprocessing";
|
|
20
|
+
var HAZE_SOURCES = 16;
|
|
21
|
+
var FRAGMENT = (
|
|
22
|
+
/* glsl */
|
|
23
|
+
`
|
|
24
|
+
uniform vec4 uSources[${HAZE_SOURCES}];
|
|
25
|
+
uniform int uCount;
|
|
26
|
+
uniform float uTime;
|
|
27
|
+
uniform float uAmount;
|
|
28
|
+
uniform vec3 uWarm;
|
|
29
|
+
uniform float uGrade;
|
|
30
|
+
|
|
31
|
+
float hzHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
|
|
32
|
+
float hzNoise(vec2 p) {
|
|
33
|
+
vec2 i = floor(p);
|
|
34
|
+
vec2 f = fract(p);
|
|
35
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
36
|
+
return mix(mix(hzHash(i), hzHash(i + vec2(1.0, 0.0)), u.x), mix(hzHash(i + vec2(0.0, 1.0)), hzHash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Heat haze: the air above a flame is hotter than the air beside it, and
|
|
40
|
+
// light bends through the difference. A few pixels of upward-scrolling
|
|
41
|
+
// displacement, only in the column above each flame, fading with height.
|
|
42
|
+
void mainUv(inout vec2 uv) {
|
|
43
|
+
vec2 offset = vec2(0.0);
|
|
44
|
+
for (int i = 0; i < ${HAZE_SOURCES}; i++) {
|
|
45
|
+
if (i >= uCount) break;
|
|
46
|
+
vec4 s = uSources[i];
|
|
47
|
+
vec2 d = uv - s.xy;
|
|
48
|
+
float h = max(s.z, 1e-4);
|
|
49
|
+
float above = d.y / h;
|
|
50
|
+
if (above < -0.1 || above > 3.2) continue;
|
|
51
|
+
float across = abs(d.x) / (h * 0.55 + 1e-4);
|
|
52
|
+
// Around the flame's own boundary as well as above it: gentle inside the
|
|
53
|
+
// body, strongest in the hot column over it, gone by three flame heights.
|
|
54
|
+
float column = smoothstep(0.3, 0.9, above) * (1.0 - smoothstep(1.4, 3.2, above));
|
|
55
|
+
float around = smoothstep(-0.1, 0.15, above) * (1.0 - smoothstep(0.8, 1.2, above)) * 0.45;
|
|
56
|
+
float m = (1.0 - smoothstep(0.45, 1.15, across)) * max(column, around) * s.w;
|
|
57
|
+
vec2 q = vec2(uv.x * 140.0, uv.y * 90.0 - uTime * 3.0);
|
|
58
|
+
offset += (vec2(hzNoise(q), hzNoise(q + 31.7)) - 0.5) * m;
|
|
59
|
+
}
|
|
60
|
+
uv += offset * uAmount;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The grade: the whole frame warms as the fire grows \u2014 a few percent, no
|
|
64
|
+
// more (spec \xA77). A multiply, so black stays black.
|
|
65
|
+
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
|
66
|
+
outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);
|
|
67
|
+
}
|
|
68
|
+
`
|
|
69
|
+
);
|
|
70
|
+
var HazeGradeEffect = class extends Effect {
|
|
71
|
+
constructor() {
|
|
72
|
+
super("HazeGradeEffect", FRAGMENT, {
|
|
73
|
+
blendFunction: BlendFunction.NORMAL,
|
|
74
|
+
uniforms: /* @__PURE__ */ new Map([
|
|
75
|
+
["uSources", new THREE.Uniform(Array.from({ length: HAZE_SOURCES }, () => new THREE.Vector4()))],
|
|
76
|
+
["uCount", new THREE.Uniform(0)],
|
|
77
|
+
["uTime", new THREE.Uniform(0)],
|
|
78
|
+
// 1–3 px at 1080p, as a fraction of the frame.
|
|
79
|
+
["uAmount", new THREE.Uniform(2.2 / 1080)],
|
|
80
|
+
["uWarm", new THREE.Uniform(new THREE.Vector3(1.04, 1, 0.94))],
|
|
81
|
+
["uGrade", new THREE.Uniform(0)]
|
|
82
|
+
])
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
get sources() {
|
|
86
|
+
return this.uniforms.get("uSources").value;
|
|
87
|
+
}
|
|
88
|
+
set count(n) {
|
|
89
|
+
this.uniforms.get("uCount").value = n;
|
|
90
|
+
}
|
|
91
|
+
set time(t) {
|
|
92
|
+
this.uniforms.get("uTime").value = t;
|
|
93
|
+
}
|
|
94
|
+
/** 0 turns the shimmer off (the low tier) and keeps the grade. */
|
|
95
|
+
set amount(px1080) {
|
|
96
|
+
this.uniforms.get("uAmount").value = px1080 / 1080;
|
|
97
|
+
}
|
|
98
|
+
/** 0..1 — how much of the warm grade to apply. */
|
|
99
|
+
set grade(g) {
|
|
100
|
+
this.uniforms.get("uGrade").value = g;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// src/fx/FxPostPass.tsx
|
|
105
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
106
|
+
var modes = {
|
|
107
|
+
agx: ToneMappingMode.AGX,
|
|
108
|
+
neutral: ToneMappingMode.NEUTRAL,
|
|
109
|
+
filmic: ToneMappingMode.ACES_FILMIC
|
|
110
|
+
};
|
|
111
|
+
var root = new THREE2.Vector3();
|
|
112
|
+
var top = new THREE2.Vector3();
|
|
113
|
+
function FxPostPass({
|
|
114
|
+
film = "neutral",
|
|
115
|
+
quality = "medium",
|
|
116
|
+
bloom = FX_BLOOM,
|
|
117
|
+
threshold = FX_BLOOM_THRESHOLD,
|
|
118
|
+
field,
|
|
119
|
+
locate,
|
|
120
|
+
haze: hazeOverride
|
|
121
|
+
}) {
|
|
122
|
+
const { bloomScale, haze: tierHaze } = fxQualityFor(quality);
|
|
123
|
+
const hazePx = hazeOverride ?? tierHaze;
|
|
124
|
+
const haze = useMemo(() => new HazeGradeEffect(), []);
|
|
125
|
+
const anchors = useRef([]);
|
|
126
|
+
useFrame(({ camera }) => {
|
|
127
|
+
if (!field || !locate) {
|
|
128
|
+
haze.count = 0;
|
|
129
|
+
haze.grade = 0;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const n = hazePx > 0 ? flameAnchors(field, locate, HAZE_SOURCES, anchors.current) : 0;
|
|
133
|
+
const sources = haze.sources;
|
|
134
|
+
for (let i = 0; i < n; i++) {
|
|
135
|
+
const a = anchors.current[i];
|
|
136
|
+
root.set(a.x, a.y, a.z).project(camera);
|
|
137
|
+
top.set(a.x, a.y + a.height, a.z).project(camera);
|
|
138
|
+
sources[i].set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat);
|
|
139
|
+
}
|
|
140
|
+
haze.count = n;
|
|
141
|
+
haze.time = field.time;
|
|
142
|
+
haze.amount = hazePx;
|
|
143
|
+
haze.grade = Math.min(1, field.lastStats.front / 0.03);
|
|
144
|
+
});
|
|
145
|
+
return /* @__PURE__ */ jsxs(EffectComposer, { children: [
|
|
146
|
+
bloom > 0 ? /* @__PURE__ */ jsx(
|
|
147
|
+
Bloom,
|
|
148
|
+
{
|
|
149
|
+
intensity: bloom,
|
|
150
|
+
luminanceThreshold: threshold,
|
|
151
|
+
luminanceSmoothing: 0.02,
|
|
152
|
+
mipmapBlur: true,
|
|
153
|
+
resolutionScale: bloomScale
|
|
154
|
+
}
|
|
155
|
+
) : null,
|
|
156
|
+
/* @__PURE__ */ jsx("primitive", { object: haze }),
|
|
157
|
+
/* @__PURE__ */ jsx(ToneMapping, { mode: modes[film] })
|
|
158
|
+
] });
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
FX_BLOOM,
|
|
162
|
+
FX_BLOOM_THRESHOLD,
|
|
163
|
+
FxPostPass,
|
|
164
|
+
PAPER_WHITE
|
|
165
|
+
};
|
|
166
|
+
//# sourceMappingURL=FxPostPass-M66QMVVC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/fx/FxPostPass.tsx","../src/fx/haze.ts"],"sourcesContent":["import { Bloom, EffectComposer, ToneMapping } from '@react-three/postprocessing'\nimport { useFrame } from '@react-three/fiber'\nimport { ToneMappingMode } from 'postprocessing'\nimport { useMemo, useRef } from 'react'\nimport * as THREE from 'three'\nimport { FX_BLOOM, FX_BLOOM_THRESHOLD, PAPER_WHITE } from './emission'\nimport type { FxPostProps } from './FxPost'\nimport { flameAnchors, type FlameAnchor } from './flames'\nimport { HAZE_SOURCES, HazeGradeEffect } from './haze'\nimport { fxQualityFor } from './quality'\n\n/**\n * Where fire's glow comes from: bloom on the HDR frame, the heat haze and the\n * warm grade, then the tone curve.\n *\n * The ONLY file in `paperlab/fx` that imports `@react-three/postprocessing`\n * (with `haze.ts`, which only this imports), and nothing imports it\n * statically — `FxPost` reaches it through a dynamic import, so the two peers\n * stay optional the way stage's `Grade` keeps them. Importing this from\n * anywhere else would put the specifier back into fx's module graph and a\n * consumer without the peers could no longer load fx.\n *\n * Why it exists, in the words of `paperlab-fx-fire-spec.md` §0 and §7: the\n * first fire's glow was PAINT — a warm colour added to `csm_Emissive` over\n * paper that had not burnt — and red added to white makes pink. Real glow is\n * light too bright for the film: an ember is brighter than the whitest paper\n * in the frame, the lens spreads it, and the curve rolls it off to white.\n * Nothing on the sheet paints glow any more; anything that wants to glow\n * emits past paper white and this makes it bloom.\n *\n * Order is the whole argument: bloom reads the frame while it is still HDR —\n * which is what lets a threshold mean \"brighter than paper\" — the haze bends\n * the light above the flames and the grade warms the frame, and the curve\n * lands the result last.\n *\n * **The composer takes the tone curve off the renderer, so this gives it\n * back.** `<EffectComposer>` sets `gl.toneMapping = NoToneMapping` while it is\n * mounted; without the `<ToneMapping>` below, mounting this pass would\n * silently throw away the rig's film and the unburnt sheet would change. The\n * spec's own test for this pass is that it does not: with no fire in frame,\n * post on and post off are the same picture (§14.3, `pnpm test:fire-look`).\n */\n\nconst modes = {\n agx: ToneMappingMode.AGX,\n neutral: ToneMappingMode.NEUTRAL,\n filmic: ToneMappingMode.ACES_FILMIC,\n} as const\n\nconst root = new THREE.Vector3()\nconst top = new THREE.Vector3()\n\nexport function FxPostPass({\n film = 'neutral',\n quality = 'medium',\n bloom = FX_BLOOM,\n threshold = FX_BLOOM_THRESHOLD,\n field,\n locate,\n haze: hazeOverride,\n}: FxPostProps) {\n const { bloomScale, haze: tierHaze } = fxQualityFor(quality)\n const hazePx = hazeOverride ?? tierHaze\n const haze = useMemo(() => new HazeGradeEffect(), [])\n const anchors = useRef<FlameAnchor[]>([])\n\n // The flames, in screen space, for the haze; the front, for the grade.\n useFrame(({ camera }) => {\n if (!field || !locate) {\n haze.count = 0\n haze.grade = 0\n return\n }\n const n = hazePx > 0 ? flameAnchors(field, locate, HAZE_SOURCES, anchors.current) : 0\n const sources = haze.sources\n for (let i = 0; i < n; i++) {\n const a = anchors.current[i]!\n root.set(a.x, a.y, a.z).project(camera)\n top.set(a.x, a.y + a.height, a.z).project(camera)\n sources[i]!.set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat)\n }\n haze.count = n\n haze.time = field.time\n haze.amount = hazePx\n // A few percent at most, and only as the fire grows (§7).\n haze.grade = Math.min(1, field.lastStats.front / 0.03)\n })\n\n return (\n <EffectComposer>\n {bloom > 0 ? (\n <Bloom\n intensity={bloom}\n luminanceThreshold={threshold}\n // Nearly a hard knee. A wide one reaches BELOW the threshold, and\n // below it is paper: the whole point of the threshold is that paper\n // never blooms (§7), and a soft knee would bloom it a little.\n luminanceSmoothing={0.02}\n mipmapBlur\n resolutionScale={bloomScale}\n />\n ) : null}\n <primitive object={haze} />\n <ToneMapping mode={modes[film]} />\n </EffectComposer>\n )\n}\n\n/**\n * Re-exported, not defined here.\n *\n * It moved to `emission.ts` because it is half of a unit — \"brighter than\n * paper\" is only meaningful beside the paper white it is measured against,\n * and every emitter now authors itself in multiples of that same number. It\n * also could not be public from here: this file is the only one in\n * `paperlab/fx` allowed to import `postprocessing` (see `boundary.test.ts`),\n * so anything exported from it is unreachable for a consumer who has not\n * installed the optional peers.\n */\nexport { FX_BLOOM, FX_BLOOM_THRESHOLD, PAPER_WHITE }\n","import * as THREE from 'three'\nimport { BlendFunction, Effect } from 'postprocessing'\n\n/** How many flames the haze can shimmer above at once. */\nexport const HAZE_SOURCES = 16\n\nconst FRAGMENT = /* glsl */ `\nuniform vec4 uSources[${HAZE_SOURCES}];\nuniform int uCount;\nuniform float uTime;\nuniform float uAmount;\nuniform vec3 uWarm;\nuniform float uGrade;\n\nfloat hzHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }\nfloat hzNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hzHash(i), hzHash(i + vec2(1.0, 0.0)), u.x), mix(hzHash(i + vec2(0.0, 1.0)), hzHash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// Heat haze: the air above a flame is hotter than the air beside it, and\n// light bends through the difference. A few pixels of upward-scrolling\n// displacement, only in the column above each flame, fading with height.\nvoid mainUv(inout vec2 uv) {\n vec2 offset = vec2(0.0);\n for (int i = 0; i < ${HAZE_SOURCES}; i++) {\n if (i >= uCount) break;\n vec4 s = uSources[i];\n vec2 d = uv - s.xy;\n float h = max(s.z, 1e-4);\n float above = d.y / h;\n if (above < -0.1 || above > 3.2) continue;\n float across = abs(d.x) / (h * 0.55 + 1e-4);\n // Around the flame's own boundary as well as above it: gentle inside the\n // body, strongest in the hot column over it, gone by three flame heights.\n float column = smoothstep(0.3, 0.9, above) * (1.0 - smoothstep(1.4, 3.2, above));\n float around = smoothstep(-0.1, 0.15, above) * (1.0 - smoothstep(0.8, 1.2, above)) * 0.45;\n float m = (1.0 - smoothstep(0.45, 1.15, across)) * max(column, around) * s.w;\n vec2 q = vec2(uv.x * 140.0, uv.y * 90.0 - uTime * 3.0);\n offset += (vec2(hzNoise(q), hzNoise(q + 31.7)) - 0.5) * m;\n }\n uv += offset * uAmount;\n}\n\n// The grade: the whole frame warms as the fire grows — a few percent, no\n// more (spec §7). A multiply, so black stays black.\nvoid mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {\n outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);\n}\n`\n\n/**\n * Heat haze above the flames, and the warm grade of a frame with a fire in\n * it — spec §7's last two items, as one pass because both are cheap and both\n * run on the HDR frame before the tone curve.\n *\n * Fed each frame by `FxPostPass`: `sources` holds up to {@link HAZE_SOURCES}\n * flames as (screen u, screen v, flame height in screen v, strength).\n */\nexport class HazeGradeEffect extends Effect {\n constructor() {\n super('HazeGradeEffect', FRAGMENT, {\n blendFunction: BlendFunction.NORMAL,\n uniforms: new Map<string, THREE.Uniform>([\n ['uSources', new THREE.Uniform(Array.from({ length: HAZE_SOURCES }, () => new THREE.Vector4()))],\n ['uCount', new THREE.Uniform(0)],\n ['uTime', new THREE.Uniform(0)],\n // 1–3 px at 1080p, as a fraction of the frame.\n ['uAmount', new THREE.Uniform(2.2 / 1080)],\n ['uWarm', new THREE.Uniform(new THREE.Vector3(1.04, 1.0, 0.94))],\n ['uGrade', new THREE.Uniform(0)],\n ]),\n })\n }\n\n get sources(): THREE.Vector4[] {\n return this.uniforms.get('uSources')!.value as THREE.Vector4[]\n }\n\n set count(n: number) {\n this.uniforms.get('uCount')!.value = n\n }\n\n set time(t: number) {\n this.uniforms.get('uTime')!.value = t\n }\n\n /** 0 turns the shimmer off (the low tier) and keeps the grade. */\n set amount(px1080: number) {\n this.uniforms.get('uAmount')!.value = px1080 / 1080\n }\n\n /** 0..1 — how much of the warm grade to apply. */\n set grade(g: number) {\n this.uniforms.get('uGrade')!.value = g\n }\n}\n"],"mappings":";;;;;;;;;;AAAA,SAAS,OAAO,gBAAgB,mBAAmB;AACnD,SAAS,gBAAgB;AACzB,SAAS,uBAAuB;AAChC,SAAS,SAAS,cAAc;AAChC,YAAYA,YAAW;;;ACJvB,YAAY,WAAW;AACvB,SAAS,eAAe,cAAc;AAG/B,IAAM,eAAe;AAE5B,IAAM;AAAA;AAAA,EAAsB;AAAA,wBACJ,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,wBAoBZ,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkC7B,IAAM,kBAAN,cAA8B,OAAO;AAAA,EAC1C,cAAc;AACZ,UAAM,mBAAmB,UAAU;AAAA,MACjC,eAAe,cAAc;AAAA,MAC7B,UAAU,oBAAI,IAA2B;AAAA,QACvC,CAAC,YAAY,IAAU,cAAQ,MAAM,KAAK,EAAE,QAAQ,aAAa,GAAG,MAAM,IAAU,cAAQ,CAAC,CAAC,CAAC;AAAA,QAC/F,CAAC,UAAU,IAAU,cAAQ,CAAC,CAAC;AAAA,QAC/B,CAAC,SAAS,IAAU,cAAQ,CAAC,CAAC;AAAA;AAAA,QAE9B,CAAC,WAAW,IAAU,cAAQ,MAAM,IAAI,CAAC;AAAA,QACzC,CAAC,SAAS,IAAU,cAAQ,IAAU,cAAQ,MAAM,GAAK,IAAI,CAAC,CAAC;AAAA,QAC/D,CAAC,UAAU,IAAU,cAAQ,CAAC,CAAC;AAAA,MACjC,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,UAA2B;AAC7B,WAAO,KAAK,SAAS,IAAI,UAAU,EAAG;AAAA,EACxC;AAAA,EAEA,IAAI,MAAM,GAAW;AACnB,SAAK,SAAS,IAAI,QAAQ,EAAG,QAAQ;AAAA,EACvC;AAAA,EAEA,IAAI,KAAK,GAAW;AAClB,SAAK,SAAS,IAAI,OAAO,EAAG,QAAQ;AAAA,EACtC;AAAA;AAAA,EAGA,IAAI,OAAO,QAAgB;AACzB,SAAK,SAAS,IAAI,SAAS,EAAG,QAAQ,SAAS;AAAA,EACjD;AAAA;AAAA,EAGA,IAAI,MAAM,GAAW;AACnB,SAAK,SAAS,IAAI,QAAQ,EAAG,QAAQ;AAAA,EACvC;AACF;;;ADTI,SAEI,KAFJ;AA9CJ,IAAM,QAAQ;AAAA,EACZ,KAAK,gBAAgB;AAAA,EACrB,SAAS,gBAAgB;AAAA,EACzB,QAAQ,gBAAgB;AAC1B;AAEA,IAAM,OAAO,IAAU,eAAQ;AAC/B,IAAM,MAAM,IAAU,eAAQ;AAEvB,SAAS,WAAW;AAAA,EACzB,OAAO;AAAA,EACP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA,MAAM;AACR,GAAgB;AACd,QAAM,EAAE,YAAY,MAAM,SAAS,IAAI,aAAa,OAAO;AAC3D,QAAM,SAAS,gBAAgB;AAC/B,QAAM,OAAO,QAAQ,MAAM,IAAI,gBAAgB,GAAG,CAAC,CAAC;AACpD,QAAM,UAAU,OAAsB,CAAC,CAAC;AAGxC,WAAS,CAAC,EAAE,OAAO,MAAM;AACvB,QAAI,CAAC,SAAS,CAAC,QAAQ;AACrB,WAAK,QAAQ;AACb,WAAK,QAAQ;AACb;AAAA,IACF;AACA,UAAM,IAAI,SAAS,IAAI,aAAa,OAAO,QAAQ,cAAc,QAAQ,OAAO,IAAI;AACpF,UAAM,UAAU,KAAK;AACrB,aAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,YAAM,IAAI,QAAQ,QAAQ,CAAC;AAC3B,WAAK,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,MAAM;AACtC,UAAI,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,MAAM;AAChD,cAAQ,CAAC,EAAG,IAAI,KAAK,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,KAAK,IAAI,IAAI,IAAI,IAAI,KAAK,KAAK,GAAG,GAAG,EAAE,IAAI;AAAA,IACrG;AACA,SAAK,QAAQ;AACb,SAAK,OAAO,MAAM;AAClB,SAAK,SAAS;AAEd,SAAK,QAAQ,KAAK,IAAI,GAAG,MAAM,UAAU,QAAQ,IAAI;AAAA,EACvD,CAAC;AAED,SACE,qBAAC,kBACE;AAAA,YAAQ,IACP;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,QACX,oBAAoB;AAAA,QAIpB,oBAAoB;AAAA,QACpB,YAAU;AAAA,QACV,iBAAiB;AAAA;AAAA,IACnB,IACE;AAAA,IACJ,oBAAC,eAAU,QAAQ,MAAM;AAAA,IACzB,oBAAC,eAAY,MAAM,MAAM,IAAI,GAAG;AAAA,KAClC;AAEJ;","names":["THREE"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/stage/Grade.tsx
|
|
2
|
+
import {
|
|
3
|
+
EffectComposer,
|
|
4
|
+
Bloom,
|
|
5
|
+
DepthOfField,
|
|
6
|
+
Vignette,
|
|
7
|
+
Noise,
|
|
8
|
+
ToneMapping
|
|
9
|
+
} from "@react-three/postprocessing";
|
|
10
|
+
import { BlendFunction, ToneMappingMode } from "postprocessing";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
var toneMappingModes = {
|
|
13
|
+
agx: ToneMappingMode.AGX,
|
|
14
|
+
neutral: ToneMappingMode.NEUTRAL,
|
|
15
|
+
filmic: ToneMappingMode.ACES_FILMIC
|
|
16
|
+
};
|
|
17
|
+
function Grade({ grade, film }) {
|
|
18
|
+
const bloom = grade.bloom > 0;
|
|
19
|
+
const depth = grade.depth > 0;
|
|
20
|
+
const vignette = grade.vignette > 0;
|
|
21
|
+
const grain = grade.grain > 0;
|
|
22
|
+
if (!bloom && !depth && !vignette && !grain) return null;
|
|
23
|
+
return /* @__PURE__ */ jsxs(EffectComposer, { children: [
|
|
24
|
+
bloom ? /* @__PURE__ */ jsx(
|
|
25
|
+
Bloom,
|
|
26
|
+
{
|
|
27
|
+
intensity: grade.bloom,
|
|
28
|
+
luminanceThreshold: grade.threshold,
|
|
29
|
+
luminanceSmoothing: 0.22,
|
|
30
|
+
mipmapBlur: true
|
|
31
|
+
}
|
|
32
|
+
) : null,
|
|
33
|
+
depth ? /* @__PURE__ */ jsx(
|
|
34
|
+
DepthOfField,
|
|
35
|
+
{
|
|
36
|
+
focusDistance: 8e-3,
|
|
37
|
+
focalLength: 0.02 + grade.depth * 0.04,
|
|
38
|
+
bokehScale: grade.depth * 2.5
|
|
39
|
+
}
|
|
40
|
+
) : null,
|
|
41
|
+
/* @__PURE__ */ jsx(ToneMapping, { mode: toneMappingModes[film] }),
|
|
42
|
+
vignette ? /* @__PURE__ */ jsx(Vignette, { offset: 0.32, darkness: grade.vignette }) : null,
|
|
43
|
+
grain ? /* @__PURE__ */ jsx(Noise, { opacity: grade.grain, blendFunction: BlendFunction.OVERLAY }) : null
|
|
44
|
+
] });
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
Grade
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=Grade-SBGYELKV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/stage/Grade.tsx"],"sourcesContent":["import {\n EffectComposer,\n Bloom,\n DepthOfField,\n Vignette,\n Noise,\n ToneMapping,\n} from '@react-three/postprocessing'\nimport { BlendFunction, ToneMappingMode } from 'postprocessing'\nimport type { FilmName } from '../config/schema'\nimport type { StageGradeConfig } from './schema'\n\n/**\n * The print pass: tone curve, bloom, vignette, grain.\n *\n * Kept in its own module for one reason — it is the ONLY file in the library\n * that imports `@react-three/postprocessing`, and nothing imports it\n * statically. `GradeLazy` reaches it through a dynamic import, which is what\n * lets the two peers stay genuinely optional; importing this module directly\n * from anywhere would put the specifier back into stage's module graph and\n * undo that. Adding an import here is fine. Importing this from anywhere but\n * `GradeLazy` is not.\n *\n * **The composer takes the tone curve away from the renderer, so this file\n * has to give it back.** `<EffectComposer>` sets `gl.toneMapping =\n * NoToneMapping` for as long as it is mounted — it has to, because tone\n * mapping belongs at the END of a post chain rather than at the end of the\n * scene pass, and a frame mapped twice is wrong twice. What that means here\n * is that mounting a composer without a `<ToneMapping>` effect silently\n * throws away `light.film` entirely: the stage's own grade would have been\n * the one thing capable of un-doing the AgX curve everything else reads.\n *\n * Why the rest of it is needed, in the order it shows up in a frame:\n *\n * - **Bloom.** The source at the end of the walk is a `meshBasicMaterial`\n * with `toneMapped: false`, deliberately, because it is light rather than\n * an object. Nothing rolls it off, so without bloom it clips to a flat\n * shape with a boundary — a lit panel hanging in the room. `Surround`\n * already spends a seven-stop alpha ramp fighting that in geometry, which\n * is the wrong layer to fight it in.\n * - **Vignette.** A frame with no edge reads as a viewport.\n * - **Grain.** The one texture the render and the subject have in common.\n */\n\n/** The rig's film, as a postprocessing mode. Mirrors `toneMappings` in PaperLighting. */\nconst toneMappingModes: Record<FilmName, ToneMappingMode> = {\n agx: ToneMappingMode.AGX,\n neutral: ToneMappingMode.NEUTRAL,\n filmic: ToneMappingMode.ACES_FILMIC,\n}\n\nexport function Grade({ grade, film }: { grade: StageGradeConfig; film: FilmName }) {\n const bloom = grade.bloom > 0\n const depth = grade.depth > 0\n const vignette = grade.vignette > 0\n const grain = grade.grain > 0\n\n // A composer is a full-screen render target and a second pass over every\n // pixel. A stage graded to nothing should not pay for one — and crucially,\n // must not MOUNT one, because an empty composer would still take the tone\n // curve off the renderer and hand back nothing.\n if (!bloom && !depth && !vignette && !grain) return null\n\n return (\n <EffectComposer>\n {/*\n Order is the whole correctness argument here.\n\n Bloom reads the scene while it is still HDR — that is what lets a\n threshold near 1.0 mean \"brighter than paper\" rather than \"brighter\n than whatever the curve happened to flatten paper to\". Tone mapping\n then lands the result in display range, and vignette and grain come\n after it because both are darkroom moves on a finished print, not\n light in the room.\n */}\n {bloom ? (\n <Bloom\n intensity={grade.bloom}\n luminanceThreshold={grade.threshold}\n luminanceSmoothing={0.22}\n mipmapBlur\n />\n ) : null}\n {/*\n Depth goes with bloom on the HDR side, before the curve, because a\n blur of tone-mapped pixels averages DISPLAY values and a blur of\n scene values averages light. Only the second one puts a bright\n highlight's glow into the soft region, which is the entire reason a\n real lens's out-of-focus areas look the way they do.\n\n `focusDistance` is normalized against the camera's far plane, and the\n stage's camera stands ON the walk looking down it — so the focal\n plane sits a little ahead of the viewer and both ends fall away.\n */}\n {depth ? (\n <DepthOfField\n // Normalized against the camera's far plane, which this scene sets\n // to 400 — so 0.008 is roughly three units out, which is where the\n // banner you are standing in front of actually is. The first pass\n // at this focused eight units away and put the focal plane in the\n // empty air past the paper, so nothing in frame was sharp.\n focusDistance={0.008}\n focalLength={0.02 + grade.depth * 0.04}\n bokehScale={grade.depth * 2.5}\n />\n ) : null}\n <ToneMapping mode={toneMappingModes[film]} />\n {vignette ? <Vignette offset={0.32} darkness={grade.vignette} /> : null}\n {/*\n OVERLAY rather than NORMAL: grain added flat lifts the blacks and\n turns a dark hall grey. Overlay leaves them where they are and puts\n the texture into the midtones, which is where film grain lives.\n */}\n {grain ? <Noise opacity={grade.grain} blendFunction={BlendFunction.OVERLAY} /> : null}\n </EffectComposer>\n )\n}\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,eAAe,uBAAuB;AAwD3C,SAYI,KAZJ;AAnBJ,IAAM,mBAAsD;AAAA,EAC1D,KAAK,gBAAgB;AAAA,EACrB,SAAS,gBAAgB;AAAA,EACzB,QAAQ,gBAAgB;AAC1B;AAEO,SAAS,MAAM,EAAE,OAAO,KAAK,GAAgD;AAClF,QAAM,QAAQ,MAAM,QAAQ;AAC5B,QAAM,QAAQ,MAAM,QAAQ;AAC5B,QAAM,WAAW,MAAM,WAAW;AAClC,QAAM,QAAQ,MAAM,QAAQ;AAM5B,MAAI,CAAC,SAAS,CAAC,SAAS,CAAC,YAAY,CAAC,MAAO,QAAO;AAEpD,SACE,qBAAC,kBAWE;AAAA,YACC;AAAA,MAAC;AAAA;AAAA,QACC,WAAW,MAAM;AAAA,QACjB,oBAAoB,MAAM;AAAA,QAC1B,oBAAoB;AAAA,QACpB,YAAU;AAAA;AAAA,IACZ,IACE;AAAA,IAYH,QACC;AAAA,MAAC;AAAA;AAAA,QAMC,eAAe;AAAA,QACf,aAAa,OAAO,MAAM,QAAQ;AAAA,QAClC,YAAY,MAAM,QAAQ;AAAA;AAAA,IAC5B,IACE;AAAA,IACJ,oBAAC,eAAY,MAAM,iBAAiB,IAAI,GAAG;AAAA,IAC1C,WAAW,oBAAC,YAAS,QAAQ,MAAM,UAAU,MAAM,UAAU,IAAK;AAAA,IAMlE,QAAQ,oBAAC,SAAM,SAAS,MAAM,OAAO,eAAe,cAAc,SAAS,IAAK;AAAA,KACnF;AAEJ;","names":[]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/surface/damageContract.ts
|
|
2
|
+
var DAMAGE_LOOK_DEFAULTS = {
|
|
3
|
+
// §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
|
|
4
|
+
// it was tuned in — a control at its limit is a report that something
|
|
5
|
+
// underneath is wrong, which in this case was a fire nothing could see.
|
|
6
|
+
emberWidth: 0.9,
|
|
7
|
+
emberIntensity: 1.45,
|
|
8
|
+
emberCoverage: 0.6,
|
|
9
|
+
emberFlicker: 1.65,
|
|
10
|
+
emberGlow: 1.25,
|
|
11
|
+
sparkle: 0.5,
|
|
12
|
+
lipWidth: 1.2,
|
|
13
|
+
// Was 1.5, its slider's ceiling, which made the ash lip brighter than the
|
|
14
|
+
// paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
|
|
15
|
+
// the sheet, not a highlight drawn on it.
|
|
16
|
+
lipBrightness: 0.85,
|
|
17
|
+
// Was 1, also a ceiling. At full warmth the char is milk chocolate —
|
|
18
|
+
// closer to cardboard than to charcoal (§5). Burnt paper keeps a little
|
|
19
|
+
// warmth in the plates and reads near black in a frame with a fire in it.
|
|
20
|
+
charWarmth: 0.3,
|
|
21
|
+
// Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
|
|
22
|
+
// at full strength the char reads as a mosaic rather than as broken plates.
|
|
23
|
+
charCracks: 0.55,
|
|
24
|
+
scorchReach: 30,
|
|
25
|
+
scorchDarkness: 1.17,
|
|
26
|
+
fingers: 1.25,
|
|
27
|
+
edgeWave: 8.5,
|
|
28
|
+
edgeBite: 3.6
|
|
29
|
+
};
|
|
30
|
+
var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
DAMAGE_LOOK_DEFAULTS,
|
|
34
|
+
DAMAGE_CHANNELS
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=chunk-7TLIWPGM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/surface/damageContract.ts"],"sourcesContent":["/**\n * What damage IS, as far as a sheet is concerned. The whole seam.\n *\n * The split is by responsibility. What has happened to the paper belongs to\n * the sheet — it is shading, alpha and eventually stiffness, and only the\n * sheet can draw or simulate those. What CAUSES it belongs to `paperlab/fx`:\n * a flame, a cup of water, a pair of scissors. The main entry draws whatever\n * damage it is handed and has no idea where it came from, which is exactly\n * how `content` already works — `<Paper>` renders a texture without knowing\n * who painted it.\n *\n * The alternative was a public plugin API for the surface composer and the\n * cloth solver, so that fx could reach in. More flexible, and it would have\n * had to be supported for years before a single effect existed to justify its\n * shape. This is one interface and four constants.\n *\n * **This file imports nothing, on purpose.** `paperlab/fx` depends on it —\n * the field is a `DamageSource` and its channel offsets are these — and the\n * boundary test allows fx to reach this one file and nothing else in the\n * library. The moment it imports three, React or the config schema, fx starts\n * dragging the library in behind it.\n */\n\n/**\n * A grid of damage over the sheet's UV, as the sheet reads it.\n *\n * Satisfied structurally by `DamageField` in `paperlab/fx`, and by anything\n * else that can produce four bytes per texel — a baked texture, a recorded\n * burn played back, a test fixture.\n */\nexport interface DamageSource {\n /** Texels along each edge. The grid is square and covers the whole sheet. */\n readonly size: number\n /**\n * RGBA per texel, 0..255, row-major from v = 0 — the channels are\n * {@link DAMAGE_CHANNELS}. Read in place, never copied: the source mutates\n * it and bumps `version`.\n *\n * Eight bits, deliberately. The simulation behind it can run in float; what\n * reaches the GPU is 16 KB per changed frame at 64², against 256 KB for a\n * 128² float texture, and it does not depend on float-texture filtering,\n * which not every phone GPU guarantees.\n */\n readonly pixels: Uint8Array\n /** Bumped whenever `pixels` changes. The sheet uploads on a change and never otherwise. */\n readonly version: number\n /**\n * How ragged a burnt or cut edge is DRAWN, 0..1; omitted means 1.\n *\n * Presentation only: per-fragment noise that moves the edge within the\n * grid's soft band, finer than the grid itself can carry. The physics reads\n * the grid and never this. It is the one part of drawing damage that costs\n * per pixel, which is why it is a number a source can turn down — `paperlab/fx`\n * sets it from its quality tier.\n */\n readonly detail?: number\n /**\n * The burn's own clock, in seconds; omitted means the frame clock.\n *\n * Presentation only. The ember line on a burning edge is beaded and alive —\n * its beads flicker and crawl — and a source that can be replayed wants\n * that motion to replay too: the same moment of the same burn should draw\n * the same beads, which the frame clock cannot promise. `DamageField`\n * hands over its simulated time.\n */\n readonly time?: number\n /**\n * How a burn is DRAWN — widths, intensities and shapes the sheet's damage\n * shading reads each frame. Presentation only; the physics never sees it.\n * Anything left out takes {@link DAMAGE_LOOK_DEFAULTS}.\n */\n readonly look?: DamageLook\n}\n\n/**\n * The knobs on what a burn looks like, in the units a person tunes by —\n * millimetres of A4 and plain multipliers. Every one is optional.\n */\nexport interface DamageLook {\n /** The ember line's widest bead, mm. */\n emberWidth?: number\n /** How bright the beads burn, × the default. */\n emberIntensity?: number\n /** How much of the edge is lit at once, 0..1. */\n emberCoverage?: number\n /** How fast the beads flicker and crawl, × the default. */\n emberFlicker?: number\n /** The dim crimson glow beside the beads, reaching into the char, 0..2. */\n emberGlow?: number\n /** Specks of glowing fibre along the edge, 0..2. */\n sparkle?: number\n /** The pale ash lip's widest point, mm. */\n lipWidth?: number\n /** How pale the ash lip is, × the sampled grey. */\n lipBrightness?: number\n /** 0 is grey char, 1 is dark orange to deep brown. */\n charWarmth?: number\n /** How visible the crack network in the char is, 0..1. */\n charCracks?: number\n /** How far the scorch reaches UP past the burn, mm. */\n scorchReach?: number\n /** How dark the scorch browns go, × the sampled ramp. */\n scorchDarkness?: number\n /** How strongly the scorch front breaks into fingers, × the default. */\n fingers?: number\n /** The burnt edge's long waves, ±mm. */\n edgeWave?: number\n /** The burnt edge's small bites in and out, ±mm. */\n edgeBite?: number\n}\n\n/**\n * What every burn is drawn with unless told otherwise — the combination Noor\n * tuned in the lab's sidebar on 2026-09-12, which is the look this ships.\n */\nexport const DAMAGE_LOOK_DEFAULTS: Required<DamageLook> = {\n // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range\n // it was tuned in — a control at its limit is a report that something\n // underneath is wrong, which in this case was a fire nothing could see.\n emberWidth: 0.9,\n emberIntensity: 1.45,\n emberCoverage: 0.6,\n emberFlicker: 1.65,\n emberGlow: 1.25,\n sparkle: 0.5,\n lipWidth: 1.2,\n // Was 1.5, its slider's ceiling, which made the ash lip brighter than the\n // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than\n // the sheet, not a highlight drawn on it.\n lipBrightness: 0.85,\n // Was 1, also a ceiling. At full warmth the char is milk chocolate —\n // closer to cardboard than to charcoal (§5). Burnt paper keeps a little\n // warmth in the plates and reads near black in a frame with a fire in it.\n charWarmth: 0.3,\n // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so\n // at full strength the char reads as a mosaic rather than as broken plates.\n charCracks: 0.55,\n scorchReach: 30,\n scorchDarkness: 1.17,\n fingers: 1.25,\n edgeWave: 8.5,\n edgeBite: 3.6,\n}\n\n/**\n * Which byte of a texel means what.\n *\n * char — scorch colour, the brown halo; on cloth, shrinkage and a curl toward the front.\n * saturation — wet darkening and smoothing; on cloth, added mass.\n * heat — how hot the paper is. Drawn only where it burns: the ember line, not the sheet.\n * presence — how much paper is there at all; below half, none is drawn.\n */\nexport const DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 } as const\n"],"mappings":";AAmHO,IAAM,uBAA6C;AAAA;AAAA;AAAA;AAAA,EAIxD,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,WAAW;AAAA,EACX,SAAS;AAAA,EACT,UAAU;AAAA;AAAA;AAAA;AAAA,EAIV,eAAe;AAAA;AAAA;AAAA;AAAA,EAIf,YAAY;AAAA;AAAA;AAAA,EAGZ,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,SAAS;AAAA,EACT,UAAU;AAAA,EACV,UAAU;AACZ;AAUO,IAAM,kBAAkB,EAAE,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,UAAU,EAAE;","names":[]}
|