Releases: NourMtir0722/Paperlab
Release list
paperlab@0.6.0
Minor Changes
-
06ff0ad: A simulation and a shape are no longer alternatives. Cloth hosts a deformer stack.
physics: 'cloth'andbehavior/deformersused to be mutually exclusive, rejected by the schema with the sim owns the vertices. It was the largest single constraint in the library. Twelve behaviors ship and exactly one of them —crumple— could be reached from a sheet anyone could touch, and only by swapping the simulation out first: the drape the sim had spent a second building was thrown away, and the crush started from a flat sheet. Everything about holding a piece of paper and then doing something to it was out of reach, because holding it and doing something to it were different modes.They compose now. The sim writes the vertices and the stack runs over what it wrote:
// Fold the sheet that is hanging there, while it hangs there. <Paper physics={{ type: 'cloth', pins: 'top-corners', wind: 0.3 }} deformers={[{ type: 'fold', options: { angle: 90, offset: 0.2, foldAngle: 120 } }]} interactive />
The change is smaller than the constraint it lifts, which is the good news and was also the reason to look: a deformer is a pure map from a point to a point. It never asked where its input came from.
applyDeformerStackalready took the base array to start from, so handing it the simulation's live particles instead of the flat rest pose is the whole of the composition. What made this an invariant rather than an omission was three copies of the same early return — in the schema, inbuildStack, and inwithMemory— and one of those had already left a note anticipating the day it stopped being the only reader.Four things had to move with it:
- The grab has to speak in rendered space. A pointer hits the sheet you can see, and with a shape running over the simulation that is not where the particles are. It now finds the nearest RENDERED vertex — which is the particle of the same index, because a deformer maps points and never reorders them — and carries the displacement at the moment of the grab as a constant offset. Exact when the grab lands, and honest as the deformation changes under it. Unchanged when nothing is deforming the sheet, where the offset is zero.
- The cloth grid honours the stack's floor.
foldneeds 48 segments to bend through rather than crease along. A shape running over a simulation is no less entitled to the grid it needs than one running over a flat sheet — still capped, because every particle is a constraint solve five times a frame. - A rebuild keeps the simulation's state. A stack arriving over a sheet rebuilds the mesh without touching anything the physics knows, so
ClothSim.adoptcarries the particles across. Otherwise the sheet snapped flat at the exact moment you tried to fold the one you were holding. memory.creasesnow bends a cloth sheet, not only shades it. A crease was always meant to be read by the geometry and the shading both; on a simulated sheet only the shading ran, because the geometry half needed a deformer stack the sheet was not allowed to have. Paper remembers a fold whether or not it is being simulated.
stripstays exclusive, and not out of caution. Cloth simulates the sheet's OWN grid, so a deformer's uv means on the sim what it means everywhere else. A strip is a 2×N ribbon whose rows are chain nodes: its uv runs along a length of paper that is partly wound on a roll, so a fold placed by uv would land somewhere the sheet is not. The schema says so in those terms now.The GPU path is unaffected, which is worth stating because it is the first question the parity gate raises. Deformers run on the GPU in field mode, and a field has no simulation in it — cloth is hero-path only. All 37 parity cases compare the same JS and GLSL twins over the same flat input they always did.
One limit to know about:
foldplaces its hinge by POSITION, not by uv, so over a draped sheet it folds along a line in space rather than along a line in the material. On a sheet that is roughly planar — which is most of what cloth does — those are the same line. On a deeply crumpled one they are not. -
06ff0ad: Creases are lit rather than painted, every surface effect is measured on the sheet instead of in UV, and the cloth learns what a hand and a gust of air actually do to paper.
Five things that were done cheaply, found by looking hard at the
/handsharness — where a camera can score a line at any angle, resize the sheet with two palms, blow at it and throw it, and so puts every one of these assumptions somewhere the built-in presets never did.A crease had no shape.
plCreasemultiplied a grey band into the albedo and added a fixed white sheen beside it. The mark therefore looked identical from every angle, under every lighting rig, and whichever way the paper had been folded — which is the one thing a crease is not, because a crease is two facets meeting at a line and swinging the sheet flips it from a dark line to a bright one. The effects now describe a HEIGHT andMeshStandardMateriallights it, throughcsm_FragNormaland Mikkelsen's surface-gradient bump. One deliberate difference from three's ownperturbNormalArb: three normalises the screen-space position derivatives, which keeps a bump map looking the same at any scale and is right for a texture. This is a real depth in world units, so the raw derivatives stay and the slope is a true one. Analytic height plus screen derivatives also anti-aliases itself — a crease shrinking below a pixel fades instead of crawling.The sign survives too. A fold toward the camera leaves paper concave from the front, so it draws as a valley, and the same crease from behind draws as the ridge it is.
CreaseShading.strengthis signed now; unsigned, a mountain and a valley were the identical smudge.Every effect measured in UV. UV divides the sheet's aspect out, so a 1.2 × 1.5 sheet is a unit square as far as the shader is concerned. Fibre came out stretched, a torn edge bit deeper into the short edge than the long one, and a crease scored at 45° rendered at 51°. All three also changed when the sheet was resized, which made the paper's own material a function of how big a piece you had cut. Grain belongs to the stock and a crease is a broken fibre; neither knows the size of the sheet. Everything now measures through
plLocal(), in the sheet's own space.That let the crease shading and the crease GEOMETRY finally agree.
CreaseShadingcarries the fold's own{ angle, offset }rather than a translation of them, so the shader evaluates the identicaldot(p, dir) - offsetthefolddeformer displaces by, and the two cannot place a line differently. The shaded width comes offCREASE_RADIUSinstead of a UV constant that agreed with it at exactly one sheet size — the mesh carries the wide hinge, the shader carries the burnished line inside it, and they add up.A grab held one particle. That is a pin, not a pinch: the sheet came to a point under the cursor and hung off the singularity.
ClothSimnow takes a patch about a centimetre across, measured across the grid so a fold that brings a far corner near the fingers cannot silently join the grip, with a smoothstep falloff — the centre held, the rim free, everything between partly both. The constraint solver's 0/1/2 weights became a real inverse mass, which reproduces them exactly for the two cases they could express and covers the rest.Letting go stopped the paper dead. A verlet particle's velocity IS the gap between its position and its last one, and the grab overwrote that gap every substep. So a sheet whipped across the frame and released came to a standstill and dropped straight down. The hand's speed is measured per second — a frame is not a fixed length and a substep is — and spent on release.
Wind was a uniform shove along +z. Every particle got the same push whichever way its patch of paper was facing, so a sheet edge-on to the wind bellied out as hard as one square to it, a folded flap was pushed the same way as the face it was folded behind, and nothing ever turned into the wind. The force on a thin surface is the air it intercepts: the relative wind along the surface normal, pushed back out along that normal. Relative earns its keep twice — a sheet already travelling with the wind stops being pushed, so a blown sheet settles at a speed instead of accelerating away; and with no wind at all the same expression is air RESISTANCE, which the sim had none of. Paper's whole character in air is that it does not fall like a stone, and it does not because a sheet falling face-down catches what it is falling through while one falling edge-down knifes past it. That fell out for free.
A small turbulent residue stays isotropic, because zero would be the textbook answer and the wrong one: a sheet lying exactly along the wind would stall in it forever, the one thing that could break the symmetry being the wind it is not feeling.
Unchanged: the GPU field path, which composes its own shader and has no simulation in it — all 37 parity cases still compare the same twins.
ClothSimis internal;grab,moveGrabandreleasekeep their signatures. -
b6b7502: New
stripphysics: a roll paying paper out as the page scrolls, and the pile it makes when it lands.unrolland thepaper-rollpreset draw a roll with a deformer stack, and for paper that never reaches the ground that is still the cheaper and better answer. This is the half geometry cannot reach. A deformer bends a sheet along a curve you have already chosen; it cannot discover that a strip under compression buckles at its weakest hinge, and it cannot let one fold land on the one beneath it. Both of those are what a pile IS, and the pile is the whole effect.const [scroll, setScroll] = useState(0) useEffect(() => {
...
paperlab@0.5.2
Patch Changes
- a16379c: Stage mode's source no longer speckles green in Safari. The glow plane faded
through its alpha channel, and a 2D canvas stores premultiplied pixels — so
uploading it un-premultiplied made the browser divide the colour back out,
which along the near-transparent tail amplified 8-bit rounding into off-hue
texels. WebKit's rounding made those visible as a drift of green dots across
the far wall. The falloff is now premultiplied into the colour on an opaque
texture and added to the room, which is also the more honest model of a light.
paperlab@0.5.1
Patch Changes
-
9eab598: Stop the generated scroll component shipping a comment about a figure that is
not in the scene.The stage brief's figure claims were gated on
showFigure, but the comment
baked into the generated component source was not — so a scroll export planted
// Scroll the section, walk the figure.in the receiver's own file whether or
not one was drawn.showFigureis off by default and every built-in stage
preset leaves it there. It now names the camera when nobody is walking. -
0adc36b: Stop the stage agent brief promising a figure that is not there.
showFiguredefaults to false and every built-in stage preset leaves it
there, so the common export is a camera moving through an empty hall.
describeStagealready knew that and withheld the "a small dark figure
walking between them" clause — but the payload's opening sentence claimed
"with a figure walking through it" unconditionally, and the scroll clause was
gated onscrollrather than on the figure, so it promised "scrolling the
page walks the figure deeper into it" as well. Both now follow the figure, and
the scroll clause names the camera when there is nobody to walk.This matters because the brief's description is the acceptance test a
receiving agent checks the render against: a figure named there is a figure it
goes looking for, and the library ships no assets — a figure is always the
caller's own model on the caller's own URL. -
0adc36b: Hand the WebGL context back when a canvas unmounts.
A browser allows a page about sixteen live WebGL contexts and then starts
killing the oldest. React Three Fiber disposes the renderer's own resources on
unmount, but the drawing context itself survives until the garbage collector
reaches the canvas — so anything that mounts and unmounts paper as it scrolls
exhausts the ceiling with contexts belonging to sheets that are no longer on
screen.<Paper>,<PaperField>and<PaperStage>now release the context
explicitly. Measured on the reference page: one scroll to the bottom went from
101 "Too many active WebGL contexts" warnings to none, at an unchanged peak of
thirteen simultaneous canvases.
paperlab@0.5.0
Minor Changes
-
309ec56: Mark every colour field with
.describe('color'), and publishsceneSchema.A colour is a string the way a date is a string, and a schema-driven panel had
no way to tell the difference — so twelve colour fields across content, wash,
light and the stage rendered as text boxes you had to type hex into. The schema
now says which strings are pigments, rather than asking every consumer to guess
from field names:colorandsecondaryare both colours,fontandtext
are both not, and no rule over names separates them.sceneSchemabecomes public because<PaperField>now takes one. -
15fd6c1: Publish
contentNamesandcontentSchemaFor, so content can be edited the way
everything else already is.Behaviors, layouts and the stage all hand their editor UI to a caller by
publishing a zod schema and letting it be walked. Content could not: the union
was internal, so the only way to build a panel for areceiptwas to write one
by hand and keep it in step with the schema — which is exactly what the editor
did, for two of the five types, untilcard,receiptandblankeach opened
onto an empty folder.contentNamesis read off the union rather than written beside it, because the
sibling name lists here (stockNames,physicsNames) are the SOURCE their
schema is built from and this one is not — a hand-written copy would be free to
drift the day a sixth content type lands.contentSchemaForanswers which
member carries which discriminator, which is the union's own fact to state
rather than a walk's to rediscover. -
309ec56: Light overrides reach a single sheet and a field, not just a stage.
scene.lightjoinsscene.lighting, so a<Paper>can be "studio, but the
key is lower and the room is dimmer" — the authorable half that stage mode has
always had.<PaperLighting>has accepted these overrides all along; nothing
was passing them, and a lone sheet could only ever be one of seven rigs exactly
as shipped.lightSchemamoves fromscene/lighting.tsintoconfig/schema.ts, where the
rest of the serialized config lives. It has to:sceneSchemaneeds it, and
lighting.tsimports FROM the schema, so the dependency could not run the
other way. It is re-exported from its old home, where a caller reaching for the
overrides besideresolveLightingwill still find it.<PaperField>takes ascenetoo, and lights itself with<PaperLighting>
rather than the bare ambient-and-directional pair it had. This changes how an
existing<PaperField>looks — and it changes it to what the editor has been
showing all along, which is the point: the gallery you composed and the gallery
the exported code produced were lit by two different rigs, and the export was
the one nobody had looked at.diffFieldPropsalso now compares structurally rather than by reference. No
object or array copied from a default is ever reference-equal to it, so a
layout option holding an array exported a prop that said exactly what the
default already said. -
3cd3bb2: Watercolour washes:
washSchema, awashfield on every content type, and a
washed-letterpreset that shows what it is for.A wash is a FIELD rather than a sixth member of the content union, and that is
the whole design. It is a ground, not a subject — the thing people want is a
letter written over one, a card laid on one, a poster with one behind the type.
Made a content type it would have been mutually exclusive with the text it
exists to sit behind, and the only way to get both would have been to bake the
words into an uploaded picture, which is exactly the trick this library exists
to avoid. It applies to the back of the sheet on the same terms.Painted rather than shipped as artwork, for the reason
DEMO_CARDSare typeset
rather than photographed. A bitmap is ~100KB that cannot cross a share link,
does not survive an export into someone else's codebase, and does not know what
stock it is lying on. A wash described in nine numbers travels anywhere the
config does, tints against the paper under it, and curls with the mesh because
it IS the texture rather than a picture composited over one.Four things separate watercolour from a soft gradient, and the painter does all
four: edge darkening that follows each pool's own irregular outline and varies
in weight around it, wet edges from three harmonics on a radius,multiply
glazing so two washes crossing are a third hue, and granulation confined to
where there is pigment. Seeded, so a preset paints the same wash forever. -
23d8bb4: Publish
stageBanner, and carry a stage's pictures through its export.<PaperStageScene>has accepted animagesarray all along, but
StageExportInputhad no way to say so — a stage built out of pictures
exported as a stage of blank banners, silently.imagesnow travels, and
exportableImagesdecides how.An uploaded picture lives as a data URL, and pasting a hundred kilobytes of
base64 into a source file is not an export. So an upload becomes a placeholder
path — the right number of them, in the right order — and the snippet says
that is what happened. A referenced URL is already something the receiver can
fetch, so it travels verbatim and gets no apology. Emitting nothing was the
other option and it is the worst one: the reader gets blank banners and no clue
that the pictures were the point.stageBanneris the sheet a stage hangs when the caller does not name one. It
is exported because it is the base anyone RESHAPING a banner has to start from
— a wider drop wants this stock, this grain and this drape at different
dimensions, and rebuilding from the schema defaults instead gives a sheet of
printer paper with no fold in it. A second copy of those numbers in a caller is
a copy free to drift from the one the scene actually falls back to. -
309ec56: Backdrops:
scene.backdrop, and<PaperBackdrop>to render one.A colour and a picture behind the sheet, with
fadeandblurso the
backdrop stays a backdrop — a photograph at full strength competes with the
paper in front of it, which is what a photographer solves by putting the
background out of the light.Optional on purpose. An unset backdrop leaves the canvas exactly as it was
found, because<Paper>has always rendered onto whatever is behind it and a
default that painted the frame would change the look of every sheet already on
a page.Painted onto a canvas at the viewport's size rather than assigned straight to
scene.background: three stretches a background texture to the frame whatever
shape it is, so a landscape photograph behind a 9:16 export would come out
squashed — and the export sizes are exactly where a backdrop earns its keep.<Paper>and<PaperField>render it.<PaperMesh>deliberately does not —
it drops into someone else's scene, and a sheet that repainted the background
of the app it is embedded in would be doing something nobody asked for.
Callers who own their own canvas render<PaperBackdrop>themselves.
Patch Changes
-
8ace6ea: Mark the interactive drag handle as chrome, so a renderer producing a picture
can leave it out.The handle is drawn with
depthTest: falseon purpose — it has to sit on top
of the sheet to be grabbable where the sheet curls away. That also makes it the
single most prominent thing in any frame captured off the canvas, which is how
the editor's new image export came out with a blue dot in the middle of the
receipt.userData.paperlabChromesays what the object IS — an editing affordance
rather than part of the artwork — instead of asking every capture path to know
this one mesh by sight. Nothing reads it unless it wants to; the flag is inert
for every existing consumer. -
8aa3029: Point the README and
homepageat paperlab.nawwara.studio.The demo, editor, reference and every image in the npm README resolved through
a URL containing the GitHub account name —nourmtir0722.github.iofor links,
raw.githubusercontent.com/NourMtir0722for images. A published README is
frozen at its version forever, so renaming the account would have left every
release already on npm pointing at a dead demo and showing broken images. The
custom domain outlives the username. -
309ec56: Fix
diffConfigthrowing away everything in a scene exceptlighting.It read
if (config.scene.lighting !== 'studio') out.scene = { lighting },
which was true whilelightingwas the only thing a scene had — and silently
discarded every field added beside it. So a hand-tuned light rig, and now a
backdrop, were shown by the editor and carried by nothing that left it: not a
.paperfile, not a share link, not a JSX snippet or an agent payload.The scene is diffed like every other branch of the config now, and a test
round-trips it: what the diff emits parses back to what went in.Code exports also stop pasting uploaded pictures into source. An upload is a
data URL of a hundred kilobytes and up, and there are two places one can now
be — the sheet's content and the backdrop behind it. A snippet gets a numbered
path in the same position and a line saying so; a referenced URL is untouched.
The.paperfile and the share link still carry the real bytes, because a file
has room for them and dropping them there would lose the artwork rather than
reformat it.
paperlab@0.4.0
Minor Changes
-
0ecfe71: Breaking: the public API is 83 names instead of 214.
Every exported name is a promise kept for years, and this library was exporting
its own internals: shader builders (buildFieldVertexShader,
buildDisplacementGLSL), texture painters (barcodeBars,makeGoboTexture,
silhouetteRects), tessellation constants (SHEET_LIFT,TRANSMISSION_GAIN),
the cloth integrator, the state machine class, and 36 individual behavior,
deformer and layout functions that the registries already reach.None of that is API. It is the inside of the box, and shipping it means a
refactor of a private helper becomes a breaking change for somebody. The
surface is now what a caller genuinely needs: the four components, the schema
and its types, the registries and their threeregister*hooks, presets and
the.paperfile format, the export helpers, lighting-as-data, interaction
states, and the accessibility utilities.Behaviors, deformers and layouts are reached through their registries.
getBehavior('peel'),getDeformer('roll')andgetLayout('ring')return
exactly what the removed named exports did, andlistBehaviors(),
listDeformers()andlistLayouts()enumerate them. Nothing was deleted from
the library — only from its front door.Three things that look internal are still exported, each with the reasoning
written where it is exported: the GPU/CPU parity harness, because it is the
only gate on the invariant the contribution ladder rests on; the tessellation
arithmetic, becauseregisterDeformeris public and a third-party deformer
must answer the segment-count question the same way the built-in seven do; and
wrapLines, because a caller measuring type before laying out a sheet has
to get the same answer the painter will.paperlab/stageloses seven names the same way — a magic constant, four
sub-schemas, and two export helpers — keeping the sixteen thatllms.txt
documents.This lands now, at 0.4.0, precisely because nobody has built on the old surface
yet. Doing it later would cost real users a migration for no benefit to them.
paperlab@0.3.1
Patch Changes
-
7a04709: The npm page now shows the library that actually shipped.
No code changes: the tarball's only difference is
README.md, which npm serves
as the package page and which had drifted badly from 0.3.0. It documented a peer
floor ofthree >= 0.160where the package requires>= 0.162; it never once
mentioned<PaperMesh>; it described deformers, content types and interaction
states nowhere on the page; and every moving image on it predated both the
current design language and the switch of demo content to paper artifacts, so
the pictures were selling a product that no longer looked like that.It also linked to a planning document that has been removed from the repository,
which on npm is a dead link with nothing behind it.The page now carries the catalogues rather than describing them — the six stage
presets, twelve field layouts, eight lighting rigs and seven paper stocks, each
photographed side by side, because a catalogue only means anything when you can
compare its entries. Every asset is regenerated from the registries by
pnpm media,pnpm shot:catalogueandpnpm sheet, so the page cannot
silently drift from the library again.
paperlab@0.3.0
Minor Changes
-
3828a50:
segments: 'auto'now subdivides the direction a deformer actually bends, and stops subdividing the one it does not.segmentsForArc(spanAlong(sheet, angle), r)has always meant "this many segments alongangle".resolveSegmentsthrew the direction away: it took the number, spread it over the sheet's long edge, and gave the short edge whatever was left. On a 1 × 1.4 page nobody could see that. On a banner it is the entire picture — the stage's 1.5 × 8.5 banner is draped in folds that run across its width, the arithmetic asks for 133 segments across, and what it got was 48 across and 48 down a drop that needs eight.A deformer now declares
geometry.axis(options, sheet)beside its floor and its target, and the demand is projected onto the sheet's own axes by it —width·|cos θ|·densityandheight·|sin θ|·density, which is exact, because the demand was always a density along a direction rather than a count for a rectangle.stackMinSegmentsandstackAutoSegmentsreturn a pair now;resolveSegmentsandcreateSheetGeometryaccept one. A bare number still means what it always meant, so an unchanged call to either exported helper answers exactly what it answered before.crumplereturnsnull— its creases run every way at once — and keeps the aspect spread, which for it is the honest answer.Fewer triangles everywhere, and in the case that needed it, a better-looking sheet:
preset grid triangles chord error receipt-unroll49×128 → 8×128 12,544 → 2,048 unchanged letter-fold91×128 → 8×128 23,296 → 2,048 unchanged hanging-poster91×128 → 24×96 23,296 → 4,608 2.8e-4 → 4.8e-4 page-flip48×48 → 48×8 4,608 → 768 unchanged photo-print16×16 → 16×8 512 → 256 unchanged the stage banner 48×128 → 128×8 12,288 → 2,048 5.3e-3 → 7.7e-4 The banner row is the one worth reading twice: six times fewer triangles and seven times less faceting, because the density finally lands on the axis that bends. Every hero preset and both stage presets were rendered before and after and are indistinguishable; the sagitta test now measures the grid a sheet actually gets rather than the arithmetic behind it.
<PaperStage>'squalitytier now reaches the geometry, which it never did. The tier'ssegmentswas written straight over the sheet'ssegmentsas a number — and a number applies to both axes, field mode caps it at 48 on the way down, anddrape's floor of 48 raised it back on the way up.low,mediumandhighall drew the identical 48 × 48 banner, measured at 143,644 triangles a frame whatever the tier said. It is now a ceiling on what'auto'may ask for (segmentCeilingon<PaperFieldMesh>, a device knob that never serializes), so it can lower the grid and never raise it.The hero re-deform loop is ~2.6× faster, which is the other half of the same frame. At the 128 ceiling one
drape + wavesheet cost 2.30 ms a frame, of which 1.44 ms wasBufferGeometry.computeVertexNormals().computeSheetNormalsdoes the same arithmetic straight over the typed arrays and is bit-identical to three's answer — asserted as exact equality, not a tolerance — at about an eighth of the cost. The loop itself now runs one deformer over every vertex instead of every deformer over one vertex, putting a single function behind the inner call site instead of a registry lookup and a megamorphic call per vertex per deformer. A resting sheet also stops expanding its whole deformer stack sixty times a second to discover it has nothing to do.grid verts was now 72 3,796 0.74 ms 0.27 ms 128 11,868 2.30 ms 0.84 ms 192 26,634 5.01 ms 1.89 ms 256 47,288 8.74 ms 3.38 ms End to end on a stage (
pnpm perf):medium65.8 ms → 51.0 ms (15 → 20 fps),low36.2 ms → 26.1 ms (28 → 38 fps),archiveat 44 banners 34.4 ms → 26.0 ms.highmoves least, because its ceiling keeps 72 across the folds and its frame is dominated by the contact-shadow pass and dpr 2.And the ceilings came up, because the axis split made them cheap. No shipped preset reaches even 128 after the change, so
AUTO_CEILINGhad stopped binding anything the library hands out — it only bound people asking for a tighter crease than any preset uses. Since a demand now lands on one axis, satisfying those costs ~0.02 ms rather than the 1.89 ms a square grid implies.was now who feels it AUTO_CEILING(hero)128 192 hand-authored tight creases — drapeat its defaults (154),roll/foldatradius: 0.02(175),curlat 0.02 (142). No preset changes.FIELD_AUTO_CEILING72 128 only a field with no segmentCeiling; the quality tiers cap themselves lower.qualityTiers.high.segments72 128 the stage's folds, on machines that measured fast enough to earn them. The third is the visible one: a banner's drape asks 133 across and had been getting 72, so at
highthe fold highlights now roll instead of stepping (sagitta 2e-3 → 7.7e-4).mediumandloware untouched and measure identically to before; explicithighcosts more on weak hardware and no machine that cannot hold it is ever promoted to it.The ladder could pump, and now cannot. Promotion needs 55 fps and demotion fires under 26, so any machine where the next tier costs more than ~2.1× the current one satisfies both forever — and raising
highis what put it at exactly that ratio on a software rasterizer.auto's policy is now a puresettleTier(tier, fps, failed), and a tier that has once failed is never offered again: the ladder tries the top once and settles. Still capped, and still said out loud:waveatamplitude: 0.3wants 272 and a 16-folddrapeat full depth wants 1377;segments: <number>is the way past.Those stage numbers are a software-rasterizer floor, not frame rates.
pnpm perfused to printrenderer: native GPUwhenever--softwas absent, which was the launch flag it had been handed rather than the driver that answered; asked properly, headless Chromium draws all of it through ANGLE/SwiftShader either way. Both harnesses now report what actually drew the frame, and both take--gpu, which gets the real platform renderer headless. On an M4 Pro the stage holds 120 fps at every tier — 120 banners at 16 megapixels included — so these numbers are the floor and not the ceiling. -
8ac1790: The stage is a room now, and there is nobody in it.
stage.roomandground.slabA ceiling, and seams in the floor. Together they are the scale of the hall.
Stage mode was a void with a horizon — a graded dome, a flat plane, and a bright rectangle at the end, and not one thing in it was a knowable size. That is the real reason the walking figure existed, and it is why removing the figure on its own would have left an abstraction rather than a room.
Architecture answers it better, for a reason worth stating plainly: a concrete floor is poured in bays of about two and a half metres and a ceiling sits about three above your head, and a viewer knows both of those without being told. They are also flat surfaces under good light — the one thing a renderer never gets wrong — where a human mesh is the one thing it always does.
The ceiling earns its place twice. It gives the haze a far surface to end on, which is why the top of frame used to grade away to nothing; and it puts a horizontal plane above the walk for the source to spill onto, which is how every reference installation reads as interior — you can see the light landing on the ceiling.
The floor was
#0e0b09, which is dark enough to disappear, and a floor that disappears cannot show the seams that are the entire point of it. Lifted to#241e19: the hall keeps its contrast against the source and gains a surface you can read the size of the room from.showFiguredefaults tofalseThe figure was doing a real job and the instinct behind it was right. The instrument was wrong.
The deciding argument is not that the model looked cheap — it is that the stage is navigable. Drag, wheel, arrow-step, click the banner you want to stand in front of: there is already a person in that hall, and it is the viewer. A second one walking the same aisle on its own clock competes for the role, and the viewer cannot tell whether they are the camera or the character. Every installation this mode is modelled on answers that question the same way: you are the one walking.
Still one flag away for anyone who wants it, and the walk system, the gait and the camera binding are untouched — none of that ever needed a visible body.
describeStagegained a fix from this. The camera was named inside the figure's own clause, so turning the figure off silently took the shot out of the description too — and the shot is what the reader is actually looking through. It is named unconditionally now, the room is described, and no figure is claimed when none is drawn. A brief that promises a walking person the render does not contain is worse than a terse one.One room with a colour...
paperlab@0.2.0
Minor Changes
-
3b38a22: New:
crumple— paper that has been handled.Seven deformers now, and the new one is the first that crushes a sheet.
waveandfoldwere the nearest and neither reads as crumpled, which made this the biggest single gap in the set: a crumple is the most recognisable paper state there is.It ships as the whole slice — the
crumpledeformer (JSdisplaceplus its GLSL twin, held together by three new cases inpnpm test:parity), acrumplebehavior (progress,coarseness,ball,seed), and acrumpled-notepreset.The field is the gap between the two nearest points of a jittered cell grid, signed per cell. It vanishes on every cell boundary, so the sheet stays continuous, and its gradient flips across one — which is a crease. What you get is an irregular polygonal network of facets alternating toward and away from you, rather than the periodic egg-crate or the smooth hammered-metal look the two earlier attempts produced. The normals are the point: a crumple that does not shade its own facets is a noisy sheet, not a crushed one.
It is the most expensive deformer in the set, and measurably so:
pnpm perf:fieldputs a field of them about 45% longer per frame than the same field of an undeformed preset. Almost none of that is geometry —segments: 'auto'already gives every sheet 72 a side, so itsminSegments: 72is a floor that only bites when a preset asks for a coarser grid by hand. The cost is the nine cell lookups per probe, three probes deep for the vertex normal.Also:
describeConfignow has a phrase forcrumple, and a test asserts that every registered behavior has one, so a new behavior can no longer describe itself as nothing. -
09416c5:
drape,crumpleand thecrumplebehavior are now exported like every other deformer and behavior.roll,curl,bend,foldandwavewere each exported individually — their deformer object, options schema and options type — whiledrapeandcrumplewere reachable only throughgetDeformer(id). Nothing depended on the difference, which is exactly why it was worth closing: an API with an arbitrary hole in it is a papercut for the first person who trips over it, and the reference site now documents all seven.This is deliberately the reversible direction. The alternative was removing all seven, which is a breaking change and belongs to the pre-1.0 export trim rather than to a tidy-up. When that trim happens, the deformer objects and their schemas should go as one group of seven.
-
b45980b: Fix: the props now accept what the docs say they accept, and
surfaceis finally one of them.Two bugs, same root.
<Paper surface={{ grain: 0.3 }} />was documented in the README,AGENTS.mdanddocs/llms.txtand was not a prop at all — it failed to typecheck, and in plain JSresolveConfigdropped it on the floor, so the effect you asked for silently never happened. Andcontent,behavior,deformersandphysicstook each schema's parsed type rather than its input type, which demanded every field of every nested object: the README's own example —content={{ type: 'receipt', store: 'acme.dev', items: [...] }}— did not compile.Both are fixed.
surfaceandsceneare real props now (surface merges over the stock's defaults rather than replacing them, sosurface={{ grain: 0.6 }}on thermal keeps thermal's banding), and every config prop takes the schema's input type, so anything with a default stays optional. The schema now exports both types for each config —ContentConfigInput,BehaviorConfigInput,SurfaceConfigInput,PhysicsConfigInput,DeformerInstanceConfigInput,SceneConfigInput— andconfig/props.test.tspins the documented examples at both the type level and at runtime, so a prop cannot quietly go back to an inferred type.No runtime behaviour changes for code that already compiled, except that a
surfaceprop now actually applies.
Patch Changes
-
1141986: Fix:
bendand its GLSL twin disagreed at low curvature, and the parity gate never looked there.The arc's in-plane shift is
r·sin θ − d, anddisr·θ— so for a gentle bend it is a difference of two nearly-equal large numbers, and the answer is whatever bits survive.r(1 − cos θ)has the same problem. JS computes both in float64 and gets away with it; the GLSL twin computes them in float32 and does not. The two paths were 6.1e-4 apart atcurvature: 0.35— past the parity gate's 5e-4 epsilon — meaning hero mode and field mode were rendering measurably different arcs.It went unnoticed because the gate only ever exercised
|curvature| ≥ 0.6, whilephoto-print— the field starter preset, and the one every gallery layout is demoed with — bends at0.35, squarely inside the untested band.bendis now written in its cancellation-free form on both sides:r(1 − cos θ)as2r·sin²(θ/2), and the in-plane shift through asin(x) − xhelper that uses a series below |x| = 1 and the direct form above it. Same arc to sixteen places — only the float32 half could tell the difference, and that is exactly the half that was wrong. Worst-case parity error at 0.35 drops from 6.1e-4 to 2.1e-5, and the existing bend cases improved by an order of magnitude too. Two permanent low-curvature parity cases now cover the band, including the gentlest arc the schema allows. -
963861b: Docs: document the community loop. A
.paperfile someone shares with you is already a preset object —<Paper preset={theirPaper} />orregisterPreset(name, theirPaper)— so it goes straight into a project without being expanded into individual props. The README,AGENTS.md, anddocs/llms.txtnow say this explicitly, andconfig/shared-paper.test.tspins the round-trip so the promise cannot silently break.CONTRIBUTING.mdnow leads with the fact that sharing a paper needs no fork and no PR; the contribution ladder is for work you want shipped inside the library.