paperlab 0.8.0 → 0.8.1

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 +57 -30
  2. package/dist/{FxPostPass-V6YDK4H2.js → FxPostPass-ACFSHY3S.js} +4 -4
  3. package/dist/FxPostPass-ACFSHY3S.js.map +1 -0
  4. package/dist/{chunk-232SU3AQ.js → chunk-BIFUJCUA.js} +2 -2
  5. package/dist/{chunk-232SU3AQ.js.map → chunk-BIFUJCUA.js.map} +1 -1
  6. package/dist/{chunk-SVWWC5UL.js → chunk-LUKNPQ3S.js} +16 -17
  7. package/dist/chunk-LUKNPQ3S.js.map +1 -0
  8. package/dist/{chunk-7WOGU2D4.js → chunk-S74YZAXE.js} +2 -2
  9. package/dist/chunk-S74YZAXE.js.map +1 -0
  10. package/dist/{damageContract-nF1fgK7P.d.cts → damageContract-9RC4egJg.d.cts} +2 -2
  11. package/dist/{damageContract-nF1fgK7P.d.ts → damageContract-9RC4egJg.d.ts} +2 -2
  12. package/dist/fx.cjs +20 -20
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +42 -44
  15. package/dist/fx.d.ts +42 -44
  16. package/dist/fx.js +21 -21
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +22 -23
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +4 -4
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +6 -6
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-D_EE8GXn.d.cts → slots-DbJQfwVd.d.cts} +1 -1
  25. package/dist/{slots-D_EE8GXn.d.ts → slots-DbJQfwVd.d.ts} +1 -1
  26. package/dist/stage.cjs +23 -24
  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 +12 -12
  31. package/dist/stage.js.map +1 -1
  32. package/package.json +5 -5
  33. package/dist/FxPostPass-V6YDK4H2.js.map +0 -1
  34. package/dist/chunk-7WOGU2D4.js.map +0 -1
  35. package/dist/chunk-SVWWC5UL.js.map +0 -1
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
  [![CI](https://github.com/NourMtir0722/Paperlab/actions/workflows/ci.yml/badge.svg)](https://github.com/NourMtir0722/Paperlab/actions/workflows/ci.yml)
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/NourMtir0722/Paperlab/blob/main/LICENSE)
8
8
 
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.
9
+ A hero image that peels, a receipt that unrolls, a letter that folds, a poster rippling in wind, a gallery ring of prints — and a sheet that burns. 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/) · [with your hands](https://paperlab.nawwara.studio/hands/) (webcam) · [the fire lab](https://paperlab.nawwara.studio/fx-lab/) · [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) · [the FX Lab](https://paperlab.nawwara.studio/fx-lab/) · [for coding agents](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md)
12
12
 
13
13
  | | |
14
14
  |---|---|
@@ -27,15 +27,21 @@ Requires React ≥ 19 and three ≥ 0.162. TypeScript types ship with the packag
27
27
 
28
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
29
 
30
- Stage mode lives at the `paperlab/stage` subpath and needs two more peers:
30
+ Paperlab has three entry points, and a bundle only resolves the ones it imports:
31
+
32
+ | | |
33
+ |---|---|
34
+ | `paperlab` | `<Paper>`, `<PaperMesh>` and `<PaperField>` — the sheet, and many sheets |
35
+ | `paperlab/stage` | `<PaperStage>` — paper as architecture |
36
+ | `paperlab/fx` | what happens **to** the paper — the fire, and everything it throws off |
37
+
38
+ Stage mode and the fire's glow need two more peers:
31
39
 
32
40
  ```sh
33
41
  npm i @react-three/postprocessing postprocessing
34
42
  ```
35
43
 
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.
44
+ They are genuinely optional, in both directions. `<Paper>` and `<PaperField>` never reach for them, so a bundle that does not import `paperlab/stage` or `paperlab/fx` contains none of it — the subpath keeps the import specifier itself out of your build graph. And both load their print pass on demand rather than at module scope, so they render without the peers too: you lose the tone curve, bloom, vignette and grain, and the console says so once.
39
45
 
40
46
  ## Quick start
41
47
 
@@ -112,6 +118,21 @@ A fold that closes past 45° at a line that stays put leaves a crease behind at
112
118
  <Paper preset="letter-fold" memory={{ set: 0.6 }} onCrease={save} />
113
119
  ```
114
120
 
121
+ ### Fire — paper that burns
122
+
123
+ `<Paper damage={source}>` hands a sheet a grid of char, water, heat and missing paper over its own UV, and the sheet draws it without knowing what caused it — the same split `content` already has. So a live simulation, a baked texture and a recorded burn played back all work the same way.
124
+
125
+ A burnt edge is drawn in the layers a real one has, measured in millimetres from the cut: a pale ash lip, a beaded ember line (the only part of the sheet that gives off light), a black char band, and a scorch with a steep edge into clean paper. On a cloth sheet the same grid reaches the physics too — char shrinks the paper and curls it toward the flame, wet paper gets heavier, and paper that has burnt away leaves the simulation, so a piece the fire cuts loose falls away with its own burnt edge.
126
+
127
+ What *causes* the damage lives in `paperlab/fx`:
128
+
129
+ - **`DamageField`** — heat, water, char and presence, simulated on the CPU with the paper's grain and fibre direction in it. It is untiered, so the same seed burns the same way on any device, and it burns at the speed real paper does: a few millimetres a second.
130
+ - **`FxFireFluid`** — the flames: a small GPU fire simulation fed from the hot rim in uneven clusters. Fuel burns where air reaches it, so a tongue has a pale core, a yellow body and a torn tip, and every zone of it is an option.
131
+ - **`FxFireLight`** — the burn is a light source. It lights the sheet, glows through it, and through `DamageSource.firelight` the room it burns in dims around it.
132
+ - **The rest of a fire** — embers, smoke and ash (`FxParticles`), a match held in a hand (`FxMatchFlame`), the thread of smoke from a smouldering bead (`FxWisps`), the embers left after the flames (`Afterglow`), and the bloom and heat haze (`FxPost`).
133
+
134
+ Every default is a tune made in the [FX Lab](https://paperlab.nawwara.studio/fx-lab/), and every one of them is an option you can set. See [AGENTS.md](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) for the wiring.
135
+
115
136
  ### Layouts — 12
116
137
 
117
138
  ![All twelve field layouts rendered side by side](https://paperlab.nawwara.studio/media/layouts.jpg)
@@ -156,19 +177,20 @@ And it is navigable rather than a video. It drifts on its own until you touch it
156
177
 
157
178
  - **Content** — `blank`, `image`, `text`, `card` and `receipt`, any of which can also sit on the reverse of the sheet via `content.back`. Text is measured and wrapped with real tracking applied *before* measurement, so the painted line matches the line it was broken to.
158
179
  - **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.
180
+ - **A floor** — `scene.floor` puts dark, matte ground under the paper. The contact shadow, a simulated sheet and anything the paper lets go of all meet at the same height, so a falling piece lands on the thing casting its shadow instead of through it.
159
181
  - **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.
160
182
  - **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.
161
183
  - **Presets** — 18 paper presets and 6 stage presets, and everything serializes to `.paper` JSON validated by a zod schema. Diffable, forkable, shareable.
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).
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.
184
+ - **Agent-first export** — the editor's **Copy for AI** 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).
185
+ - **Accessible by default** — `prefers-reduced-motion` freezes behaviors at their pose and disables physics, entrances and the fire's simulations, 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.
164
186
 
165
- The zod schema in `config/schema.ts` is the single source of truth: it validates the API, generates the editor's controls, defines the file format and feeds the docs. If a feature can't serialize into a preset, it doesn't ship.
187
+ The zod schema in `config/schema.ts` is the single source of truth: it validates the API, generates the editor's controls, defines the file format and feeds the docs. If a feature can't serialize into a preset, it doesn't ship. (Damage is the one deliberate exception: it is live state, so it is a prop and never part of a preset or a share link.)
166
188
 
167
189
  ## Papers are made to be passed around
168
190
 
169
191
  A paper is data — a `.paper` JSON object validated by a zod schema — so it travels without asking anyone's permission. **You do not need to fork this repo to share one.**
170
192
 
171
- **Sending one.** Sculpt a paper in the [editor](https://paperlab.nawwara.studio/editor/) and hit **Share**: you get a link with the whole paper packed into it. Anyone who opens that link lands in their own editor with your paper loaded and *editable* — a fork, not a read-only view. (Uploaded images are too big for a URL; use the ⬇ download and send the `.paper` file instead.)
193
+ **Sending one.** Sculpt a paper in the [editor](https://paperlab.nawwara.studio/editor/) and pick **Export → Copy share link**: you get a link with the whole paper packed into it. Anyone who opens that link lands in their own editor with your paper loaded and *editable* — a fork, not a read-only view. (Uploaded images are too big for a URL; use the ⬇ download and send the `.paper` file instead.)
172
194
 
173
195
  **Receiving one.** Open the link, or drag a `.paper` file onto the preset panel. Either way it lands in your library next to the built-ins.
174
196
 
@@ -193,11 +215,11 @@ That's the whole loop: **make → send → remix → ship.** If you'd rather you
193
215
 
194
216
  ## The apps
195
217
 
196
- Four surfaces ship alongside the library, all built on its public API only.
218
+ Three sites ship alongside the library, all built on its public API only.
197
219
 
198
220
  **[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.
199
221
 
200
- **[The editor](https://paperlab.nawwara.studio/editor/)** — a three-rail canvas tool: presets on the left, sculpt on canvas, inspector on the right, transport at the bottom (space = play/pause), undo and redo on ⌘Z.
222
+ **[The editor](https://paperlab.nawwara.studio/editor/)** — a three-rail canvas tool: presets on the left, sculpt on canvas, inspector on the right, transport at the bottom (space = play/pause), undo and redo on ⌘Z. One switch along the top moves between five surfaces that all wear that same frame — **Paper · Field · Stage · Hands · FX Lab** — and the top-right holds a single button, **Export**.
201
223
 
202
224
  ![The Paperlab editor in paper mode, showing a thermal receipt on the canvas with the inspector open](https://paperlab.nawwara.studio/media/editor.jpg)
203
225
 
@@ -205,52 +227,56 @@ The inspector is generated from the zod schema, so it can never drift from the A
205
227
 
206
228
  ![The editor in field mode, fourteen cards arranged in a ring with the layout panel open](https://paperlab.nawwara.studio/media/editor-field.jpg)
207
229
 
208
- Field mode composes galleries against the same panel — swap the layout, watch fourteen papers rearrange in one draw call. **Export code** ends the session in your codebase, and **Copy for AI** ends it in a coding agent's. It wants a real screen: under about 900px it says so and points you at the playground.
209
-
210
- **[The fire lab](https://paperlab.nawwara.studio/fx-lab/)** — every knob behind the burn, on one page: the flame's zones, the fluid, the bloom, the light it throws, the ash lip and the char, how fast it eats. The library's defaults are a tune made here. Scrub the burn, turn something, copy the tune out as JSON and pass it to `<Paper>`.
230
+ Field mode composes galleries against the same panel — swap the layout, watch fourteen papers rearrange in one draw call. **Export** ends the session wherever you are going next: a share link, an image framed for a post, a story or a slide, the component or its JSON for your codebase, or **Copy for AI** for a coding agent. It wants a real screen: under about 900px it says so and points you at the playground.
211
231
 
212
- **[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.
213
-
214
- **[Your hands](https://paperlab.nawwara.studio/hands/)** — set fire to the paper with a real flame. Hold a lighter up to the camera and the sheet catches where the flame is; with no lighter, pinch and hold still and you are holding a match. The page draws your hand as it tracks it and boxes the flame when it finds one, and once a flame touches the paper the fire runs from there until the whole sheet has burnt. Blow to put it out. One feature, done properly: it burns the way the fire lab burns, from the same defaults, and the page is a hundred percent public API — `packages/paperlab` doesn't know it exists.
232
+ - **[FX Lab](https://paperlab.nawwara.studio/fx-lab/)** — every knob behind the burn, around the sheet it is burning: the flame's zones, the fluid, the bloom, the light it throws, the ash lip and the char, how fast it eats and how much of the sheet it takes. The library's defaults are a tune made here. Scrub the burn, turn something, and **Export** copies the tune out as JSON to pass to `<Paper>`.
233
+ - **[Hands](https://paperlab.nawwara.studio/hands/)** — set fire to the paper with a real flame. Hold a lighter up to the camera and the sheet catches where the flame is; with no lighter, pinch and hold still and you are holding a match; with no camera, there is a button. The page draws your hand as it tracks it and boxes the flame when it finds one, and once a flame touches the paper the fire runs from there until the whole sheet has burnt. Blow to put it out. It burns the way the FX Lab burns, from the same defaults, and the page is a hundred percent public API — `packages/paperlab` doesn't know it exists.
215
234
 
216
235
  The flame detector is arithmetic over the camera's pixels, not a model: a flame is bright, warm in the order red-green-blue, and never still, and the last of those three is what keeps a desk lamp from lighting your paper. The hand 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.
217
236
 
237
+ **[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.
238
+
218
239
  ## Development
219
240
 
220
241
  pnpm + Turborepo, Node 22, [Biome](https://biomejs.dev) for lint and format.
221
242
 
222
243
  ```sh
223
244
  pnpm install
245
+ pnpm hands:setup # once — vendors the hand tracker's wasm and models (35 MB, not committed)
224
246
  pnpm dev # the editor at localhost:5173
225
247
  ```
226
248
 
227
249
  | | |
228
250
  |---|---|
229
251
  | [`packages/paperlab`](https://github.com/NourMtir0722/Paperlab/blob/main/packages/paperlab/) | the npm library — the only published artifact |
230
- | [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — every knob, and the export. Also `/hands` and `/fx-lab`, built from the same app in passes of their own |
252
+ | [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — Paper, Field and Stage, plus Hands (`/hands`) and the FX Lab (`/fx-lab`), each built from the same app in a pass of its own |
231
253
  | [`apps/playground`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/playground/) | the playground — one input, one scene, shareable by link |
232
254
  | [`apps/docs`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/docs/) | the reference site, with every behavior running live |
233
- | [`tools/`](https://github.com/NourMtir0722/Paperlab/blob/main/tools/) | browser harnesses — parity, perf, screenshots, the README's motion |
255
+ | [`tools/`](https://github.com/NourMtir0722/Paperlab/blob/main/tools/) | browser harnesses — parity, perf, screenshots, the fire's checks, the README's motion |
234
256
  | [`AGENTS.md`](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) · [`docs/llms.txt`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/llms.txt) | the agent-readable API reference |
235
- | [`docs/design.md`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/design.md) | the design language the three apps share, enforced by a test |
257
+ | [`docs/design.md`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/design.md) | the design language the apps share, enforced by a test |
236
258
 
237
259
  ### Checks
238
260
 
239
261
  ```sh
240
- pnpm test # 700+ unit tests — deformer math, schema, cloth, layouts, exports
241
- pnpm test:parity # 37 golden-vector cases: every deformer's GLSL twin vs its JS twin
242
- pnpm test:drive # the stage really walks when you drag, wheel or arrow it
243
- pnpm test:share # sculpt → link → a browser that has never seen the paper
244
- pnpm test:dropdown # every dropdown option is reachable, including below the fold
245
- pnpm test:route # the site root routes by device, and links every route it deploys
246
- pnpm test:hands # the three ways to light the paper really light it (needs a camera-less Chromium)
262
+ pnpm test # 1,100+ unit tests — deformer math, schema, cloth, layouts, the fire, exports
263
+ pnpm test:parity # 37 golden-vector cases: every deformer's GLSL twin vs its JS twin
264
+ pnpm test:drive # the stage really walks when you drag, wheel or arrow it
265
+ pnpm test:share # sculpt → link → a browser that has never seen the paper
266
+ pnpm test:dropdown # every dropdown option is reachable, including below the fold
267
+ pnpm test:route # the site root routes by device, and links every route it deploys
268
+ pnpm test:consumer # installs the packed library into an empty project and imports it
269
+ pnpm test:damage # an untouched damage field changes not one pixel
270
+ pnpm test:fire-budget # what a burn must measure in the frame — the stage stays black, paper never blooms
271
+ pnpm test:hands # the ways to light the paper really light it (needs a camera-less Chromium)
272
+ pnpm test:fire-look # the burn, moment by moment, beside the stills it is judged against
247
273
  pnpm typecheck
248
274
  pnpm lint
249
- pnpm knip # dead code and unused exports
275
+ pnpm knip # dead code and unused exports
250
276
  pnpm build
251
277
  ```
252
278
 
253
- 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.
279
+ 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` and `test:fire-look` 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. `test:fire-look` is run by hand: it lays each render beside a reference still, those stills are kept out of the repo, and passing it still takes someone looking.
254
280
 
255
281
  ### Measurement
256
282
 
@@ -262,6 +288,7 @@ pnpm shot:light # every lighting rig, one frame each
262
288
  pnpm shot:catalogue # sweep any axis — --vary=layout|stock|lighting --all
263
289
  pnpm sheet # compose those frames into a labelled contact sheet
264
290
  pnpm media # the README's GIFs and MP4s, stepped frame-exact (needs ffmpeg)
291
+ pnpm film # the burn playing, first contact to cold, one video per origin (needs ffmpeg)
265
292
  ```
266
293
 
267
294
  ## Contributing
@@ -4,8 +4,8 @@ import {
4
4
  PAPER_WHITE,
5
5
  flameAnchors,
6
6
  fxQualityFor
7
- } from "./chunk-7WOGU2D4.js";
8
- import "./chunk-232SU3AQ.js";
7
+ } from "./chunk-S74YZAXE.js";
8
+ import "./chunk-BIFUJCUA.js";
9
9
 
10
10
  // src/fx/FxPostPass.tsx
11
11
  import { Bloom, DepthOfField, EffectComposer, ToneMapping } from "@react-three/postprocessing";
@@ -61,7 +61,7 @@ void mainUv(inout vec2 uv) {
61
61
  }
62
62
 
63
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.
64
+ // more. A multiply, so black stays black.
65
65
  void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
66
66
  outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);
67
67
  }
@@ -181,4 +181,4 @@ export {
181
181
  FxPostPass,
182
182
  PAPER_WHITE
183
183
  };
184
- //# sourceMappingURL=FxPostPass-V6YDK4H2.js.map
184
+ //# sourceMappingURL=FxPostPass-ACFSHY3S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fx/FxPostPass.tsx","../src/fx/haze.ts"],"sourcesContent":["import { Bloom, DepthOfField, 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: 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 * test for this pass is that it does not: with no fire in frame,\n * post on and post off are the same picture (`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 focus = 0,\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 /** Where the fire is, for the focus to sit on it. */\n const focusTarget = useRef(new THREE.Vector3())\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 // Focus on the fire itself, as a point in the room rather than a\n // fraction of the far plane: the far plane is a thousand times the size\n // of a sheet, and a focus expressed against it lands the plane a metre\n // away and blurs everything, the rim included.\n if (focus > 0 && n > 0) {\n focusTarget.current.set(0, 0, 0)\n for (let i = 0; i < n; i++) {\n const a = anchors.current[i]!\n focusTarget.current.add(root.set(a.x, a.y, a.z))\n }\n focusTarget.current.multiplyScalar(1 / n)\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.\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, 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 {focus > 0 ? (\n <DepthOfField\n target={focusTarget.current}\n // A hand's width of sheet in focus at the gentlest, a few\n // centimetres at the strongest — the macro look of the reference\n // crops, without losing the rim the shot is about.\n focalLength={0.06 - 0.045 * focus}\n bokehScale={1 + 3 * focus}\n />\n ) : null}\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. 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, 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":"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","names":["THREE"]}
@@ -11,7 +11,7 @@ var DAMAGE_LOOK_DEFAULTS = {
11
11
  emberFlicker: 1.65,
12
12
  emberGlow: 1.25,
13
13
  sparkle: 0.5,
14
- // As wide as the char band (Noor, 2026-09-13): the lip has to be seen. At
14
+ // As wide as the char band: the lip has to be seen. At
15
15
  // 0.95 mm it was a hairline tracing the edge.
16
16
  lipWidth: 3.5,
17
17
  charWidth: 3.5,
@@ -31,4 +31,4 @@ export {
31
31
  DAMAGE_LOOK_DEFAULTS,
32
32
  DAMAGE_CHANNELS
33
33
  };
34
- //# sourceMappingURL=chunk-232SU3AQ.js.map
34
+ //# sourceMappingURL=chunk-BIFUJCUA.js.map
@@ -1 +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 * The light the damage gives off, as the room around the sheet should feel\n * it. Presentation only; the physics never sees it. Omitted, the room is\n * lit exactly as its lighting says.\n */\n readonly firelight?: DamageFirelight\n}\n\n/**\n * What a burning sheet does to the light around it.\n *\n * A fire big enough to see by is the key light while it burns, and a room's\n * own light yields to it — a sheet burning under an unchanged studio key\n * looks like a flame pasted onto a photograph. The source says by how much,\n * because only the source knows how big its fire is; the lighting does the\n * dimming, because only the lighting knows what its lights are.\n */\nexport interface DamageFirelight {\n /**\n * How much of the room's own light is left, 0..1; omitted means 1. The key,\n * the ambient fill and the studio light are all scaled by it, every frame,\n * without rebuilding anything.\n */\n readonly room?: number\n}\n\n/**\n * How much of the room's light `source` leaves, 0..1 — the one reading of\n * {@link DamageFirelight.room}, so every lighting rig reads it alike. Anything\n * that is not a number in range is the room untouched: a firelight is a\n * dimmer, and the worst a bad one may do is nothing.\n */\nexport function roomLight(source: DamageSource | null | undefined): number {\n const room = source?.firelight?.room\n if (room === undefined || !(room >= 0)) return 1\n return Math.min(1, room)\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 /** How wide the pale ash lip is, from the cut out to the ember line, mm. */\n lipWidth?: number\n /** How wide the black char band is, past the ember line, mm. */\n charWidth?: 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 — Noor's tune in the\n * lab's sidebar on 2026-09-13, and the source of truth for how a burn looks:\n * the lab starts from it, `/hands` inherits it, and a user changes it through\n * `look`. The values it replaced, and why each of those had moved, are in the\n * history of this file.\n */\nexport const DAMAGE_LOOK_DEFAULTS: Required<DamageLook> = {\n emberWidth: 1.25,\n emberIntensity: 1.05,\n emberCoverage: 0.6,\n emberFlicker: 1.65,\n emberGlow: 1.25,\n sparkle: 0.5,\n // As wide as the char band (Noor, 2026-09-13): the lip has to be seen. At\n // 0.95 mm it was a hairline tracing the edge.\n lipWidth: 3.5,\n charWidth: 3.5,\n lipBrightness: 0.76,\n charWarmth: 0.32,\n charCracks: 0.45,\n scorchReach: 9,\n scorchDarkness: 0.86,\n fingers: 1.95,\n edgeWave: 13,\n edgeBite: 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":";AAwGO,SAAS,UAAU,QAAiD;AACzE,QAAM,OAAO,QAAQ,WAAW;AAChC,MAAI,SAAS,UAAa,EAAE,QAAQ,GAAI,QAAO;AAC/C,SAAO,KAAK,IAAI,GAAG,IAAI;AACzB;AAgDO,IAAM,uBAA6C;AAAA,EACxD,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,WAAW;AAAA,EACX,SAAS;AAAA;AAAA;AAAA,EAGT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,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":[]}
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 * The light the damage gives off, as the room around the sheet should feel\n * it. Presentation only; the physics never sees it. Omitted, the room is\n * lit exactly as its lighting says.\n */\n readonly firelight?: DamageFirelight\n}\n\n/**\n * What a burning sheet does to the light around it.\n *\n * A fire big enough to see by is the key light while it burns, and a room's\n * own light yields to it — a sheet burning under an unchanged studio key\n * looks like a flame pasted onto a photograph. The source says by how much,\n * because only the source knows how big its fire is; the lighting does the\n * dimming, because only the lighting knows what its lights are.\n */\nexport interface DamageFirelight {\n /**\n * How much of the room's own light is left, 0..1; omitted means 1. The key,\n * the ambient fill and the studio light are all scaled by it, every frame,\n * without rebuilding anything.\n */\n readonly room?: number\n}\n\n/**\n * How much of the room's light `source` leaves, 0..1 — the one reading of\n * {@link DamageFirelight.room}, so every lighting rig reads it alike. Anything\n * that is not a number in range is the room untouched: a firelight is a\n * dimmer, and the worst a bad one may do is nothing.\n */\nexport function roomLight(source: DamageSource | null | undefined): number {\n const room = source?.firelight?.room\n if (room === undefined || !(room >= 0)) return 1\n return Math.min(1, room)\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 /** How wide the pale ash lip is, from the cut out to the ember line, mm. */\n lipWidth?: number\n /** How wide the black char band is, past the ember line, mm. */\n charWidth?: 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 tune made in the\n * lab's sidebar, and the source of truth for how a burn looks:\n * the lab starts from it, `/hands` inherits it, and a user changes it through\n * `look`. The values it replaced, and why each of those had moved, are in the\n * history of this file.\n */\nexport const DAMAGE_LOOK_DEFAULTS: Required<DamageLook> = {\n emberWidth: 1.25,\n emberIntensity: 1.05,\n emberCoverage: 0.6,\n emberFlicker: 1.65,\n emberGlow: 1.25,\n sparkle: 0.5,\n // As wide as the char band: the lip has to be seen. At\n // 0.95 mm it was a hairline tracing the edge.\n lipWidth: 3.5,\n charWidth: 3.5,\n lipBrightness: 0.76,\n charWarmth: 0.32,\n charCracks: 0.45,\n scorchReach: 9,\n scorchDarkness: 0.86,\n fingers: 1.95,\n edgeWave: 13,\n edgeBite: 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":";AAwGO,SAAS,UAAU,QAAiD;AACzE,QAAM,OAAO,QAAQ,WAAW;AAChC,MAAI,SAAS,UAAa,EAAE,QAAQ,GAAI,QAAO;AAC/C,SAAO,KAAK,IAAI,GAAG,IAAI;AACzB;AAgDO,IAAM,uBAA6C;AAAA,EACxD,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,WAAW;AAAA,EACX,SAAS;AAAA;AAAA;AAAA,EAGT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,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":[]}
@@ -2,7 +2,7 @@ import {
2
2
  DAMAGE_CHANNELS,
3
3
  DAMAGE_LOOK_DEFAULTS,
4
4
  roomLight
5
- } from "./chunk-232SU3AQ.js";
5
+ } from "./chunk-BIFUJCUA.js";
6
6
 
7
7
  // src/config/merge.ts
8
8
  function mergeConfig(base, override) {
@@ -1549,7 +1549,7 @@ var paperConfigSchema = z15.object({
1549
1549
  ctx.addIssue({
1550
1550
  code: z15.ZodIssueCode.custom,
1551
1551
  path: ["physics"],
1552
- message: "the strip simulation and behavior/deformers are exclusive \u2014 the roll owns the vertices, and its rows are chain nodes rather than the sheet's own grid (pick Shape OR Strip)"
1552
+ message: "the strip simulation and behavior/deformers cannot be used together. The roll owns the vertices, and its rows are chain nodes rather than the sheet's own grid (pick Shape OR Strip)"
1553
1553
  });
1554
1554
  }
1555
1555
  if (config.states) {
@@ -1571,7 +1571,7 @@ var paperConfigSchema = z15.object({
1571
1571
  ctx.addIssue({
1572
1572
  code: z15.ZodIssueCode.custom,
1573
1573
  path: ["states", "states", name, "overrides"],
1574
- message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")} \u2014 ${first.message}` : "invalid"}`
1574
+ message: `state "${name}" overrides don't validate against the paper schema: ${first ? `${first.path.join(".")}: ${first.message}` : "invalid"}`
1575
1575
  });
1576
1576
  }
1577
1577
  }
@@ -2162,7 +2162,7 @@ function getPreset(name) {
2162
2162
  }
2163
2163
  function registerPreset(name, input) {
2164
2164
  if (name in builtins) {
2165
- throw new Error(`[paperlab] "${name}" is a built-in preset \u2014 pick another name.`);
2165
+ throw new Error(`[paperlab] "${name}" is a built-in preset. Pick another name.`);
2166
2166
  }
2167
2167
  paperConfigSchema.parse(input);
2168
2168
  userPresets.set(name, input);
@@ -2711,7 +2711,7 @@ function resolveDeformerStack(raw) {
2711
2711
  if (!parsed.success) {
2712
2712
  const issue = parsed.error.issues[0];
2713
2713
  throw new Error(
2714
- `[paperlab] deformers[${i}] ("${instance.type}"): ${issue ? `${issue.path.join(".") || "options"} \u2014 ${issue.message}` : "invalid options"}`
2714
+ `[paperlab] deformers[${i}] ("${instance.type}"): ${issue ? `${issue.path.join(".") || "options"}: ${issue.message}` : "invalid options"}`
2715
2715
  );
2716
2716
  }
2717
2717
  return {
@@ -6699,7 +6699,7 @@ vec3 plScorchTint(float t) {
6699
6699
  return min(c / paper, vec3(1.0));
6700
6700
  }
6701
6701
 
6702
- /** The ember ramp (\xA75 zone 3): off \u2192 #870E03 \u2192 #A12108 \u2192 #F77E14 \u2192 #FEFBE0, hottest last. */
6702
+ /** The ember ramp: off \u2192 #870E03 \u2192 #A12108 \u2192 #F77E14 \u2192 #FEFBE0, hottest last. */
6703
6703
  vec3 plEmberRamp(float t) {
6704
6704
  vec3 c0 = plLinear(vec3(0.529, 0.055, 0.012));
6705
6705
  vec3 c1 = plLinear(vec3(0.631, 0.129, 0.031));
@@ -6729,7 +6729,7 @@ void plDamage(inout vec4 color, inout float roughness) {
6729
6729
  vec2 gradC = vec2(e.r - w.r, n.r - s.r) * 0.5 * perWorld;
6730
6730
  // Scorch reaches ahead of the char, and further UP than anywhere else:
6731
6731
  // the char a centimetre below this point, and the char a few millimetres
6732
- // around it, each lent to the scorch at a discount (\xA74.2, \xA74.5).
6732
+ // around it, each lent to the scorch at a discount.
6733
6733
  vec2 mmUv = vec2(PL_MM) / uSheetSize;
6734
6734
  float charBelow = max(texture2D(uDamage, uv - upUv * uLook2.y * mmUv).r, texture2D(uDamage, uv - upUv * 0.5 * uLook2.y * mmUv).r);
6735
6735
  // A ring of eight, averaged: four diagonal maxima drew the grid back in as
@@ -6788,7 +6788,7 @@ void plDamage(inout vec4 color, inout float roughness) {
6788
6788
  float lipEdge = uLook1.y * mix(0.7, 1.1, plFbm(vec2(dot(p, tangentR) / (5.0 * PL_MM), 1.7)));
6789
6789
  float charEnd = lipEdge + uLook0.x + uLook3.w * mix(0.8, 1.2, plFbm(vec2(dot(p, tangentR) / (7.0 * PL_MM), 4.3)));
6790
6790
  // Further above the burn than below it \u2014 hot gas rises and cooks the paper
6791
- // over it (\xA74.5) \u2014 out to the scorch's full reach straight up, and about
6791
+ // over it \u2014 out to the scorch's full reach straight up, and about
6792
6792
  // half of it below and beside.
6793
6793
  vec2 upMm = normalize(upUv * uSheetSize);
6794
6794
  float above = length(toward) > 1e-3 ? max(0.0, dot(-normalize(toward), upMm)) : 0.0;
@@ -9212,7 +9212,7 @@ function buildDisplacementGLSL(stack, sheet2) {
9212
9212
  const deformer = getDeformer(instance.type);
9213
9213
  if (!deformer.glsl) {
9214
9214
  throw new Error(
9215
- `[paperlab] Deformer "${instance.type}" has no GLSL implementation \u2014 it can't run in field mode.`
9215
+ `[paperlab] Deformer "${instance.type}" has no GLSL implementation, so it can't run in field mode.`
9216
9216
  );
9217
9217
  }
9218
9218
  const ns = `u${cap(instance.type)}${i}_`;
@@ -10120,10 +10120,9 @@ function FieldKeyboardMirror({
10120
10120
  children: [
10121
10121
  paperLabel(slot, i),
10122
10122
  carrying?.slot === i && /* @__PURE__ */ jsxs7("span", { "aria-live": "polite", children: [
10123
- " ",
10124
- "\u2014 carrying; zone ",
10123
+ ", carrying. Zone ",
10125
10124
  controller.current?.zoneIds()[carrying.zoneIndex] ?? "none",
10126
- "; Enter places, Escape returns"
10125
+ ". Enter places it, Escape puts it back."
10127
10126
  ] })
10128
10127
  ]
10129
10128
  },
@@ -10452,7 +10451,7 @@ function withoutUploads(value) {
10452
10451
  };
10453
10452
  return { value: walk(value), replaced };
10454
10453
  }
10455
- var UPLOAD_NOTE = "// Uploaded pictures cannot travel in a snippet \u2014 the paths below are\n// stand-ins, in order. Point them at your own files.";
10454
+ var UPLOAD_NOTE = "// Uploaded pictures cannot travel in a snippet, so the paths below are\n// stand-ins, in order. Point them at your own files.";
10456
10455
  function buildJsxSnippet(config) {
10457
10456
  const diffed = diffConfig(config);
10458
10457
  delete diffed.meta;
@@ -10480,7 +10479,7 @@ var BEHAVIOR_PHRASES = {
10480
10479
  settle: (o) => o.relax > 0.7 ? "lying where it landed, flat but for one corner the stiffness kept" : "just come to rest, still holding a little of the shape it fell in",
10481
10480
  carry: () => "drooping from a pinched corner, fluttering as if being carried",
10482
10481
  flight: (o) => o.path === "loop" ? "tumbling through a seamless airborne loop" : "tumbling across the scene on the wind",
10483
- crumple: (o) => o.progress < 0.3 ? "lightly handled \u2014 a few soft creases across it" : o.progress < 0.7 ? "crushed into irregular creased facets, as if screwed up and flattened out again" : "balled up in a fist"
10482
+ crumple: (o) => o.progress < 0.3 ? "lightly handled, with a few soft creases across it" : o.progress < 0.7 ? "crushed into irregular creased facets, as if screwed up and flattened out again" : "balled up in a fist"
10484
10483
  };
10485
10484
  function describeConfig(config) {
10486
10485
  const stock = getStock(config.stock);
@@ -10541,8 +10540,8 @@ export function ${name}() {
10541
10540
  give the parent an explicit height.
10542
10541
 
10543
10542
  4. Verify: run the dev server. You should see ${describeConfig(config)}.
10544
- If the canvas is blank, the parent container almost certainly has no height \u2014
10545
- give it one (this is the classic React Three Fiber integration bug, not a
10543
+ If the canvas is blank, the parent container almost certainly has no height.
10544
+ Give it one (this is the classic React Three Fiber integration bug, not a
10546
10545
  paperlab bug).
10547
10546
 
10548
10547
  Constraints: don't modify the preset values; three >= 0.160 and React 19 are
@@ -10636,4 +10635,4 @@ export {
10636
10635
  describeConfig,
10637
10636
  buildAgentPayload
10638
10637
  };
10639
- //# sourceMappingURL=chunk-SVWWC5UL.js.map
10638
+ //# sourceMappingURL=chunk-LUKNPQ3S.js.map