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 in it
threshold— a few enormous sheets, wide enough apart to walk between — is now terracotta.Every stage in the set was a warm neutral corridor, and white paper against warm neutral is white paper against nothing: the sheets and the room sit at the same temperature and the picture flattens. Against a saturated ground the paper sings, which is why the installations worth copying are shot in rooms painted terracotta and washed with gels rather than in white boxes.
source.color,source.zenithandground.colorare the same three stops that build the environment map, so the light bouncing onto the sheets is the room's own colour and cannot disagree with the walls in shot.Smaller
The docs app was shipping a 1.5 MB copy of the walking-figure glTF that nothing in it referenced. Removed; the editor and playground copies stay, because those two do reach it when the figure is switched on.
-
54aee8b: Stage mode is navigable. It was a picture you watched; now you can walk it.
The whole mode had one input —
progress— and if nobody supplied one the walk ran on a clock. There was nothing to touch: no drag, no wheel, no keyboard, no way to stop in front of a banner and read it. Field mode has hadmotion={{ driver }}since it shipped, and the stage — the mode most likely to be somebody's entire homepage — had nothing.<PaperStage motion={{ driver: "drag", speed: 1, capture: true }} /> // the default
Same contract as a field's, and the same three driver names.
dragis the viewer: pointer drag with inertia, wheel, arrow keys, PageUp/PageDown, Home/End, or a click on the paper you want to stand in front of.autoplayis the clock and only the clock.noneis nobody.It drifts until you touch it.
dragwalks on its own until the first pointer, wheel or key, and is yours for good from then on. That is deliberately one behaviour rather than two drivers, because the alternatives are each half wrong: a stage that only autoplays cannot be touched, and one that only waits opens as a still photograph of itself.Steps land on a paper, not near one.
Layout.walkStops(n, options)is a new optional member — only a layout that arranges along a path can answer it — andcolonnadecomputes it from the same helperposeplaces banners with, so a stop cannot drift off its banner. Layouts that arrange around an origin simply decline, and stepping falls back to an even spread.capture(default true) is whether the walk takes the wheel and touch away from the page. True for a stage that fills the screen — it is the page. False for one sitting in a column of prose, where capturing them eats a reader's scroll on the way past and traps a finger on a phone; mouse drag and arrow keys still work. Even when captured, the wheel is handed back at the ends of an open walk rather than pressing silently into a wall.Supplying
progressstill outranks all of it: a stage bound to page scroll is a controlled component, and a driver writing the same number the page is writing is a fight, not a feature.Two supporting additions.
<PaperStage onProgress={walk => …} />reports the live position every frame it changes, whoever is driving — mirror it into an uncontrolled input for a scrubber that follows the walk with no re-renders, exactly as<PaperMesh>'sonProgressdoes for a behavior. And<PaperFieldMesh onSelect={paper => …} />fires with a paper's index when it is clicked; supplying a handler is what makes the papers raycastable at all, which matters because hit-testing an instanced mesh is per-instance work on every pointer move.Fixed while wiring it: autoplay used to extrapolate straight past the end of an open walk and keep going for as long as the tab was open — a camera stationed in the dark past the last banner. It wraps. The playground had been running its own clock and its own
% 1specifically to avoid that, and no longer needs either.pnpm test:driveis new and is in CI. The navigation is pointer capture, wheel handlers and key handlers against a live canvas, and no unit test can see any of it; the math has unit tests, and this drives a real browser through drift, drag, flick, wheel, arrow keys, a click, and a controlled stage refusing all of it. It paid for itself on the first run by catchingraycast={undefined}— which does not mean "leave the default", it assigns undefined over the method three is about to call, and silently disabled every click. -
e00a307:
segments: 'auto'now sizes the grid from the active deformers, which is what the schema always claimed it did.It did not. It gave the long side a flat 72 whatever was on the sheet, and a deformer's
minSegmentswas only ever a floor — so nothing could raise a grid that already started at the highest number anyone asked for, and nothing ever lowered it. A blank sheet was tessellated exactly as finely as a crumpled one, andcrumple'sminSegments: 72was a no-op except against a hand-picked coarser grid.The distinction the fix rests on:
minSegmentsis a correctness floor — the density below which a deformer stops working. What'auto'needed is a quality target, and a target has to depend on the options, because a bend atcurvature: 0.05and a roll atradius: 0.02are not remotely the same request and one constant per deformer cannot answer for both. So deformers now declaregeometry.autoSegments(options, sheet)alongside their floor, and six of the seven derive it from the same place: a mesh is a piecewise-linear stand-in for a curved surface, the error is the sagittah²/8r, and inverting that turns "how many segments?" into arithmetic on the radius the options imply.The tolerance is calibrated rather than picked. At the old flat 72 the default
rollalready ran at a sagitta of 3.9e-4, so that is the tolerance — the tightest configuration in common use keeps exactly the density it ships with, and everything gentler stops paying for precision it was not using.This subdivides both ways. A blank sheet drops to 8 a side; a tight fold rises to 128, which the flat 72 could never give it however much the crease needed. Per preset, in hero mode:
preset before after typed-note,blank-sheet(no deformer)7,344 tris 96 (−99%) photo-print(the field starter, a gentle bend)7,776 512 (−93%) page-flip7,344 4,608 (−37%) postage-stamp8,496 6,784 (−20%) vintage-note,crumpled-note— unchanged hero-peel,flying-note,receipt-unroll6,912–7,200 +78–81% letter-fold,hanging-poster7,344 23,296 (+217%) Across all presets that is +24% triangles: the library trades geometry away from sheets that were not using it and spends it on creases that were short of it. If the presets that went up are not worth their cost to you, the ceiling is one constant in
core/tessellation.ts.The ceiling is 128, and it is a measured CPU budget rather than a round number. Hero mode re-deforms every vertex in JS on the main thread every frame for any animated stack, and
waveis animated, so a hanging poster pays it permanently rather than only while something plays. One sheet, one re-deform ofdrape + wave: 0.67 ms at 72, 2.05 ms at 128, 4.53 ms at 192, 7.89 ms at 256. 256 is half a 60 fps frame on one sheet on a fast machine; 128 is the last step that leaves room for a scene around it.In a field (
pnpm perf:field --soft):typed-notegoes from 98.3 ms to 23.5 ms a frame at ×20, and 261.5 ms to 37.8 ms at ×60 — 4 fps to 26, at 1.3% of the triangles.crumpled-noteis unchanged to the triangle, deliberately (below).Two things worth knowing before relying on this.
crumpleis the one deformer that gets noautoSegments, and that is the honest answer rather than an omission: every other deformer approximates a smooth surface, so its density falls out of a radius, but a crumple's creases are exactly where the gradient is meant to break and there is no sagitta to bound. What it wants is segments per cell, which at the defaultscale: 3is the 72 its floor already asks for.A field is capped separately and lower, at the old flat 72, because it draws that buffer once per instance — the hero ceiling of 128 would be paid sixty times over.
FIELD_SEGMENT_CAPkeeps its original and only job of capping the FLOOR a stack may demand; it explicitly does not cap the target. Capping both looked tidy and was a visual regression, because it is the one thing that could holdcrumple— which has no target, only a floor of 72 — down to 48 in a field, coarser than the deformer says it needs to read as a crumple at all. Field geometry is therefore unchanged to the triangle from before this release.Also: the grid is built once, but a behavior's stack is not the same shape throughout — an unroll is a tight roll at one end of its progress and a flat sheet at the other. Sizing to the configured moment would leave the sheet under-tessellated for the rest of the play, so both the hero and field paths sample the behavior's progress across 0→1 and keep the densest answer. That sampling assumes every behavior's
progressParamruns 0..1, which is true of all ten and is now pinned by a test, because it fails silently otherwise.core/tessellation.test.tsmeasures the sagitta directly rather than trusting the arithmetic: for every edge of the resolved grid it compares the deformed chord midpoint against the deformer's own answer there, across each deformer's real option range. It also asserts the measure has teeth by forcing a tight bend onto the coarsest grid the ladder allows and requiring it to fail.Two configurations still do not meet the tolerance and are honest about it:
waveatamplitude: 0.3asks for 272 segments anddrapeat its default depth asks for 154, against a ceiling of 128. They pass the suite on a "no worse than the flat 72 this replaced" clause, which documents a real remaining gap rather than creating slack — closing it costs more CPU per frame than the budget above allows. Recorded indocs/roadmap.md. -
c4899e7: Behaviors nominate the params that matter, and a paper can say where its handle is.
Two additions, both for tools built on top of the library.
Behavior.signaturenames the two or three options that ARE a behavior — the ones someone reaches for first, in the order they'd reach for them. Editors give those the loud controls and fold the rest away; the schema still generates a control for every option, so nothing is removed, only ranked. All twelve built-ins nominate one (peel→ progress, corner;flight→ gustiness, tumble, path, which puts its three-slider wind vector one disclosure away instead of first). It is optional and its absence means "show everything" — the library never hides a param it was not told to hide, because silence from a community behavior is not permission to guess.PaperHandle.handlePoint(id?, target?)returns a behavior's grab point in world space, or null when the behavior has no handles. The handle rides the deformed surface, so its position is a fact about the frame, not about the config — nothing outside the render can derive it from a UV, which is why anything that wants to point at the handle (a coach-mark, a tooltip, an arrow) had no way to. Passtargetand it writes in place, so a per-frame reader does not allocate. -
55253bc: The room gets architecture that stands in it, and paper gets more than one way to hang.
stage.room.columns— square piers with a base plate and a capital, down both sides of the walk, spaced by arc length so a bend or a spiral gets even bays. Three instanced meshes, so length is free. A ceiling and floor seams are boundaries: they say where the room stops, not how big it is. A base plate is the only element in a scene that puts a hard horizontal edge at a known height off the floor, which is what makes a floor read as a floor — and it is the reading the walking figure was retired for giving. Off by default;naveturns it on. Columns stand outside the paper and are darker than it on purpose: the light is the brightest thing in these frames and the paper is second.stage.room.doorway— a wall at the end of the walk with the source shining through an opening in it. Without it the source is a bright rectangle in a void: it reads as light, but not as light coming from anywhere. It also gives the room the corner it never had. Off by default;thresholdturns it on.stage.suspensionnow names the hardware properly.typeis what carries the load —'thread'(one line per sheet), the new'rod'(a dowel across the sheet's top edge, hung at both ends so it cannot tip), or'none'.hardwareis what grips the sheet —'clip'(wide and shallow, across the edge), the new'peg'(narrow and deep, down the face), or'none'. Hardware also scales with the sheet it holds now, which it did not.Breaking:
suspension.clips: booleanis replaced bysuspension.hardware: 'none' | 'clip' | 'peg'.clips: truebecomeshardware: 'clip', which is the default, so a stage that never mentioned it is unchanged. A boolean was the reason two of the four pieces of hardware the plan named had no way to be asked for. -
e0b4e0f: The stage figure walks like a body now, and can be somebody else's rig.
It had thighs, knees, arms, a bob and a lean, and that was the whole gait. What it did not have is the thing you actually recognise a human walk by: the pelvis and the chest turning against each other. A walk without that counter-rotation reads as a shamble however good the legs are.
So the gait grew the terms that were missing, all of them driven by distance walked exactly as the old ones were:
- Pelvis rotation, carrying the swing-side hip forward — which is how a step gets longer than the leg is.
- Chest counter-rotation against it, cancelling most of that angular momentum so the head travels straight. Given as an absolute rotation against the direction of travel rather than relative to the pelvis, so "these two oppose" is legible in the data and testable; the renderer applies the difference.
- Lateral sway, the trunk leaning over whichever foot is carrying the weight, twice per stride.
- Pelvic obliquity, the unweighted hip dropping away as it swings.
- Elbows. The arm was one capsule from shoulder to wrist; it is now an upper arm and a forearm with a joint between them, carrying a standing bend that tightens as the arm drives forward.
And running, which is not a fast walk.
figure.gaitis'auto' | 'walk' | 'run', and'auto'decides with the Froude number —v²/gLagainst leg length, past ≈0.5 — so the transition sits where a real one does and moves with the figure's size: a shorter figure breaks into a run at a speed a taller one still walks. A run swings further, folds the knee toward the seat, holds the elbows near a right angle, leans harder, and covers more ground per step.The tell is the bounce, and it inverts. A walk vaults over a straight stance leg, so it is highest at midstance and never rises above standing height. A run's leg is a spring that compresses under the body at midstance and throws it clear of the ground in between — so the same curve turns upside down and crosses zero.
bobmay now be positive, which it never was before, and only ever in a run.figure.modeltakes a rigged glTF/GLB. The asset is not part of the library and never ships in the npm tarball — it is a URL the app hosts. What the library contributes is the part that is actually hard: the clip is scrubbed by distance walked, not played on a mixer clock. Run a walk cycle on its own timeline and the feet skate the moment the figure's pace disagrees with the animator's, which a scroll-driven walk does constantly. One gait cycle maps onto one pass of the clip, andstrideis the knob that syncs a particular asset.Around that: the clip is chosen by name so
Walk,walk_01andArmature|Runningall resolve (and it will take the other gait over nothing); the rig is scaled tofigure.heightoff its own bounding box, so assets authored in centimetres and in metres both come out right against the paper; it is cloned throughSkeletonUtilsso two figures on one URL cannot drive each other's skeleton; and it is drawn as a silhouette like the capsules, because the nave is lit from behind and a shaded character dissolves into the haze it has to read against. Anything that fails — a 404, a file that is not a glTF, a rig with no clips — falls back to the capsule figure rather than emptying the stage, and says so in the console.One invariant narrowed on purpose. The gait used to promise "same ground covered = same pose, whatever pace put the figure there". It now promises that within a gait: crossing the walk/run threshold is a different gait with a different stride, so the pose at a given distance changes with it. The old wording was never quite true anyway —
leanhas always readspeed. The test that pinned it now says which it means, and a second one pins that the rule still holds inside a run.Also fixed while wiring the trunk: the forward lean was applied to the hips, which tipped the legs along with it. A body leans from the waist, so it now applies to the trunk and the legs stay under it.
The rigged path is verified against a real asset, and doing that found a bug that would have hit every model. The rig is scaled to
figure.heightoff its own bounding box — but a freshly cloned scene has stale world matrices, soBox3measured the root's untransformed geometry, reported a model far smaller than it is, and produced a correspondingly enormous scale. On screen the figure filled the frame. It now callsupdateMatrixWorldbefore measuring. No amount of reading the code was going to surface that; it took rendering it.Verified end to end against Khronos's
CesiumMan— loads from a remote URL, scales correctly against the banners, silhouettes, casts its contact shadow, and changes pose withprogress, which is the distance-scrubbing working. It also happens to exercise the awkward case on purpose:CesiumManhas exactly one, unnamed clip, so it runs throughpickClip's last fallback rather than any name match. -
2fa9b6b: Lighting is data now. A preset names a starting point;
stage.lightmoves it.Every other axis of this library is parametric — sheet, stock, surface, deformer stack, layout, walk, shot — and lighting was an enum of six strings. You could not place a light, warm one, or turn the room down.
lightis the missing half, in the terms a person would actually say them in:<PaperStage stage={{ lighting: "nave", light: { exposure: 0.9, key: 3.2, direction: 180, height: 24, ambient: 0.03, studio: 0.6, haze: 1.2, }, }} />
directionandheightare degrees around the room and degrees above the horizon, not a position vector, because "where is the light" is a question about the room rather than about the coordinate system.lightAngles()andlightPosition()are exported, pure, and exact inverses — which is what lets a slider read the resolved rig and write back a single field without drifting a millimetre per drag.Every field is optional, and that is load-bearing: an unset field means whatever the preset says, so a shared stage carries the two sliders you moved rather than a frozen copy of a rig you never touched, and re-basing onto another preset keeps your intent instead of your numbers.
studiois new light, not a new slider. It is the room itself — the same three colours as the cyclorama, plus a soft disc of the key's own colour where the key stands — built procedurally into an equirectangular image and prefiltered through PMREM. No HDRI, nothing fetched, nothing added to the tarball. Flat<ambientLight>adds brightness with zero direction, which is the single biggest reason a surface reads flat; this is the same brightness with a shape. Every preset'sambientcame down accordingly, and paper finally has something for its sheen to reflect.And it uncovered a real bug.
translucencyValues()reads the key light's own position so a sheet's backlit glow can never disagree with the lamp casting its shadow — but it read it from the paper's ownscene.lighting, and no stage banner ever carried one. Every banner in every stage computed its glow fromstudio, a lamp up and to the right, while the hall was lit bynavefrom behind. The coupling was right; the wire was missing. Scenes now publish the rig they resolved through a<LightRig>context and the paper reads that in preference to its own name — exported, so a hand-built R3F scene gets the same guarantee. Moving a light writes four uniforms in place rather than rebuilding a shader program, so dragging a slider does not recompile per frame.Retuned along with it, all of it visible in the README's stage loop: the source at the end of the walk was a hundred units across and filled the frame behind the colonnade, so
source.spreadnow sizes it as an opening rather than a wall; its falloff runs from a held core into a long tail instead of dropping to nothing over the last 45%, which had put a visible rim on it like a moon hanging in the room; the nave prints a stop under, because a backlit sheet carries its lamp's whole intensity as transmission and at the old exposure every banner clipped to flat white and lost the folds it was draped for; and the haze reaches past the end of the walk instead of saturating halfway down it.New in
figure:finish: 'silhouette' | 'shaded', defaulting toshaded. A rigged model keeps its own materials and takes the scene's light — in a backlit hall, a rim down one edge and the studio light filling the other. Two clip fixes came with it:pickClipnow takes the shortest matching name, soMan_Runwins overMan_RunningJump(taking the first match meant a pack that happened to list the jump first put the figure into it for the whole walk), and a frozen figure — which is whatprefers-reduced-motionproduces — stands in an idle clip instead of holding frame 0 of a stride with one leg out.What it costs, measured (
pnpm perf, native GPU, nave at medium): 44ms → 68ms a frame, all of it the environment sampling in a scene with heavy overdraw, andpnpm perfgained a case so the trade stays visible. Thelowtier swaps the environment for a hemisphere light rather than dropping it, so a weak machine still gets light with a top and a bottom;light: { studio: 0 }turns it off at any tier without moving the tier. -
5dea79c: A content type for the things paper actually gets cut into, real typesetting controls, and no preset touches the network any more.
card— the paper-artifact typeOne composition — a tracked label, a hairline rule, a body, a line of small print — covering the index card, the library due-date card, the museum wall label, the telegram slip and the gallery quote sheet, because those are the same object with different parts present.
It exists because
textcould not make any of them.textsets a block of prose in one size and one weight; every artifact above is a hierarchy, and composing one out of plain text meant hand-placing newlines and hoping. The proportions insidepaintCardare ratios of the body size rather than numbers, so a card scales as a card instead of as a paragraph that grew, and the whole block is measured before anything is drawn so it can sit optically centred — a card whose type hangs from the top edge reads as a page that got cropped.Held to the receipt's standard deliberately. The receipt has been the only content type in this library anybody art-directed; everything else went through one
fillTextloop in the system serif.textgainedtrackingandvalignTracking is the control display type cannot do without: a line set to be read across a room needs it pulled in, small uppercase needs it pushed out, and neither is reachable by changing the size. It is applied before measuring, because
measureTexthonoursletterSpacingand wrapping against the untracked width breaks lines to a measure the painted line does not have.valign: 'center'optically centres the block rather than hanging it from the top edge — what a label or a poster wants, wheretopis what a letter wants because a letter starts at the top of the page. Both default to the old behaviour.Line breaking is shared, and it no longer lets type leave the sheet
wrapLinesis one module now rather than a loop about to be copied into a second painter — two copies of a line-breaker is two answers to "where does this wrap", and on a sheet that CURLS the reader sees the break land on a fold.It also fixes a real bug. The old loop appended a word whenever the line was empty, on the reasonable theory that one word always fits. A long URL or a compound on a narrow banner does not, and it ran off the edge of the sheet with nothing to stop it. A sheet is a physical object: type that leaves it has left it. Over-long words are now broken to the measure, and a blank line survives as a paragraph break instead of collapsing.
Fonts are requested by name
document.fonts.ready— which this library already awaited — resolves when the fonts the document requested have settled. A family named only inside a canvasctx.fontstring was never requested by anything, so on a page where no DOM element uses it,readyresolves immediately and the sheet paints in the fallback: Times where the preset says Playfair, silently and only sometimes.ensureFontcallsdocument.fonts.load()for the face the content actually names before painting. Failures are swallowed on purpose — a font that will not load is a fallback, not an exception.No built-in preset touches the network
All four of the presets that fetched Unsplash are fixed, not the two originally counted.
hero-peelandhanging-posterwere demonstrating a behaviour; the photograph was incidental. They are typeset now —hanging-posteris a real poster, which is what every paper installation worth the name hangs.photo-printandpostage-stampare containers for the caller's own art, and their whole documented use is<PaperField images={photos} preset="photo-print" />.image.srcnow defaults to empty, and empty renders as bare stock rather than as a failure. An image that fails to load falls back the same way instead of leaving the sheet with no texture at all.
A live third-party fetch inside the first thing a new user renders fails offline, behind a corporate proxy, under a strict CSP, and on the day the URL changes.
The Field composer's default is paper
The demo pool was eight HSL gradient tiles with a translucent white disc on each — the right instinct (procedural, offline, nothing to leak) attached to the wrong art direction, and the single most damaging screenshot in the product: a library about paper greeting every visitor who clicked Field with a carousel of app-icon swatches.
It is eight
cardartifacts now — a mill specimen, a due-date card, a telegram, a catalogue label, an index card, a ticket stub, a note, an archive label — and the default population isblank-sheetrather thanphoto-print, because a museum label printed on gloss photo stock is the wrong material. They are content rather than images, so they are not photographs of paper: they are typeset by the same painter that sets every other sheet, on the slot's own stock, and they curl with the mesh.Three parsed-vs-input type slips, found by adding two fields
Adding
trackingandvalignbroke the build in three places that had been quietly wrong:PaperStage's banner literal assertedsatisfies ContentConfig, andFieldPaperSlot.contentand the a11y mirror both took the parsed type.z.inferis the config with every default filled in;z.inputis what a caller may write. A literal a human types is by definition the input type, and demanding the parsed one turns a two-line content object into a type error — the exact failureconfig/props.test.tsexists to catch on the props.FieldPaperSlot.contenttakesContentConfigInputand is parsed internally.Worth noting how they were found: they only surfaced because the schema grew. Each was a tripwire on the schema rather than a check on the object, and they had all been passing by coincidence.
The docs' stock grid is a specimen sheet
Every stock now renders as a
cardunder the newrakingkey — the light a paper merchant photographs a swatch book under. It skims across the sheet instead of landing on it, which is the only way a stock's own character reads as material rather than as tint.cardContentSchema,CardContentandwrapLinesare exported. -
5dea79c: The tone curve is part of the lighting rig, stage mode has a print pass, and the source is a real light instead of a decal.
Three changes that turned out to be one change, because each of the first two only works if the third is true.
light.film— the curve, whereexposurewas already the stop<PaperStage>pinnedACESFilmicToneMappingon its canvas inonCreated;<Paper>never set one and took whatever R3F defaults to. The two modes could disagree about the film while agreeing about everything else, and neither could be told otherwise without forking the component. It islight.filmnow, resolved byresolveLightingwith everything else and applied byPaperLightingbeside the exposure it already owned — so a stage and a lone<Paper>under the same preset are printed identically by construction.Every preset ships
neutral— Khronos PBR Neutral — and the first attempt at this shippedagx, which was wrong. The reasoning for AgX was sound and the render disagreed: rendered through all three onnave, AgX and ACES both bleach a warm clipping source toward grey-white, because both desaturate hard as they approach white. On a hall whose entire subject is warm light coming through paper, that removes the thing you came for. Neutral is built specifically to hold hue and saturation through the roll-off, and it is the only one of the three that keeps the light warm.agxandfilmicremain selectable.stage.grade— bloom, tone curve, vignette, grain{ bloom, threshold, vignette, grain }, serialized like everything else. It lives on the stage rather than on the lighting rig — the rig is read by<Paper>too, and<Paper>has no composer, so a grade in the rig would be a promise one of the two modes could not keep.A composer takes the tone curve away from the renderer, so the chain has to give it back.
<EffectComposer>setsgl.toneMapping = NoToneMappingfor as long as it is mounted, and it is right to: tone mapping belongs at the end of a post chain, not the end of the scene pass, and a frame mapped twice is wrong twice. What that means is that a composer mounted without a<ToneMapping>effect silently discardslight.filmentirely. The chain isBloom → ToneMapping → Vignette → Noise— bloom while the frame is still HDR, tone mapping to land it in display range, and the two darkroom moves on the finished print.thresholdis in linear light and defaults to 1.6, above 1.0 on purpose, and its bound is 4 rather than 1. Because bloom reads the scene before the curve, 1.0 means "as bright as white" rather than "the brightest thing on screen". Lit near-white stock sits near 1.0 unaided; a threshold under 1 blooms the paper, fogs the hall, and costs every sheet its edges.The source is an emitter now, not a decal
Source— the bright void the walk resolves toward — was ameshBasicMaterialwithtoneMapped: false. That is a workaround for not having a post chain, and it stops working the moment there is one: a composer maps the whole framebuffer at the end, so a material that opted out of the renderer's curve is not exempt from the composer's. The source came out crushed to a flat grey panel — the one thing in the scene that must never look like a panel.It burns at
SOURCE_INTENSITY(3.4× white, in linear light) and is tone-mapped like everything else. This is both the fix and the more honest description: light is brighter than white, that is what makes it light, a curve rolling off a value above 1.0 is what gives a source falloff instead of an edge, and it is the only thing bloom can key off.Surroundhas spent several versions fighting the same problem with a seven-stop alpha ramp, in geometry, which was the wrong layer.Two lighting presets built for paper as a material
rakingandlightboxbring the set to eight.rakingputs a hard key eight degrees above the horizon and well off to one side, so it skims ACROSS a sheet rather than landing on it. It is how a paper merchant photographs a swatch book, and it is the only rig in the set that turns a fold, a crease or a crumple into relief instead of shading. Ambient and studio are the lowest in the set on purpose: raking light works by the shadows it casts, and fill is exactly what erases them. Measured againststudiooncrumpled-note, the difference is not subtle — every crease facet resolves as a distinct light or dark plane wherestudiorenders a soft white sheet with a suggestion of texture. First cut hadambient: 0.06, which took the shadow side to near-black and made the relief read as holes; lifted to 0.09.lightboxputs the lamp behind the sheet and level with it. Every other front-lit rig shows ink ON paper; this shows light THROUGH it, whichtranslucencyhas been able to render since it became a per-stock number and which no preset had ever made the subject. Printed atexposure: 0.85for the same reasonnaveis under: a backlit sheet carries the lamp's whole intensity as transmission and clips to flat white at 1.0.One honest limit, found by building
rakingand looking at it. The surface effects —grain,aging,deckle,creaseLines— are albedo and alpha, not normal perturbation. There is no bump map anywhere insurface/compose.ts. So a grazing key reveals geometry beautifully and reveals surface texture not at all: a crumple lights up, a sheet of aged newsprint does not. The pitch for this preset was originally "it reveals fibre and deckle as relief", and that half is not true yet. Making it true means perturbing normals in the surface shader, which is its own piece of work.Depth falloff, off by default
grade.depthis a real optical blur, and it defaults to 0 as a considered answer rather than a stub. Depth in this scene is already staged by haze — one fragment instruction, and how a real hall does it — while optical blur is a second full-screen pass and the effect most likely to read as a video game rather than a photograph. Every paper installation worth copying is shot deep. It ships because a shallow frame is a legitimate look for a close shot on one banner, and the schema is the only place a look is allowed to live. The first mapping focused eight units out, which put the focal plane in the empty air past the paper and left nothing in frame sharp; it now focuses at roughly three units, where the banner you are standing in front of actually is.Calibration: the six original presets needed no numbers changed
Rendered under Neutral through the new
pnpm shot:lightharness. They hold, and the warm ones —goldenhour,window,leaves— actively improve, because the key colour ACES was desaturating now survives the roll-off.noirkeeps its crushed shadows and its contrast. This is the answer to "re-calibrate against the new film": measured, and the answer is that the film change was in their favour.A harness for judging light, and a bug it found
pnpm shot:lightphotographs one paper preset under one lighting preset headless —--allsweeps every rig in one run. Nothing here could previously answer "what does this rig do to a sheet?" without a human opening the editor.Building it surfaced a real defect in
apps/editor/media.html: it drew the lamps with<PaperLighting>but never published the rig with<LightRig>, so every sheet computed its backlit transmission against its ownscene.lighting—studio, a front key — while the actual lamp stood behind it. That is precisely the disagreementresolveLightingexists to prevent, and it is whylightboxfirst rendered as a flat grey sheet. The README's motion assets are recorded through that same entry.Shipping details
BREAKING: stage mode moves to its own entry point.
import { PaperStage } from 'paperlab/stage'— likewisegetStagePreset,walks,stageSchema,buildStageAgentPayloadand the rest of the stage surface. The main entry is unchanged for<Paper>and<PaperField>.This reverses the decision recorded in
docs/roadmap.md, and the reason it is allowed to is that it is a different argument. That decision was about BYTES, and it was right about bytes: tree-shaking already kept stage code out of a<Paper>bundle, so a subpath saved nobody a byte. This is about RESOLVABILITY, which tree-shaking cannot fix. Tree-shaking removes the code; it cannot remove the import specifier. While the main entry named@react-three/postprocessing, a consumer who installed paperlab for<Paper>alone — and believed the word "optional" — got an unresolvable module at build time. The peers were briefly shipped asoptionalon exactly that false premise.Measured on the built package, which is the only way this claim is worth anything:
occurrences of postprocessingdist/index.js(main, ESM)0 dist/index.cjs(main, CJS)0 dist/stage.js4 And end to end: with both packages uninstalled, bundling
export { Paper } from 'paperlab'now succeeds and contains zero references. Before the split the same build failed to resolve.@react-three/postprocessingandpostprocessingarepeerDependenciesMeta.optionalagain, and this time it is true.threepeer floor rises to>=0.162.NeutralToneMappinglanded in r162 and is now the default film, so>=0.160would have handed r160/r161 usersundefined.The print runs on the
hightier only, measured rather than assumed.pnpm perf --soft(SwiftShader, the weak-machine floor): switching it on atmediumtook the frame 51.0 ms → 92.2 ms, 20 fps to 11, whilelow— which never had it — held at 26.1 → 28.4 ms. The control is what makes the ~40 ms readable as the grade and not the weather.mediumis the tierautostarts at, so paying it there pushes weak machines down tolow, where they lose the environment light and the shadow map to buy a bloom. Switching it back off returnedmediumto 52.9 ms.filmNames,FilmName,stageGradeSchema,StageGradeConfigandSOURCE_INTENSITYare exported.pnpm shottakes--film,--bloom,--threshold,--vignetteand--grain. -
10952fe: The Ribbon: a strip hung the full drop of a room, pooling where it lands.
The strongest image in the reference set, and the payoff for the four phases before it — it needs a room with a ceiling to hang from, hardware to hang by, type that can be set down a length without reading as a caption, and a crease that begins at the floor line rather than at the sheet's centre. Ships as the
ribbonbehavior, thepaper-ribbonpreset, and theribbonstage.What it is made of, and two things the render corrected
A ribbon is folds down its length plus a hinge where it meets the ground. Both already existed, so it is a behavior rather than a deformer — but neither of the obvious choices survived contact with a render.
wave, notdrape.drapeis the obvious deformer for folds down a hanging sheet, and it renders an invisible sheet on the hero (CPU) path — at any grid, including an explicitly fixed one. See below; it is written up as an open bug.wavepinned at the top is the same picture by another road, and is proven on both paths.fold, notroll. A roll wraps the pooled length around a cylinder, so it curls up and over and finishes in mid-air: a hook, not a pool. Paper meeting a floor does not wrap — it creases and lies down. A soft hinge at the floor line does exactly that, and the length below it runs out flat along the ground.The hinge is placed from the sheet, which makes this the one behavior that genuinely needs the second argument to
stack(): "a pool-length above the bottom edge" is meaningless without a height. Its radius scales with the sheet too, because a fixed hinge that reads as a fold on a short strip reads as a knife-edge on a long one.progressParamiscurlrather thanpool, and for a mechanical reason: the grid is sized by sampling that parameter from 0 to 1, so it has to be a 0..1 parameter — apoolbounded at 0.5 would be sampled across a range it rejects — and it should be the one that drives the geometry hardest.colonnade.hovermay go below zero nowIts own comment claimed "0 = they pool on it", and at 0 a banner's bottom edge sits on the floor, which is not the same thing at all. A ribbon creases a pool-length above its bottom edge, so it has to hang that much lower for the crease to land on the ground — otherwise the slack lies flat in mid-air, parallel to a floor it never touches.
The bound was
min(0), so the one thing the option documented itself as doing was the one thing it could not do. It ismin(-0.5)now, and theribbonstage setshoverto exactly minus its pool fraction.An open bug this turned up
draperenders nothing on the hero path. Not faintly — the frame contains one colour, the background. Ruled out in order: the math (swept across its whole option range, every vertex finite and bounded), tessellation (an explicitsegments: 96renders the same blank), and the sheet and content (identical ones render fine underhang). Isolated by bisecting the stack:rollalone renders,drapealone is blank, both together blank.Nobody had hit it because
drapehad exactly one caller in the library — the stage banner — and that runs the field/GPU path and its GLSL twin. No behavior and no paper preset had ever put it on the CPU side. Which is worth stating plainly: a parity gate proves the two implementations agree, not that either one draws. Written up indocs/roadmap.md.The stage
Tighter than the banner stages and hung lower, with a raised camera and a short look-ahead — pooled paper lies flat, so from standing height it foreshortens to a sliver and the shot has to get above it for the thing this stage is about to read at all. Twelve strips, a low ceiling so the drop reads as the height of the room rather than as a short thing in a tall one, and the suspension threads finally in frame where they were built to be.
-
2cfa737: Paper hangs from something now, and it can have landed.
Two primitives, shared by three of the four gallery stages still to come — built once here rather than three times later.
settle— the pose after the fallThe library could drop paper (
fall), fly it (fly,flight), heap it (pile) and catch it mid-air (spill), and had no way at all to show a sheet that has arrived. Every reference installation worth copying has paper on the floor: sheets settled on concrete after the fall, ribbons pooling where they meet the ground. It is the most beautiful detail in the set and it appears there twice.The distinction from
fallis not the shape, it is the clock.fallflutters — its wave carriesspeed: 1.3, because it is a sheet still arguing with the air. This one is over. Everything insettleis static, and that is the point: a settled sheet that ripples is a settled sheet nobody believes. It also costs a per-frame re-deform forever, for motion that should not be there. A test asserts it at every setting.{ relax, lift, corner, slack }.relaxis how long ago it landed;liftis how hard the stock resists lying flat, and it is the floor under the relaxing — tissue surrenders completely, card never does. Relaxing subtracts; stiffness is what it will never give back.It is a behavior, not a deformer, because a landed sheet is a gentle curl the stiffness held on to plus a long slack undulation where it bridges the floor — and both already exist. A deformer that can be spelled out of the ones we have does not earn a GLSL twin and a parity case.
The first version rendered a flat rectangle, which is the one outcome it exists to avoid: the corner lift was scaled below
fall's, when a settled sheet should keep more than a falling one — that corner is the thing gravity could not take from it. Recalibrated againstfall's own numbers and pinned by a test.Ships with a
settled-sheetpreset.Suspension — what holds the paper up
Every paper installation shows its hardware: monofilament from a ceiling grid, steel wire, bulldog clips, a rod. In the scattered-sheet pieces the threads are half the composition. Stage mode's banners hung from nothing at all, which is a larger realism gap than any shader in the backlog and closes for a few thin lines of geometry — a hung thing that shows what suspends it stops reading as a rectangle that happens to float.
stage.suspensionis{ type: 'thread' | 'none', color, clips }.Both halves are one draw call each. The threads are a single
LineSegmentsbuffer rather than N line meshes, and the clips are anInstancedMesh, because a field of forty banners is drawn in one call and it would be absurd for the string holding them up to cost eighty more.Two details that are easy to get wrong and are pinned by tests. A thread attaches to the sheet's own top edge, rotated the way the pose rotates it — so a tilted banner's thread follows its top rather than rising from a point above its centre. And the clips are sized off the sheet rather than in world units, so a clip on a postage stamp and a clip on an eight-metre banner both look like a clip.
The threads deliberately cast no shadow: a shadow map at this scale renders monofilament as a black bar across the floor, far more visible than the thread itself and completely wrong.
Worth knowing where you will and will not see it. The colonnade stages are framed at eye level down an aisle, and the banners are tall enough that their tops — and therefore their threads — sit above the frame. It reads in a
wideor raised shot, and it will matter properly in the gallery stages, where paper hangs at varying heights in view. That is what it was built for.
Patch Changes
-
435367d: A callback prop is a notification, not a dependency — the last two places that had it the other way round.
Both are the shape that made the editor feel frozen in stage mode a fortnight ago, found in the same sweep and left open because neither was a loop. They are closed now.
<DropZone>re-registered on every render of the page above it. The registration effect namedonPlacein its dependency list, and the natural way to pass that prop is an inline arrow — a new function every render. So a consumer re-rendering for any reason at all tore the zone out of the registry and put it back, which bumped the registry version, which re-rendered everyDropZoneVisualin the field. It was churn rather than a loop only because the effect's own component is not what the version change re-renders, and that was luck rather than design. The registration now depends on what the zone is — its id, bounds, accept globs and highlight — and reaches the callback through a ref at the moment a paper is actually placed.<PaperStage>re-parsed its whole schema on every render.stagearrives from an editor or a page as a fresh object literal, so keyingstageSchema.parseandgetWalkPathon its identity meant both ran again for a value that had not changed. Harmless once per render and never wrong, but it is why each iteration of that earlier feedback loop cost as much as it did. Now on serialized deps, the wayPaperFieldMeshalready did it.Neither changes an API or a rendered frame. Both remove work that a well-behaved consumer could not have avoided doing.
-
ff76e4a: Four crashes, one shape: a value reaching a strict parse from inside a render.
The report was "when I interact with anything the whole app freezes", then "whenever I try to manage speed the app gets closed". Both were real, neither was what it sounded like, and the second one turned out to be a class rather than a bug.
The freeze: a notification that had become a pump
<PaperStageScene>reported its settled quality tier from an effect that named the callback in its own dependency list. The natural way to pass that prop is an inline arrow, which is a new function on every render of the page above — so the effect fired on every consumer render, not on every tier change. The consumer stores the tier, which re-renders, which makes another arrow, which fires the effect again.Measured at ~6 App renders a second at rest in stage mode, each one a full
stageSchema.parseand walk resample. That is why every interaction felt frozen, and why dragging the scrubber could take the tab out with an out-of-memory crash. The callback now lives in a ref and the effect depends ontieralone.The general rule, since it is not specific to this prop: a callback prop is a notification, not a dependency. If an effect exists to tell the consumer something, it depends on the thing being told and reaches the callback through a ref.
The crash:
.int(), and everywhere else the same shape hidThe editor's generated sliders took two facts off a schema —
minandmax— and derived a step of(max - min) / 200. They never read.int(). So touchingseedon a colonnade wrote2.5into a field declaredz.number().int(), and<PaperStageScene>re-parses its layout options during render to place the walk's stops. A strict parse does not warn about a fraction; it throws, inside a render, which unmounts the tree.Ten fields across the library carry
.int(). Fixed once, in the control model: an int field getsstep: 1and its emitted value is rounded, because the readout you can type into clamps but never snaps.Asking where else that shape hid found three more:
- A second copy of the schema walk in the editor's states bar, missing
.int()in exactly the same way, crashing through a different parse (resolveFieldSlotConfig, also during render). Fixed by deleting the copy — there is now one reader of az.ZodNumber. - Exclusive bounds.
.positive()is stored asmin: 0, inclusive: false— one boolean away from.min(0)— and reading the value while dropping the boolean gives a slider whose end is the one number the schema rejects. Latent; handled anyway. - The same shape on the text side, and live. A stage's sky colours are text fields, and a text field emits per keystroke, so the library is handed
#fand#ffwhile somebody types#ffaa22.addColorStopis one of the few canvas calls that throws rather than ignoring what it cannot parse, and the sky is built during render. Three.js is the forgiving one, which is why the gradient was the only path that broke.cssColorOrnow asks a canvas whether a string is a colour — the canvas's own opinion rather than a regex, because CSS colours are a larger set than a regex should be trusted with.
The rule worth keeping: a schema is a contract in both directions. Anything generated from one has to emit what that schema accepts, because the code receiving it is entitled to parse strictly — and a strict parse inside a render is an app-level crash, not a validation message.
Also: dependency arrays are evaluated every render
PaperFieldMesh,FitCamera,useContentAtlasand the resolved-config memo all usedJSON.stringifyas a memo dependency. A dependency array is evaluated on every render, so the serialization was paid every render whether or not anything changed — and paid in garbage rather than in time. A field of fourteen photographs re-serialized roughly seventeen megabytes per render, because an image slot carries its bitmap inline as a data URL.Replaced with
useStable, a deep compare that allocates nothing and short-circuits onObject.isat every level, so the common case — a fresh wrapper around the same inner objects — costs a handful of pointer checks however large the data URL underneath. - A second copy of the schema walk in the editor's states bar, missing
-
2eba685: The ribbon stage renders what it is for, and banner type is set to the measure.
ribbon's crease could not reach a right angle.foldAnglewas62 + curl * 46, so below curl 0.61 — including the default, and including the value the ribbon stage shipped — the pooled length was still travelling downward when it passed the crease and went through the floor. Above 0.61 it tilted back up and floated. A hinge turns through one angle and the flap holds that heading, so only 90° is the floor: it is fixed at 90 now, andcurldrives the crease radius, which is what its own description always said it did. The crease is also placed a hinge-radius higher, because the flap leaves the hinge cylinder that much below the crease line — measured at ~9cm under the floor on the stage's own numbers.ribbonusesdrapeagain. It had been switched towaveto work around a report thatdraperendered an invisible sheet on the CPU path. That report does not reproduce; it rested on counting colours in a screenshot, and a near-flat strip filling the frame has about as many colours as an empty one.wavewas never the same picture either — a sine runs at one amplitude end to end, and a hung strip is flat where it is held and gathers as it falls.Banner type was sized by the drop and never by the measure. On a tall narrow banner the chosen size was wider than the sheet, so every word was broken wherever the measure ran out and the column then overran the drop and was clipped.
bannerTextSizenow takes the longest word and the measure (bannerMeasurestates how much room there is, once), and a single-word column is set one letter to a line on purpose (letterColumn) instead of being shattered at arbitrary points —carriedreads down its banner rather than asca / rr / ie / d. Columns are centred down the drop, since one size is shared by the whole rank.splitAcrossBannersalso dealt with a stride that dropped banners: twenty words over twelve gave ten columns and left two blank.New:
deformers/draws.test.ts— every registered deformer, built into a real sheet on two aspect ratios, asserted to be finite, actually moved, still to have most of its area, and to have unit normals. A parity gate proves the two implementations agree, not that either one draws; this is the missing half, and a new deformer cannot skip it.