Skip to content

Releases: NourMtir0722/Paperlab

paperlab@0.6.0

Choose a tag to compare

@github-actions github-actions released this 02 Sep 13:00
b6af3be

Minor Changes

  • 06ff0ad: A simulation and a shape are no longer alternatives. Cloth hosts a deformer stack.

    physics: 'cloth' and behavior / deformers used 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. applyDeformerStack already 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, in buildStack, and in withMemory — 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. fold needs 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.adopt carries the particles across. Otherwise the sheet snapped flat at the exact moment you tried to fold the one you were holding.
    • memory.creases now 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.

    strip stays 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: fold places 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 /hands harness — 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. plCrease multiplied 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 and MeshStandardMaterial lights it, through csm_FragNormal and Mikkelsen's surface-gradient bump. One deliberate difference from three's own perturbNormalArb: 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.strength is 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. CreaseShading carries the fold's own { angle, offset } rather than a translation of them, so the shader evaluates the identical dot(p, dir) - offset the fold deformer displaces by, and the two cannot place a line differently. The shaded width comes off CREASE_RADIUS instead 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. ClothSim now 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. ClothSim is internal; grab, moveGrab and release keep their signatures.

  • b6b7502: New strip physics: a roll paying paper out as the page scrolls, and the pile it makes when it lands.

    unroll and the paper-roll preset 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(() => {

...

Read more

paperlab@0.5.2

Choose a tag to compare

@github-actions github-actions released this 28 Aug 21:35
099eb61

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

Choose a tag to compare

@github-actions github-actions released this 28 Aug 13:23
477220d

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. showFigure is 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.

    showFigure defaults to false and every built-in stage preset leaves it
    there, so the common export is a camera moving through an empty hall.
    describeStage already 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 on scroll rather 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

Choose a tag to compare

@github-actions github-actions released this 25 Aug 00:24
ea26c94

Minor Changes

  • 309ec56: Mark every colour field with .describe('color'), and publish sceneSchema.

    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: color and secondary are both colours, font and text
    are both not, and no rule over names separates them.

    sceneSchema becomes public because <PaperField> now takes one.

  • 15fd6c1: Publish contentNames and contentSchemaFor, 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 a receipt was 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, until card, receipt and blank each opened
    onto an empty folder.

    contentNames is 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. contentSchemaFor answers 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.light joins scene.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.

    lightSchema moves from scene/lighting.ts into config/schema.ts, where the
    rest of the serialized config lives. It has to: sceneSchema needs it, and
    lighting.ts imports 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 beside resolveLighting will still find it.

    <PaperField> takes a scene too, 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.

    diffFieldProps also 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, a wash field on every content type, and a
    washed-letter preset 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_CARDS are 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 an images array all along, but
    StageExportInput had no way to say so — a stage built out of pictures
    exported as a stage of blank banners, silently. images now travels, and
    exportableImages decides 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.

    stageBanner is 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 fade and blur so 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: false on 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.paperlabChrome says 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 homepage at 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.io for links,
    raw.githubusercontent.com/NourMtir0722 for 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 diffConfig throwing away everything in a scene except lighting.

    It read if (config.scene.lighting !== 'studio') out.scene = { lighting },
    which was true while lighting was 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
    .paper file, 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 .paper file 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

Choose a tag to compare

@github-actions github-actions released this 23 Aug 23:16
d77ccf5

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 three register* hooks, presets and
    the .paper file 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') and getLayout('ring') return
    exactly what the removed named exports did, and listBehaviors(),
    listDeformers() and listLayouts() 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
    , because registerDeformer is 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/stage loses seven names the same way — a magic constant, four
    sub-schemas, and two export helpers — keeping the sixteen that llms.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

Choose a tag to compare

@github-actions github-actions released this 23 Aug 21:45
fc81435

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 of three >= 0.160 where 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:catalogue and pnpm sheet, so the page cannot
    silently drift from the library again.

paperlab@0.3.0

Choose a tag to compare

@github-actions github-actions released this 23 Aug 12:20

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 along angle". resolveSegments threw 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 θ|·density and height·|sin θ|·density, which is exact, because the demand was always a density along a direction rather than a count for a rectangle. stackMinSegments and stackAutoSegments return a pair now; resolveSegments and createSheetGeometry accept one. A bare number still means what it always meant, so an unchanged call to either exported helper answers exactly what it answered before. crumple returns null — 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-unroll 49×128 → 8×128 12,544 → 2,048 unchanged
    letter-fold 91×128 → 8×128 23,296 → 2,048 unchanged
    hanging-poster 91×128 → 24×96 23,296 → 4,608 2.8e-4 → 4.8e-4
    page-flip 48×48 → 48×8 4,608 → 768 unchanged
    photo-print 16×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>'s quality tier now reaches the geometry, which it never did. The tier's segments was written straight over the sheet's segments as a number — and a number applies to both axes, field mode caps it at 48 on the way down, and drape's floor of 48 raised it back on the way up. low, medium and high all 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 (segmentCeiling on <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 + wave sheet cost 2.30 ms a frame, of which 1.44 ms was BufferGeometry.computeVertexNormals(). computeSheetNormals does 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): medium 65.8 ms → 51.0 ms (15 → 20 fps), low 36.2 ms → 26.1 ms (28 → 38 fps), archive at 44 banners 34.4 ms → 26.0 ms. high moves 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_CEILING had 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 — drape at its defaults (154), roll/fold at radius: 0.02 (175), curl at 0.02 (142). No preset changes.
    FIELD_AUTO_CEILING 72 128 only a field with no segmentCeiling; the quality tiers cap themselves lower.
    qualityTiers.high.segments 72 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 high the fold highlights now roll instead of stepping (sagitta 2e-3 → 7.7e-4). medium and low are untouched and measure identically to before; explicit high costs 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 high is what put it at exactly that ratio on a software rasterizer. auto's policy is now a pure settleTier(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: wave at amplitude: 0.3 wants 272 and a 16-fold drape at full depth wants 1377; segments: <number> is the way past.

    Those stage numbers are a software-rasterizer floor, not frame rates. pnpm perf used to print renderer: native GPU whenever --soft was 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.room and ground.slab

    A 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.

    showFigure defaults to false

    The 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.

    describeStage gained 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...

Read more

paperlab@0.2.0

Choose a tag to compare

@github-actions github-actions released this 13 Aug 18:14

Minor Changes

  • 3b38a22: New: crumple — paper that has been handled.

    Seven deformers now, and the new one is the first that crushes a sheet. wave and fold were 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 crumple deformer (JS displace plus its GLSL twin, held together by three new cases in pnpm test:parity), a crumple behavior (progress, coarseness, ball, seed), and a crumpled-note preset.

    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:field puts 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 its minSegments: 72 is 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: describeConfig now has a phrase for crumple, and a test asserts that every registered behavior has one, so a new behavior can no longer describe itself as nothing.

  • 09416c5: drape, crumple and the crumple behavior are now exported like every other deformer and behavior.

    roll, curl, bend, fold and wave were each exported individually — their deformer object, options schema and options type — while drape and crumple were reachable only through getDeformer(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 surface is finally one of them.

    Two bugs, same root. <Paper surface={{ grain: 0.3 }} /> was documented in the README, AGENTS.md and docs/llms.txt and was not a prop at all — it failed to typecheck, and in plain JS resolveConfig dropped it on the floor, so the effect you asked for silently never happened. And content, behavior, deformers and physics took 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. surface and scene are real props now (surface merges over the stock's defaults rather than replacing them, so surface={{ 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 — and config/props.test.ts pins 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 surface prop now actually applies.

Patch Changes

  • 1141986: Fix: bend and its GLSL twin disagreed at low curvature, and the parity gate never looked there.

    The arc's in-plane shift is r·sin θ − d, and d is r·θ — 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 at curvature: 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, while photo-print — the field starter preset, and the one every gallery layout is demoed with — bends at 0.35, squarely inside the untested band.

    bend is now written in its cancellation-free form on both sides: r(1 − cos θ) as 2r·sin²(θ/2), and the in-plane shift through a sin(x) − x helper 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 .paper file someone shares with you is already a preset object — <Paper preset={theirPaper} /> or registerPreset(name, theirPaper) — so it goes straight into a project without being expanded into individual props. The README, AGENTS.md, and docs/llms.txt now say this explicitly, and config/shared-paper.test.ts pins the round-trip so the promise cannot silently break. CONTRIBUTING.md now 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.